Toggle Button Group
A set of toggle buttons with shared state.
Formatting: bold
Alignment: left
Codeapp/demos/public/toggle-button-group.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
ToggleButtonGroup,
ToggleButtonGroupItem,
} from '@stemsui/registry/ui/toggle-button-group.tsx'
function Icon(handle: Handle<{ paths: string[] }>) {
return () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{handle.props.paths.map((d) => (
<path d={d} />
))}
</svg>
)
}
const bold = ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8']
const italic = ['M19 4h-9', 'M14 20H5', 'M15 4 9 20']
const underline = ['M6 4v6a6 6 0 0 0 12 0V4', 'M4 20h16']
const alignLeft = ['M21 5H3', 'M15 12H3', 'M17 19H3']
const alignCenter = ['M21 5H3', 'M17 12H7', 'M19 19H5']
const alignRight = ['M21 5H3', 'M21 12H9', 'M21 19H7']
export const ToggleButtonGroupDemo = clientEntry(
import.meta.url,
function ToggleButtonGroupDemo(handle: Handle) {
let formatting: string[] = ['bold']
let alignment: string[] = ['left']
return () => (
<div class="flex flex-col items-center gap-6">
<div class="flex flex-col items-center gap-2">
<ToggleButtonGroup
aria-label="Text formatting"
multiple
variant="outline"
value={formatting}
onValueChange={(value) => {
formatting = value
void handle.update()
}}
>
<ToggleButtonGroupItem value="bold" aria-label="Toggle bold">
<Icon paths={bold} />
</ToggleButtonGroupItem>
<ToggleButtonGroupItem value="italic" aria-label="Toggle italic">
<Icon paths={italic} />
</ToggleButtonGroupItem>
<ToggleButtonGroupItem value="underline" aria-label="Toggle underline">
<Icon paths={underline} />
</ToggleButtonGroupItem>
</ToggleButtonGroup>
<p class="text-sm text-muted-foreground" data-testid="formatting-value">
Formatting:{' '}
<code class="font-mono text-foreground">
{formatting.length ? formatting.join(', ') : '(none)'}
</code>
</p>
</div>
<div class="flex flex-col items-center gap-2">
<ToggleButtonGroup
aria-label="Text alignment"
variant="outline"
spacing={0}
value={alignment}
onValueChange={(value) => {
alignment = value
void handle.update()
}}
>
<ToggleButtonGroupItem value="left" aria-label="Align left">
<Icon paths={alignLeft} />
</ToggleButtonGroupItem>
<ToggleButtonGroupItem value="center" aria-label="Align center">
<Icon paths={alignCenter} />
</ToggleButtonGroupItem>
<ToggleButtonGroupItem value="right" aria-label="Align right">
<Icon paths={alignRight} />
</ToggleButtonGroupItem>
</ToggleButtonGroup>
<p class="text-sm text-muted-foreground" data-testid="alignment-value">
Alignment: <code class="font-mono text-foreground">{alignment[0] ?? '(none)'}</code>
</p>
</div>
<ToggleButtonGroup
aria-label="View"
orientation="vertical"
size="sm"
defaultValue={['list']}
>
<ToggleButtonGroupItem value="list">List</ToggleButtonGroupItem>
<ToggleButtonGroupItem value="grid">Grid</ToggleButtonGroupItem>
<ToggleButtonGroupItem value="board" disabled>
Board
</ToggleButtonGroupItem>
</ToggleButtonGroup>
</div>
)
},
)
Installation
npx shadcn add @stemsui/toggle-button-groupRequires the one-time setup. Behavior: stems-ui/toggle-button-group, stems-ui/toggle-button.
API reference
The styled component is built on the headless modules below: its props mirror these options, and the same events and data attributes apply.
stems-ui/toggle-button-group
toggleButtonGroup shares a pressed-values array between toggle buttons from
stems-ui/toggle-button, in single or multiple mode, with roving
focus. App code owns the markup and visual styles.
Base UI equivalent: Toggle Group. It is named
toggle-button-group because Remix's toggle is a switch.
Usage
import { toggleButton } from 'stems-ui/toggle-button'
import * as toggleButtonGroup from 'stems-ui/toggle-button-group'
function Alignment() {
return () => (
<toggleButtonGroup.Context defaultValue={['left']}>
<div aria-label="Text alignment" mix={toggleButtonGroup.root()}>
<button aria-label="Align left" mix={toggleButton({ value: 'left' })}>
L
</button>
<button aria-label="Align center" mix={toggleButton({ value: 'center' })}>
C
</button>
<button aria-label="Align right" mix={toggleButton({ value: 'right' })}>
R
</button>
</div>
</toggleButtonGroup.Context>
)
}
Several pressed at once: <toggleButtonGroup.Context multiple>.
stems-ui/toggle-button-group
Context: owns the pressed values. Props (same as Base UI'sToggleGroup):value: pressed button values. Controlled when notundefined.defaultValue: initially pressed values (uncontrolled). Defaults to[].onValueChange(value, event): called with the next value and the DOM event that caused it.multiple: allow several pressed buttons. Defaults tofalse: pressing a button releases the others, and pressing the pressed one releases it (value[]).disabled: disable every button.orientation:'horizontal'(default; Left/Right, mirrored in RTL) or'vertical'(Up/Down).loopFocus: wrap arrow-key focus at the ends. Defaults totrue.handle.context.get(toggleButtonGroup.Context)returns{ value, multiple, disabled, orientation, loopFocus }.
root(): setsrole="group",data-orientation,data-disabled, anddata-multiple. Name it witharia-labeloraria-labelledby.ToggleButtonGroupChangeEvent: bubblingstems:toggle-button-group-changeevent with the nextvalue, dispatched from the button that changed. The button also dispatches its ownstems:toggle-button-change.onToggleButtonGroupChange(handler, capture?): typed listener mixin for the event.
Behavior notes
toggleButton({ value })inside the Context reads and updates the group: its ownpressed/defaultPressedare ignored,onPressedChangestill fires, and the group'sdisabledapplies. Give every button avalue; a button without one gets a generated id as its value.- Focus: the buttons form one tab stop: the last focused button, or the first enabled one. Arrow keys (and Home/End) move focus without pressing; Space and Enter press. Disabled buttons are skipped.
- Toggle buttons don't submit form values. Like Base UI's ToggleGroup, the group is not a Field
control, but an enclosing
<fieldset.Context disabled>disables it. - Base UI's change
reasonis always'none', so Stems doesn't pass one.
| Key | Action |
|---|---|
ArrowRight / ArrowLeft | Next / previous button (horizontal; mirrored in RTL) |
ArrowDown / ArrowUp | Next / previous button (vertical) |
Home / End | First / last enabled button |
Space / Enter | Press or release the focused button |
Styling hooks
| Part | Attribute | Present when / value |
|---|---|---|
| root | data-orientation | "horizontal" or "vertical" |
| root | data-disabled | disabled is set |
| root | data-multiple | multiple is set |
| button | data-pressed | The button is pressed |
| button | data-disabled | The button or the group is disabled |
Inside a toolbar
When a toggle button group is placed inside a toolbar.Context, it hands arrow-key navigation to
the toolbar so the whole toolbar has one tab stop. Add toolbar.item() (or toolbar.button()) to
each toggle button's mix so it joins the toolbar's roving focus.
stems-ui/toggle-button
toggleButton wires a two-state button that can be pressed or not (aria-pressed). App code owns
the markup and visual styles.
Base UI equivalent: Toggle. It is named
toggle-button because Remix's toggle is a switch (see stems-ui/switch).
Usage
import { toggleButton } from 'stems-ui/toggle-button'
function BoldButton() {
return () => (
<button aria-label="Toggle bold" mix={toggleButton({ defaultPressed: true })}>
B
</button>
)
}
Controlled:
function BoldButton(handle: Handle) {
let bold = false
return () => (
<button
aria-label="Toggle bold"
mix={toggleButton({
pressed: bold,
onPressedChange: (pressed) => {
bold = pressed
handle.update()
},
})}
>
B
</button>
)
}
Listen for changes on the button or any ancestor:
<div mix={onToggleButtonChange((event) => console.log(event.value, event.pressed))}>
<button mix={toggleButton({ value: 'bold' })}>B</button>
<button mix={toggleButton({ value: 'italic' })}>I</button>
</div>
stems-ui/toggle-button
toggleButton(options?): wires the button. Options (same as Base UI'sToggle):pressed: controlled pressed state. The button is controlled when this is notundefined.defaultPressed: initial uncontrolled state. Defaults tofalse.onPressedChange(pressed, event): called with the next state and the DOM event that caused it.disabled: ignore interaction. Adisabled(oraria-disabled) host prop works too.value: identifies the button inside a toggle button group. Included in change events.
ToggleButtonChangeEvent: bubblingstems:toggle-button-changeevent withpressedandvalue, dispatched from the button.onToggleButtonChange(handler, capture?): typed listener mixin forToggleButtonChangeEvent.
Behavior notes
- Prefer a
<button>host. It getstype="button", nativedisabled, and native keyboard activation. - Other hosts get
role="button",tabIndex=0(removed when disabled),aria-disabled, and keyboard activation. - Inside
<toggleButtonGroup.Context>the pressed state comes from the group (byvalue):pressed/defaultPressedare ignored, the group'sdisabledapplies, and the buttons share one tab stop (tabIndex0/-1) moved with the arrow keys. Seestems-ui/toggle-button-group. - A toggle button doesn't submit a value with a form. Use
stems-ui/switchfor a form field. - Explicit host props win (
attrs()semantics), for exampletype="submit".
| Key | Action |
|---|---|
Space | Toggles (native on <button>, keyup on other hosts) |
Enter | Toggles (native on <button>, keydown on other hosts) |
Styling hooks
Boolean attributes are present ("") or absent. aria-pressed is always "true" or "false".
| Attribute | Present when |
|---|---|
data-pressed | The button is pressed |
data-disabled | The button is disabled |