ToggleButton
A button that toggles between on and off states, with the same variant, intent, size, and rounded system as Button. Shows a persistent pressed visual when active.
pnpm add @tessinaui/uiWhen to use
- A state that applies immediately: bold in a toolbar, mute, a filter toggled on and off. If the change needs saving, it is a form control, not a toggle.
- A control that has to look like a button beside other buttons, with the
same
variant,intent,sizeandroundedas Button. - An icon-only toggle in a toolbar:
size="icon"with anaria-label. - A label that stays the same in both states. "Star" that becomes "Starred"
changes width mid-click and makes the pressed state harder, not easier, to
read. The state is already carried by
aria-pressedand the fill. - Short async work before the state settles: set
loadingand the toggle keeps its width and its focus.
When not to use
- For a persistent on/off setting. Use Switch: a toggle button reads as "apply this now", a switch as "this is how it stays".
- For one action that does something and has no state. Use Button.
- For one-of-many. Use ToggleGroup or SegmentedControl, which manage selection and roving focus for you.
- For a choice that needs saving or confirming. Use a Checkbox in a form.
- To navigate. Use Link or Tabs.
Installation
pnpm add @tessinaui/uiUsage
import { ToggleButton } from "@tessinaui/ui";<ToggleButton>Star</ToggleButton>Examples
Default
A controlled toggle. The label stays the same in both states; the fill and aria-pressed carry the state.
Variants
Four visual styles — primary, secondary, ghost, and outline.
Sizes
Five text sizes from xs to xl on the family scale (32 / 40 / 48 / 56 / 96 px), plus icon — a 48 px square for an icon-only toggle that requires an accessible name. xs and sm keep a 44 px minimum touch target on phones and tablets.
Intents
Semantic color intents — none, error, warning, success, and info.
States
Off, pressed, disabled, and loading states.
On color
Use tone="on-color" so toggles read correctly on a colored surface.
Skeleton
RTL
dir="rtl" puts leadingIcon on the right of the label and trailingIcon on the left. Unset, dir is inherited from the page, and the prop wins when set.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
pressed | boolean | — | Controlled pressed state |
defaultPressed | boolean | false | Initial pressed state for uncontrolled usage |
onPressedChange | (pressed: boolean) => void | — | Callback fired when pressed state changes |
variant | "primary" | "secondary" | "ghost" | "outline" | "primary" | Visual style |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic color intent |
size | "xs" | "sm" | "md" | "lg" | "xl" | "icon" | "md" | Family scale 32 / 40 / 48 / 56 / 96 px; icon is a 48 px square and requires aria-label or aria-labelledby (enforced by the type) |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Corner radius |
tone | "default" | "on-color" | inherited from <Surface> | High-contrast styling for a coloured or dark surface; pressed becomes the inverse chip |
fullWidth | boolean | false | Stretch to the container width |
showBorder | boolean | true | outline only. outline means "border at rest", as on Button; pass false for a row of toggles that should read as one quiet surface until one is on — or use variant="ghost", which is what that look is |
leadingIcon | React.ReactNode | — | Icon rendered before the label. Stays mounted while loading (the spinner overlays it) so the width never moves |
trailingIcon | React.ReactNode | — | Icon rendered after the label. Stays mounted while loading |
loading | boolean | false | Overlays a spinner, sets aria-busy + aria-disabled, swallows activation. Keeps focus and size; not natively disabled |
disabled | boolean | false | Disables the button and prevents toggling |
dir | "ltr" | "rtl" | — | Text direction — RTL reverses icon/label order via CSS flex. Unset, it inherits the page's direction |
Controlled vs Uncontrolled
Controlled — manage pressed state externally:
const [starred, setStarred] = useState(false);
<ToggleButton pressed={starred} onPressedChange={setStarred}>
Star
</ToggleButton>Uncontrolled — let the component manage its own state:
<ToggleButton defaultPressed={false} onPressedChange={(p) => console.log(p)}>
Star
</ToggleButton>Pressed intents
Accessibility
VoiceOver + Safari: passed 2026-09-14. Focus reads "Star, toggle button". Pressing it is silent, as a native <button aria-pressed> is in VoiceOver + Safari; re-reading it adds "selected" between the label and "toggle button". NVDA and JAWS are untested.
Follows the WAI-ARIA Button pattern, toggle variant.
- Renders a
<button>witharia-pressedreflecting the current toggle state data-pressedis present (empty string) when pressed and absent when not — target it as[data-pressed], not[data-pressed="true"]. A[data-pressed="false"]selector never matches anything- Keyboard accessible: Enter and Space toggle the button
- Focus ring visible with 2px ring + 2px offset (matches design system standard)
- Disabled state prevents toggling and is reflected via the native
disabledattribute loadingis not the same as disabled: the toggle keeps focus and reportsaria-busy, and both icons stay mounted so its width does not move- Under Windows high-contrast mode the pressed state maps to the system
Highlightcolours — the painted fill is stripped there, which would otherwise leave an icon-only toggle with no pressed indication at all size="icon"requiresaria-labeloraria-labelledby, enforced by the type- Touch targets ≥ 44×44px on phones and tablets (
xs/smgrow;md+ already clear it)