Overview
<Switch /> is the binary on/off toggle. It uses the same hidden-input + custom-painted-indicator
technique as <Checkbox />, but with a sliding thumb instead of a glyph. The visual
language is intentionally different so users learn the affordance:
- Switch flips a setting now (often async, often server-confirmed).
- Checkbox queues a value for submission.
Switch ships three independently overridable slots: root, track, thumb. The slide is pure
CSS (transition-transform) and respects prefers-reduced-motion — no Motion-library cost is
paid for the most common interaction.
Overview — off, on, with description, and disabled
Anatomy
[ track [ thumb ] | label / description column ][ track [ thumb ] | label / description column ]- track — the colored rail. Variant × color × shape lives here.
- thumb — the sliding pill (always circular). Optional
thumbIconglyph inside. - label —
children, click target via the wrapping<label>. - description — optional secondary text, wired via
aria-describedby.
The track + thumb are decorative (aria-hidden). Assistive tech reads the underlying
<input role="switch">.
Examples
Basic
Variants
Sizes
Colors
Shapes
WithThumbIcons
WithDescription
Disabled
Invalid
Loading
SettingsRow
Controlled
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | — | Controlled checked state. |
| color | ResponsiveValue<SwitchColor> | 'primary' | Palette role for the ON-state track / accent. |
| defaultChecked | boolean | false | Uncontrolled initial state. |
| description | ReactNode | — | Secondary text below the label, wired via `aria-describedby`. |
| invalid | boolean | — | Visual + a11y invalid state. |
| labelPosition | enum | 'right' | Logical label side. `right` = end side (LTR), start side (RTL). |
| loading | boolean | — | Async-toggle spinner inside the thumb. Sets `aria-busy` and blocks user toggling — useful for "Connect to Slack"-style settings where the server must confirm the new state. |
| onChange | ((event: ChangeEvent<HTMLInputElement, Element>) => void) | — | Native change handler. Fires alongside `onCheckedChange`. |
| onCheckedChange | ((checked: boolean) => void) | — | Canonical handler — receives the new boolean directly. |
| shape | enum | 'pill' | Track corner shape. Thumb stays circular. |
| size | ResponsiveValue<SwitchSize> | 'md' | Track length + thumb diameter + slide distance. |
| sx | Sx | — | Theme-aware inline style object. |
| thumbIcon | SwitchThumbIcon | — | Optional glyphs rendered inside the thumb per state. |
| variant | ResponsiveValue<SwitchVariant> | 'solid' | Stylistic family of the ON-state. |
Accessibility
- The hidden
<input type="checkbox" role="switch">is the canonical control. Form participation (name+value), keyboard, and screen-reader announcements all flow through it. aria-checkedis binary (true/false) — switches are not tri-state.aria-busyis set whenloadingistrue; toggling is blocked at the React layer.- Focus ring lands on the track via the
peer-focus-visiblevariant, colored to match the activecolorfor every variant × color cell. Spacetoggles;Enteris a no-op (matches nativerole="switch"per the ARIA spec).invalidsetsaria-invalidon the input and adds a danger ring on the track.descriptionis auto-wired viaaria-describedby; consumer-suppliedaria-describedbyids are merged in.- A dev-mode warning fires when no accessible name is provided (
SWITCH_NO_LABEL). - The internal spinner sits inside the
aria-hiddentrack. Screen readers learn about the loading state via thearia-busyon the input itself — the visible spinner is decorative. - axe-core passes across the variant × color × state matrix.
Theming
<ThemeProvider
theme={defineTheme({
components: {
Switch: {
defaultProps: { variant: 'soft', size: 'lg' },
styleOverrides: {
root: 'gap-3',
track: 'shadow-inner',
thumb: 'shadow-md',
},
},
},
})}
>
{children}
</ThemeProvider><ThemeProvider
theme={defineTheme({
components: {
Switch: {
defaultProps: { variant: 'soft', size: 'lg' },
styleOverrides: {
root: 'gap-3',
track: 'shadow-inner',
thumb: 'shadow-md',
},
},
},
})}
>
{children}
</ThemeProvider>Instance-level overrides win via tailwind-merge:
<Switch className="font-medium" sx={{ radius: 'lg' }} style={{ accentColor: '#22c55e' }}>
Override me
</Switch><Switch className="font-medium" sx={{ radius: 'lg' }} style={{ accentColor: '#22c55e' }}>
Override me
</Switch>Motion
- Thumb slide — pure CSS
transition-transform(~150ms,ease-standard). - Track bg crossfade — pure CSS
transition-[background-color,border-color,...]. - Loading spinner — Tailwind
animate-spinon a 2-px ring; no Motion library import. prefers-reduced-motion— collapses all transitions via Tailwind'smotion-reducevariant on every animated rule.
Total Motion-library cost for Switch: 0 bytes. The whole component slides without paying for the spring engine.
RTL
labelPositionis logical (right= end side LTR, start side RTL).- The thumb starts at
start-0.5(logical), so it begins from the start edge in both directions. - The Tailwind
translate-x-*slide is already direction-aware in modern browsers when the parent hasdir="rtl"— the thumb visually slides toward the logical end without an explicit flip.
Do / Don't
- Do use Switch for instant-effect settings: notifications, dark mode, integrations.
- Do use
loadingfor async toggles where the server must confirm the new state — the spinner inside the thumb is the right affordance for "I'm waiting on a server", not "I'm computing locally". - Do label the switch — short verb phrases ("Enable…", "Allow…") read best.
- Don't use Switch inside a form that requires Submit before changes apply — that's
<Checkbox />'s job. The user expects switches to take effect immediately. - Don't wrap multiple switches in a
<RadioGroup>-style single-selection container — that's<Radio />. Switches are independent boolean toggles. - Don't use a switch for navigation or for opening a dialog — that's
<Button>'s job. The switch metaphor implies persistent state.