Toggle Group
stimeo--toggle-group
A row of buttons that hold a pressed state, for picking one or picking several.
A row of buttons that hold a pressed state. Whether one is pressed is announced while its name stays the same, because a name that swaps underneath confuses anyone relying on it. The whole row is a single Tab stop and the arrow keys move within it. It can take one choice or several. A button that cannot be used is skipped by the arrow keys, while one marked unavailable stays reachable but does nothing. Buttons added later work without setup of their own. You are told when someone presses one, and told separately when the page changes which ones are pressed.
Multiple selection
Single selection
Unavailable items
Keyboard
| Key | Action |
|---|---|
| Space / Enter | Toggle the focused button. |
| → / ↓ | Move focus to the next button (wraps). Under RTL only the horizontal pair swaps. |
| ← / ↑ | Move focus to the previous button (wraps). Under RTL only the horizontal pair swaps. |
| Home / End | Move focus to the first / last button. |
<%# Markup for the toggle-group demo: multiple selection, single selection, and
unavailable items. The group delegates click and keydown, so the items carry
no data-action of their own. %>
<div class="toggle-group-demo-suite">
<section class="toggle-group-demo__example">
<h3 class="toggle-group-demo__heading" id="toggle-group-multiple-heading">
<%= t("components.toggle_group.demo.multiple_heading") %>
</h3>
<div class="toggle-group-demo" role="group"
aria-labelledby="toggle-group-multiple-heading"
data-controller="stimeo--toggle-group"
data-stimeo--toggle-group-name-value="styles[]">
<%# The container the pressed items' data-value are generated into as hidden
inputs; it is optional. %>
<div data-stimeo--toggle-group-target="fields"></div>
<button type="button" class="toggle-group-demo__item" aria-pressed="false" tabindex="0"
aria-label="<%= t('components.toggle_group.demo.bold') %>" data-value="bold"
data-stimeo--toggle-group-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 4h8a4 4 0 0 1 0 8H6z"></path>
<path d="M6 12h9a4 4 0 0 1 0 8H6z"></path>
</svg>
</button>
<button type="button" class="toggle-group-demo__item" aria-pressed="false" tabindex="-1"
aria-label="<%= t('components.toggle_group.demo.italic') %>" data-value="italic"
data-stimeo--toggle-group-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</svg>
</button>
<button type="button" class="toggle-group-demo__item" aria-pressed="false" tabindex="-1"
aria-label="<%= t('components.toggle_group.demo.underline') %>" data-value="underline"
data-stimeo--toggle-group-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 3v7a6 6 0 0 0 12 0V3"></path>
<line x1="4" y1="21" x2="20" y2="21"></line>
</svg>
</button>
</div>
</section>
<section class="toggle-group-demo__example">
<h3 class="toggle-group-demo__heading" id="toggle-group-single-heading">
<%= t("components.toggle_group.demo.single_heading") %>
</h3>
<div class="toggle-group-demo" role="group"
aria-labelledby="toggle-group-single-heading"
data-controller="stimeo--toggle-group"
data-stimeo--toggle-group-mode-value="single"
data-stimeo--toggle-group-name-value="align">
<%# single mode submits 0 or 1 value through the same container. %>
<div data-stimeo--toggle-group-target="fields"></div>
<button type="button" class="toggle-group-demo__item" aria-pressed="true" tabindex="0"
aria-label="<%= t('components.toggle_group.demo.align_left') %>" data-value="left"
data-stimeo--toggle-group-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" aria-hidden="true">
<path d="M4 6h16M4 10h11M4 14h16M4 18h9"></path>
</svg>
</button>
<button type="button" class="toggle-group-demo__item" aria-pressed="false" tabindex="-1"
aria-label="<%= t('components.toggle_group.demo.align_center') %>" data-value="center"
data-stimeo--toggle-group-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" aria-hidden="true">
<path d="M4 6h16M6 10h12M4 14h16M7 18h10"></path>
</svg>
</button>
<button type="button" class="toggle-group-demo__item" aria-pressed="false" tabindex="-1"
aria-label="<%= t('components.toggle_group.demo.align_right') %>" data-value="right"
data-stimeo--toggle-group-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" aria-hidden="true">
<path d="M4 6h16M9 10h11M4 14h16M11 18h9"></path>
</svg>
</button>
</div>
</section>
<section class="toggle-group-demo__example">
<h3 class="toggle-group-demo__heading" id="toggle-group-disabled-heading">
<%= t("components.toggle_group.demo.disabled_heading") %>
</h3>
<div class="toggle-group-demo" role="group"
aria-labelledby="toggle-group-disabled-heading"
data-controller="stimeo--toggle-group"
data-stimeo--toggle-group-name-value="filters[]">
<%# Unavailable items can never be pressed, so they never reach the container. %>
<div data-stimeo--toggle-group-target="fields"></div>
<button type="button" class="toggle-group-demo__item toggle-group-demo__item--wide"
aria-pressed="false" tabindex="0" data-value="available"
data-stimeo--toggle-group-target="item">
<%= t("components.toggle_group.demo.available") %>
</button>
<button type="button" class="toggle-group-demo__item toggle-group-demo__item--wide"
aria-pressed="false" tabindex="-1" data-value="native-disabled" disabled
data-stimeo--toggle-group-target="item">
<%= t("components.toggle_group.demo.native_disabled") %>
</button>
<button type="button" class="toggle-group-demo__item toggle-group-demo__item--wide"
aria-pressed="false" tabindex="-1" data-value="aria-disabled" aria-disabled="true"
data-stimeo--toggle-group-target="item">
<%= t("components.toggle_group.demo.aria_disabled") %>
</button>
</div>
</section>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* Presentation-only styles for the toggle-group demo.
* The pressed state is expressed via [aria-pressed="true"] (set by the library).
*/
.toggle-group-demo-suite {
display: grid;
gap: 1.5rem;
}
.toggle-group-demo__example {
display: grid;
justify-items: start;
gap: 0.5rem;
}
.toggle-group-demo__heading {
margin: 0;
color: var(--fg);
font-size: 0.9375rem;
font-weight: 700;
}
.toggle-group-demo {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.toggle-group-demo__item {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
color: var(--fg);
background: transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.toggle-group-demo__item--wide {
width: auto;
min-width: 2.25rem;
padding-inline: 0.75rem;
font: inherit;
font-size: 0.875rem;
}
.toggle-group-demo__item:hover {
background: var(--surface-subtle);
}
.toggle-group-demo__item:disabled,
.toggle-group-demo__item[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-group-demo__item:disabled:hover,
.toggle-group-demo__item[aria-disabled="true"]:hover {
background: transparent;
}
.toggle-group-demo__item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Fill with the accent color while pressed. */
.toggle-group-demo__item[aria-pressed="true"] {
color: var(--white);
background: var(--accent);
border-color: var(--accent);
}
.toggle-group-demo__item .demo-icon {
width: 1.1rem;
height: 1.1rem;
}
// The hidden fields mirror the pressed set, and for a press the user made they
// emit the native `change` a form already listens to — so `stimeo--auto-submit`,
// form validation, or any `change` handler on the `<form>` reacts without knowing
// what this component is. A refresh that follows a morph or a repair is silent,
// and the component reports a set the page moved — buttons added or removed, a
// `mode` change, a rewritten `aria-pressed` — as `stimeo--toggle-group:reconcile`.
// This demo reads both events, so the readout follows the user's presses and the
// page's moves.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--toggle-group-target='fields']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--toggle-group-target='fields'] input"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--toggle-group:reconcile", showSubmitted);
showSubmitted();
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--toggle-group"
Targets
| Name | Description | Attribute |
|---|---|---|
item
required
|
A <button type="button"> or non-interactive role="button" toggle; pressed state is aria-pressed. |
data-stimeo--toggle-group-target="item" |
fields
|
Optional container the pressed items' data-value are generated into as named hidden inputs. |
data-stimeo--toggle-group-target="fields" |
Values
| Name | Description | Attribute |
|---|---|---|
mode
|
single (0–1 pressed, others released) or multiple (any number; default). |
data-stimeo--toggle-group-mode-value |
name
|
The name the generated hidden inputs submit under. |
data-stimeo--toggle-group-name-value |
form
|
The form attribute given to the generated hidden inputs; empty leaves it off. |
data-stimeo--toggle-group-form-value |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Optional per-item action; Arrow/Home/End rove and Space/Enter activate generic hosts. | stimeo--toggle-group#onKeydown |
toggle
|
Optional per-item action that toggles the item. Delegated click/keydown work without either action. | stimeo--toggle-group#toggle |
Events
| Name | Description | Event |
|---|---|---|
change
|
User activation only; detail is { value, pressed, values, reason } in DOM order. detail.reason reports user / focus / pointer / api. |
stimeo--toggle-group:change |
reconcile
|
Fires when the page moves the pressed set — items added or removed, a mode change, an in-place aria-pressed or data-value write, a host the group stops managing; detail is { values } alone, since the move is to the set rather than one item. Not fired on connect. |
stimeo--toggle-group:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-pressed |
Item | "true" / "false" — the button's pressed state. |
tabindex |
Item | 0 on the active item, -1 on the rest (roving). |