Switch
An instant boolean toggle — flips a setting on or off the moment it is pressed
pnpm add @tessinaui/uiWhen to use
- An instant setting. Notifications, a feature flag, a boolean preference: anything that takes effect the moment it is flipped, with no save step.
- A single option with two states, on and off, where "off" is a real, meaningful state rather than the absence of a choice.
- A settings list where each row is one independent preference. Use
fullWidthwithlabelPlacement="start"for the row layout. - A master toggle above dependent switches. Flipping it disables the children instead of rewriting them, so their values survive.
- Short async work where the control can show a spinner with
isLoadingand flip only after the operation resolves.
When not to use
- For a choice that waits for a submit button. Use Checkbox; a switch always reads as instant.
- For two opposing options. Monthly or Yearly is not on or off. Use SegmentedControl.
- For three or more options. Use Radio or SegmentedControl.
- For several related options or a mixed state. Use Checkbox; a switch has no mixed state.
- For a one-shot action. Use Button; a switch is never a button.
- For a destructive or irreversible change that needs confirmation. Use a Button with a confirmation step.
Installation
pnpm add @tessinaui/uiUsage
import { Switch } from "@tessinaui/ui";<Switch label="Enable notifications" defaultChecked />Label content: a noun or short noun phrase describing the thing controlled
("Marketing emails"), sentence case, no terminal punctuation. Never phrase it
as a command ("Turn on emails"), a question, or — critically — text that
changes with the state: the APG requires the label to stay identical whether
the switch is on or off. When you want a visible On/Off word, that is
statusLabel, not the label.
Examples
Default
A basic switch with a label, toggled on by default. The label is a wrapping
<label>, so clicking it toggles the switch and the whole row is one tap
target.
With Description
Pair a label with supporting description text. The description reaches
assistive tech through aria-describedby, never the accessible name.
Sizes
Five sizes, xs through xl — track heights 16/20/24/26/28px at a constant
2:1 aspect. The invisible hit area keeps every size at ≥44px on both axes.
Label Placement
Logical sides: end (default) or start — both flip correctly under
dir="rtl". The physical labelPosition is deprecated.
Settings rows
fullWidth + labelPlacement="start" is the mobile settings-row: text
leading, control trailing, the whole row one tap target. Rich labels can carry
a Badge; pass aria-label to keep the accessible name clean when they do.
Master toggle
A pause-all parent above child switches. Flipping the master disables the
children instead of rewriting their values, so their choices survive.
role="switch" has no mixed state — a master switch is strictly boolean.
States
Disabled (token swap, out of the interaction), read-only (full-color,
focusable, not operable) and showIcon={false}.
Loading
isLoading swaps the thumb icon for a spinner, reports aria-busy and
refuses toggling the read-only way — the switch stays focusable and is never
announced disabled. Flip the visible state only after the operation resolves.
Async failure and retry
The full async contract: pending → failure reverts the switch, explains
itself in errorMessage (announced politely) and offers a retry.
Validation
errorMessage replaces the description while shown, tints the message and
sets aria-invalid. Switches rarely validate — they apply immediately — so
reserve this for async failures and switches inside genuinely validated forms.
State words in the track
stateLabels renders ON/OFF words inside the track (custom { on, off }
strings for i18n). Rendered at md and larger only — smaller tracks have no
legible room — with contrast-audited inks on both fills.
Status word beside the track
statusLabel is the long-form anatomy: an On/Off word OUTSIDE the track
that updates with state. It is aria-hidden — the switch role already
announces the state, so the word would otherwise be spoken twice.
Native form submission
name/value submit through the hidden input when on; uncheckedValue sends
an explicit value when off; required runs native constraint validation.
In a table column
The column header names the axis, not each control — give every switch its
own aria-label.
Conditional disclosure
A switch revealing its dependent controls. Keep the revealed block right after the row in DOM order so focus and reading order stay linear.
Selection cards
The switch's own wrapping label is the card — one tap target, no extra click
handlers (Base UI 1.3.0 bubbles hidden-input clicks to ancestors, so an
ancestor onClick would double-fire).
RTL
Logical properties end to end: placement and knob travel flip with dir.
On a coloured surface
Wrap a region in <Surface> and every switch inside inherits tone="on-color": the unchecked track becomes the plate's well, the checked track keeps its fill behind a hairline of its own ink, and the label, description and error line take the plate's inks.
Skeleton
SwitchSkeleton mirrors the configured layout — same size, same text lines,
same box — so the swap causes no layout shift.
API Reference
Switch
Extends Base UI Switch.Root props (checked, defaultChecked,
onCheckedChange(checked, eventDetails), disabled, readOnly, required,
name, form, value, uncheckedValue, inputRef, …). The root renders a <span>
plus a hidden <input>; the ref element type is HTMLElement.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Track 16–28px tall at 2:1; hit area ≥44px at every size |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Deprecated — no effect. Track and knob are a pill at every value, so a Form's or Fieldset's cascaded corner can never square them. |
label | ReactNode | — | Accessible name via a label text span (aria-labelledby). Rich content allowed; nested links work |
description | ReactNode | — | Helper below the label, wired via aria-describedby. Hidden while errorMessage is shown |
errorMessage | ReactNode | — | Replaces the description; error-tinted, aria-invalid, polite live region |
labelPlacement | "start" | "end" | "end" | Logical label side |
fullWidth | boolean | false | Text column grows, track sits at the far edge (settings row with labelPlacement="start") |
showIcon | boolean | true | Checkmark (on) / X (off) in the knob, contrast-audited |
stateLabels | boolean | { on: string; off: string } | — | ON/OFF words inside the track, md+ only; knob slims to half-width |
statusLabel | boolean | { on: ReactNode; off: ReactNode } | — | On/Off word beside the track, aria-hidden, updates with state |
isLoading | boolean | false | Spinner in the knob, aria-busy, read-only semantics (focusable, toggle refused) |
readOnly | boolean | false | Full-color display, focusable, not operable |
required | boolean | false | Native constraint validation + asterisk after the label |
optional | boolean | false | Muted "(optional)" marker (joins the name with a space). Mutually exclusive with required |
visuallyHiddenLabel | boolean | false | Hides the label visually; name AND description stay announced |
dir | "ltr" | "rtl" | — | Text direction for the wrapper |
strings | Partial<SwitchStrings> | English, Arabic under dir="rtl" | Override the optional marker ("(optional)") |
tone | "default" | "on-color" | inherited from <Surface> | Colour recipe for a coloured or dark surface: the unchecked track is the plate's well, the checked track keeps its success fill with a hairline of its own on-ink, and the text and focus ring read the plate's inks |
labelClassName | string | — | Override the label's text classes |
Deprecated aliases (dev warning, removed next minor): labelPosition
("left"/"right" → labelPlacement "start"/"end"), labelSpacing ("spread" →
fullWidth), isLabelHidden (→ visuallyHiddenLabel), loading (→
isLoading).
SwitchSkeleton
Mirrors the real component's configured layout — same box for the same props.
Bars are one line box of the real text ladder and follow rounded
proportionally; the track placeholder is a pill at every value, like the real
track.
| Prop | Type | Default | Description |
|---|---|---|---|
size | same as Switch | "md" | Track box |
rounded | same as Switch | "full" | Text-bar corners (the track stays a pill) |
showLabel | boolean | true | Label bar |
showDescription | boolean | false | Description bar |
showStatusLabel | boolean | false | Status-word bar beside the track |
labelPlacement | "start" | "end" | "end" | Side of the text bars (labelPosition deprecated) |
fullWidth | boolean | false | Mirrors the real fullWidth row |
labelChars / descriptionChars | number | size-scaled | Bar lengths in characters |
data-slot targets
switch (wrapper) · switch-row (wrapping label) · switch-track ·
switch-thumb · switch-label · switch-description · switch-error ·
switch-status · switch-skeleton.
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | Move focus to / away from the switch |
Space | Toggle |
Enter | Toggle — the APG lists Enter as optional for switches, and Base UI ships it. (Checkbox deliberately does NOT toggle on Enter; the divergence is per-pattern, not an inconsistency.) |
Do / Don't
- Do write the label as the thing controlled: "Marketing emails".
- Do flip the visible state only after an async operation resolves; on
failure revert, explain in
errorMessage, offer a retry. - Do explain a locked/forced setting in the
description(plan-gated, policy-managed) instead of leaving a bare disabled switch. - Don't change the label text with the state ("Enabled" ⇄ "Disabled") —
APG: the label must not change when the state changes. Use
statusLabel. - Don't use a switch to pick between two opposing options (Monthly ⇄ Yearly) — the role announces on/off, which is meaningless there. Use SegmentedControl.
- Don't put a switch behind a Save button unless the form genuinely defers — a switch reads as instant.
- Don't hand-roll an On/Off word next to the track —
statusLabelcarries thearia-hiddenwiring.
Accessibility
role="switch"witharia-checkedon the Base UI root; state also lives on a hidden<input type="checkbox">for forms.- The root is a
<span>, which is not a labelable element — the visible label is therefore a wrapping<label>, and the accessible name points at the label text span viaaria-labelledby(a reference to the wrapping label itself hits Chromium's accname cycle guard and resolves to an empty name). - Space and Enter both toggle (Enter is APG-optional; see Keyboard).
- Touch target ≥44×44px at every size via an invisible centred hit area.
stateLabels/statusLabelwords arearia-hidden; the role announces the state, so text would double-speak.isLoadingreportsaria-busyand keeps the switch in the Tab order.- Disabled uses a token swap, not opacity; Windows High Contrast maps checked
state to
SelectedItemsystem colors. - Track, knob and word transitions are disabled under
prefers-reduced-motion. - Known platform quirks (not specific to this library): Firefox + VoiceOver may not re-announce the state change (correct in Chrome/Safari), and some AT historically mis-handled span-based switches.
- VoiceOver passed 2026-09-14 (Safari). Focus reads "Enable notifications on switch". Space announces "off Enable notifications switch", and Space again announces "on Enable notifications switch". NVDA and JAWS are untested here.
- Group related switches with
<fieldset>/<legend>(see Fieldset) orrole="group"+aria-labelledby.