Switch
A native checkbox drawn as a switch. Pressing stretches the thumb toward where it is going; releasing springs it across. Every animation is CSS.
Installation
Add the registry to components.json, once
{
"registries": {
"@rabtx": "http://localhost:3002/r/{name}.json"
}
}Add the component
npx shadcn@latest add @rabtx/switchOr, without the namespace, by URL:
npx shadcn@latest add http://localhost:3002/r/switch.jsonThe files land in your components/ui and are styled by your own theme. Add --overwrite to replace a shadcn component with the same name.
Usage
Installed from the registry, import from @/components/ui/switch; inside this monorepo, from
@rabtx/ui/switch.
import { Switch } from "@rabtx/ui/switch";
export function WifiSetting() {
const [on, setOn] = useState(true);
return (
<Switch checked={on} onCheckedChange={setOn}>
Wi-Fi
</Switch>
);
}It is an <input type="checkbox"> with role="switch" and the HTML switch attribute, so the
browser provides Space to toggle, the form value (name, value), and the on and off state for
screen readers. With children it wraps itself in a <label>, so the text names it and the whole row
toggles it. A bare switch needs a Field or an aria-label.
States
Off, on and disabled. A disabled switch is dimmed to 50%, is skipped by Tab and never moves.
Field
Inside a Field, the label names the switch, the hint describes it, and error marks it invalid
(a red edge and a soft halo) and links the message.
At most one a month.
Turn this on to continue.
Controlled
checked and onCheckedChange, as in shadcn. onChange still receives the native event.
Notifications are on
Settings rows
Reverse the label with flex-row-reverse justify-between to put the switch at the end of a row.
The row is still one label, so a click anywhere on it toggles the switch.
Right to left
The thumb starts on the inline start edge and travels toward the end, so it mirrors in right-to-left text with no prop.
API
Switch takes everything a native checkbox <input> takes, except type, size and role,
plus:
Prop
Type
Outside React, the class names are exported from @rabtx/ui/switch/styles:
<label class="rx-switch-label">
<input type="checkbox" role="switch" switch class="rx-switch rx-switch-md" />
Wi-Fi
</label>Theming
The Switch reads only the theme tokens.
| Part | Tokens |
|---|---|
| Track | off --muted-foreground (70% over --background), on --primary |
| Thumb | off --background, on --primary-foreground |
| Depth (Floating) | track --field-edge-top, --field-edge-bottom, --field-shade; thumb --surface-ring, --surface-sheen, --surface-shade |
| Focus and error | --ring, --destructive |
| Label | --foreground, disabled --disabled-foreground |
| Corners and motion | --radius-button, --spring-switch, --motion-ease |
How the motion works
| Effect | CSS |
|---|---|
| Travel | :checked sets the thumb's --_x to the track width minus its height; inset-inline-start follows on the switch spring (stiffness 800, damping 80, mass 4: a heavy thumb, about 4% overshoot, settled in 527ms), solved into a linear() easing |
| Press | :active (on the switch or its label) widens the thumb by 4px toward where it is going (the near edge stays put) and scales it to 0.9, on the same spring |
| Release | the toggle moves --_x while the width and scale spring back, so the thumb lands as it un-squishes |
| Track | --_fill is a registered @property color, so the fill inside the gradient layers fades in 200ms |
| Right to left | the thumb is placed with inset-inline-start (which is what transitions), so it starts and travels from the inline start in either direction |
The thumb is the input's ::before, so there is no extra markup to keep in sync.
Browser support
- Everywhere:
appearance: none,:checked,:active,:has(),color-mix(), logical insets, nesting,@propertyandlinear()easings: Chrome 120+, Safari 17.2+ and Firefox 128+ draw the same switch (from compatibility data; tested in Chrome). - Safari 17.4+: the
switchattribute makes it a native switch (announced as on and off,:indeterminatenever matches). The page's CSS still draws it. Elsewhere the attribute is ignored androle="switch"gives the same semantics. - Not used yet: the experimental
::thumband::trackpseudo-elements (Safari behind a flag).
Accessibility
- Announced as a switch, on or off, from the input's native checked state; there is no
aria-checkedto drift. - Space toggles it. Enter does not, as with any native checkbox.
- Focus shows a 2px
--ringoutline. Label it with children, aFieldor anaria-label. - The state is shown by position and color, never motion alone. Reduced motion and
data-motion="off"switch instantly.