Stems UI
Menu

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-group

Requires 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's ToggleGroup):
    • value: pressed button values. Controlled when not undefined.
    • 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 to false: 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 to true.
    • handle.context.get(toggleButtonGroup.Context) returns { value, multiple, disabled, orientation, loopFocus }.
  • root(): sets role="group", data-orientation, data-disabled, and data-multiple. Name it with aria-label or aria-labelledby.
  • ToggleButtonGroupChangeEvent: bubbling stems:toggle-button-group-change event with the next value, dispatched from the button that changed. The button also dispatches its own stems: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 own pressed / defaultPressed are ignored, onPressedChange still fires, and the group's disabled applies. Give every button a value; 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 reason is always 'none', so Stems doesn't pass one.
KeyAction
ArrowRight / ArrowLeftNext / previous button (horizontal; mirrored in RTL)
ArrowDown / ArrowUpNext / previous button (vertical)
Home / EndFirst / last enabled button
Space / EnterPress or release the focused button

Styling hooks

PartAttributePresent when / value
rootdata-orientation"horizontal" or "vertical"
rootdata-disableddisabled is set
rootdata-multiplemultiple is set
buttondata-pressedThe button is pressed
buttondata-disabledThe 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's Toggle):
    • pressed: controlled pressed state. The button is controlled when this is not undefined.
    • defaultPressed: initial uncontrolled state. Defaults to false.
    • onPressedChange(pressed, event): called with the next state and the DOM event that caused it.
    • disabled: ignore interaction. A disabled (or aria-disabled) host prop works too.
    • value: identifies the button inside a toggle button group. Included in change events.
  • ToggleButtonChangeEvent: bubbling stems:toggle-button-change event with pressed and value, dispatched from the button.
  • onToggleButtonChange(handler, capture?): typed listener mixin for ToggleButtonChangeEvent.

Behavior notes

  • Prefer a <button> host. It gets type="button", native disabled, 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 (by value): pressed / defaultPressed are ignored, the group's disabled applies, and the buttons share one tab stop (tabIndex 0 / -1) moved with the arrow keys. See stems-ui/toggle-button-group.
  • A toggle button doesn't submit a value with a form. Use stems-ui/switch for a form field.
  • Explicit host props win (attrs() semantics), for example type="submit".
KeyAction
SpaceToggles (native on <button>, keyup on other hosts)
EnterToggles (native on <button>, keydown on other hosts)

Styling hooks

Boolean attributes are present ("") or absent. aria-pressed is always "true" or "false".

AttributePresent when
data-pressedThe button is pressed
data-disabledThe button is disabled