DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. Switch

Switch

An instant boolean toggle — flips a setting on or off the moment it is pressed

$ pnpm add @tessinaui/ui

When 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 fullWidth with labelPlacement="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 isLoading and 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Switch } from "@tessinaui/ui";
example.tsx
<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.

PropTypeDefaultDescription
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.
labelReactNode—Accessible name via a label text span (aria-labelledby). Rich content allowed; nested links work
descriptionReactNode—Helper below the label, wired via aria-describedby. Hidden while errorMessage is shown
errorMessageReactNode—Replaces the description; error-tinted, aria-invalid, polite live region
labelPlacement"start" | "end""end"Logical label side
fullWidthbooleanfalseText column grows, track sits at the far edge (settings row with labelPlacement="start")
showIconbooleantrueCheckmark (on) / X (off) in the knob, contrast-audited
stateLabelsboolean | { on: string; off: string }—ON/OFF words inside the track, md+ only; knob slims to half-width
statusLabelboolean | { on: ReactNode; off: ReactNode }—On/Off word beside the track, aria-hidden, updates with state
isLoadingbooleanfalseSpinner in the knob, aria-busy, read-only semantics (focusable, toggle refused)
readOnlybooleanfalseFull-color display, focusable, not operable
requiredbooleanfalseNative constraint validation + asterisk after the label
optionalbooleanfalseMuted "(optional)" marker (joins the name with a space). Mutually exclusive with required
visuallyHiddenLabelbooleanfalseHides the label visually; name AND description stay announced
dir"ltr" | "rtl"—Text direction for the wrapper
stringsPartial<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
labelClassNamestring—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.

PropTypeDefaultDescription
sizesame as Switch"md"Track box
roundedsame as Switch"full"Text-bar corners (the track stays a pill)
showLabelbooleantrueLabel bar
showDescriptionbooleanfalseDescription bar
showStatusLabelbooleanfalseStatus-word bar beside the track
labelPlacement"start" | "end""end"Side of the text bars (labelPosition deprecated)
fullWidthbooleanfalseMirrors the real fullWidth row
labelChars / descriptionCharsnumbersize-scaledBar 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

KeyAction
Tab / Shift+TabMove focus to / away from the switch
SpaceToggle
EnterToggle — 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 — statusLabel carries the aria-hidden wiring.

Accessibility

  • role="switch" with aria-checked on 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 via aria-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/statusLabel words are aria-hidden; the role announces the state, so text would double-speak.
  • isLoading reports aria-busy and keeps the switch in the Tab order.
  • Disabled uses a token swap, not opacity; Windows High Contrast maps checked state to SelectedItem system 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) or role="group" + aria-labelledby.
RadioTextarea

On this page

When to useWhen not to useInstallationUsageExamplesDefaultWith DescriptionSizesLabel PlacementSettings rowsMaster toggleStatesLoadingAsync failure and retryValidationState words in the trackStatus word beside the trackNative form submissionIn a table columnConditional disclosureSelection cardsRTLOn a coloured surfaceSkeletonAPI ReferenceSwitchSwitchSkeletondata-slot targetsKeyboardDo / Don'tAccessibility