Switch
An instant boolean toggle — flips a setting on or off the moment it is pressed
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Switch } from "@tessinaui/ui";<Switch label="Enable notifications" defaultChecked />When to use
A switch applies immediately — no save step, no submit moment. If a physical light switch would work for the action, a Switch works here.
| Situation | Reach for |
|---|---|
| Instant on/off setting (notifications, dark mode as a boolean, feature flags) | Switch |
| Choice collected now, applied on submit | Checkbox |
| Several related options, or an indeterminate/mixed state | Checkbox (group / tree) |
| Two opposing options where neither is "off" (Monthly ⇄ Yearly) | SegmentedControl |
| Three or more states (System / Light / Dark) | Radio or SegmentedControl |
| A one-shot action | Button — a switch is never a button |
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.
Rounded
Every painted corner follows rounded proportionally — the knob's inner
radius steps with the track, and square switches get square skeleton bars.
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 Carbon/Primer 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.
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" | Track + knob corner shape, proportional (§2) |
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 |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
size / rounded | same as Switch | "md" / "full" | Track box + corners |
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; Base UI ships it and Radix documents 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. Verified with VoiceOver locally; NVDA and JAWS are untested here.
- Group related switches with
<fieldset>/<legend>(see Fieldset) orrole="group"+aria-labelledby.