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-buttonRequires 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'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 |