Skip to content

Toggle group

hc-toggle-group is a connected row of toggle buttons, the shadcn ToggleGroup equivalent. It comes in two selection modes and ships a small behavior (installToggleGroup) for the keyboard and state logic; the CSS is the segmented-control skin.

The mode is set with data-type on the group and reflected by the ARIA roles on the buttons — choose the markup that matches the semantics.

Only one button can be on at a time. Per the WAI-ARIA APG, an exclusive set of toggles is a radio group even when it looks like buttons: use role="radiogroup" on the group and role="radio" + aria-checked on each button. Selection follows focus (arrow keys move and select), and the group can never be emptied by a click.

Each button toggles independently. Use role="group" on the group and aria-pressed on each button. Arrow keys move focus; Space / Enter / click toggle the focused button on and off.

data-size accepts sm, md (default), and lg on the group, drawn from the shared --hc-control-* scale (so data-density shrinks them consistently).

KeySingle (radio)Multiple (group)
TabEnters / leaves the group (one stop).Same.
/ Move focus to next + select it.Move focus to next.
/ Move focus to previous + select it.Move focus to previous.
Home / EndFirst / last enabled + select.First / last enabled.
Space / EnterSelect the focused button.Toggle the focused button.

Navigation wraps around the ends and skips disabled buttons (disabled or aria-disabled="true"). The group is a single Tab stop via a roving tabindex.

import { installToggleGroup } from '@hypermedia-components/core';
installToggleGroup(); // idempotent; returns an uninstaller

The zero-config @hypermedia-components/core/behaviors entry installs it automatically. Every change dispatches a bubbling hc:togglegroupchange on the group:

group.addEventListener('hc:togglegroupchange', (e) => {
// single → { type:'single', value, item, group }
// multiple → { type:'multiple', values, item, pressed, group }
console.log(e.detail);
});

value / values come from each button’s data-value attribute.

Set data-name="X" on the group and the behavior maintains hidden inputs so it serialises like a native control — one <input type="hidden" name="X"> for the checked value (single), or one per pressed value (multiple). No JS wiring needed on the server side.

<div class="hc-toggle-group" role="radiogroup" data-type="single"
data-name="view" aria-label="View">
<button type="button" class="hc-toggle" role="radio"
aria-checked="true" data-value="grid">Grid</button>
<button type="button" class="hc-toggle" role="radio"
aria-checked="false" data-value="list">List</button>
</div>
<!-- submits view=grid -->

Drive a request straight off the change event — e.g. a segmented view switcher that swaps a region:

<div class="hc-toggle-group" role="radiogroup" data-type="single"
aria-label="View"
data-hx-get="/reports"
data-hx-trigger="hc:togglegroupchange"
data-hx-include="this"
data-hx-target="#report"
data-name="range">
<button type="button" class="hc-toggle" role="radio" aria-checked="true" data-value="7d">7d</button>
<button type="button" class="hc-toggle" role="radio" aria-checked="false" data-value="30d">30d</button>
<button type="button" class="hc-toggle" role="radio" aria-checked="false" data-value="90d">90d</button>
</div>

data-hx-include="this" picks up the hidden input the data-name integration writes, so the request carries range=7d etc.

Reflect the selection inline (the detail carries value for single, values for multiple):

<div class="hc-toggle-group" role="radiogroup" data-type="single" aria-label="View"
_="on hc:togglegroupchange add .is-{event.detail.value} to #grid">
</div>

More patterns: Hyperscript → Reacting to component events.

  • Give the group an accessible name with aria-label (or aria-labelledby).
  • Pick the role that matches the semantics: an exclusive choice is a radio group, not a set of independent toggles. Using aria-pressed for an exclusive choice misleads screen-reader users into thinking several can be on at once.
  • The behavior keeps aria-checked / aria-pressed and the roving tabindex in sync; you only set the initial state in markup.
  • Disabled buttons are skipped by keyboard navigation.

Component tokens (in component.tokens.json):

Token pathPurpose
toggle.height / padding-x / radius / font-size / font-weightBox metrics (height + padding-x are density-aware).
toggle.fg / bg / borderResting colors.
toggle.hover-bg / hover-fgHover state.
toggle.on-bg / on-fg / on-borderSelected / pressed state — the accent tracks data-color.
toggle.disabled-fg / disabled-bgDisabled state.
toggle.sm.* / lg.*Size variants.
Show the generated CSS variables
--hc-toggle-height | -padding-x | -radius | -font-size | -font-weight
--hc-toggle-fg | -bg | -border
--hc-toggle-hover-bg | -hover-fg
--hc-toggle-on-bg | -on-fg | -on-border
--hc-toggle-disabled-fg | -disabled-bg
--hc-toggle-sm-* | -lg-*
  • Button — for single actions rather than persistent state.
  • Tabs — when each option reveals a different panel of content.
  • Switch — for a single on / off setting.