Stems UI
Menu

Toggle Button

A two-state button that can be on or off.

Bold off · Italic on

Codeapp/demos/public/toggle-button.tsx
import { clientEntry, type Handle } from 'remix/component'
import { ToggleButton, onToggleButtonChange } from '@stemsui/registry/ui/toggle-button.tsx'

function BoldIcon() {
  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"
    >
      <path d="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" />
    </svg>
  )
}

function ItalicIcon() {
  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"
    >
      <line x1="19" x2="10" y1="4" y2="4" />
      <line x1="14" x2="5" y1="20" y2="20" />
      <line x1="15" x2="9" y1="4" y2="20" />
    </svg>
  )
}

function UnderlineIcon() {
  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"
    >
      <path d="M6 4v6a6 6 0 0 0 12 0V4" />
      <line x1="4" x2="20" y1="20" y2="20" />
    </svg>
  )
}

export const ToggleButtonDemo = clientEntry(
  import.meta.url,
  function ToggleButtonDemo(handle: Handle) {
    let pressed: Record<string, boolean> = { bold: false, italic: true }

    return () => (
      <div
        class="flex flex-col items-center gap-4"
        mix={onToggleButtonChange((event) => {
          if (event.value) pressed[event.value] = event.pressed
          void handle.update()
        })}
      >
        <div class="flex items-center gap-2">
          <ToggleButton aria-label="Toggle bold" value="bold">
            <BoldIcon />
          </ToggleButton>
          <ToggleButton aria-label="Toggle italic" value="italic" variant="outline" defaultPressed>
            <ItalicIcon />
          </ToggleButton>
          <ToggleButton aria-label="Toggle underline" value="underline" disabled>
            <UnderlineIcon />
          </ToggleButton>
          <ToggleButton size="sm" variant="outline" value="sm">
            Small
          </ToggleButton>
          <ToggleButton size="lg" value="lg">
            Large
          </ToggleButton>
        </div>

        <p class="text-sm text-muted-foreground" data-testid="toggle-button-result">
          Bold <span class="font-medium text-foreground">{pressed.bold ? 'on' : 'off'}</span> ·
          Italic <span class="font-medium text-foreground">{pressed.italic ? 'on' : 'off'}</span>
        </p>
      </div>
    )
  },
)

Installation

npx shadcn add @stemsui/toggle-button

Requires the one-time setup. Behavior: stems-ui/toggle-button.

API reference

The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.

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