Content
In-track glyphs via checkedContent / uncheckedContent.
An instant on/off toggle for a single setting.
@garn/ui/switchDefault switch.
Interactive states, toggled live.
Switch with a setting label.
sm · md · lg.
The tone axis: neutral · brand · success · warning · danger.
In-track glyphs via checkedContent / uncheckedContent.
Controlled `checked` + `onCheckedChange`, driving `loading` while an async toggle resolves.
Switch in a Field — settings row + option card.
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add switchswitch@radix-ui/react-switchclass-variance-authoritylucide-reactutilsImport
import { Switch } from "@garn/ui/switch";The component's public props and their types.
size"lg" | "md" | "sm"mdtone"brand" | "danger" | "neutral" | "success" | "warning"brandcheckedContentReact.ReactNodeloadingbooleanfalseuncheckedContentReact.ReactNodePlus 293 inherited native <button> attributes.
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="switch"]States
| Selector | State | Description |
|---|---|---|
[data-state=checked] | checked | Track fills with the tone's solid + a one-step-darker edge; the thumb slides to the far inset (mirrored under RTL) and turns --garn-background (white). Any `checkedContent` fades into the vacated inline-start well. |
[data-state=unchecked] | unchecked | Track is a light neutral (--garn-muted lightened toward --garn-background) with a --garn-input edge and no shadow; the thumb is a soft-dark (--garn-foreground lightened). Any `uncheckedContent` fades into the vacated inline-end well. |
[data-loading] | loading | A spinner rides in the thumb, aria-busy is set, and the control is disabled — but the disabled dim is suppressed so the spinner reads at full strength. |
:focus-visible | focus-visible | 2px ring + offset. |
[disabled] | disabled | Dims and blocks pointer. |
| Property | Token | Tier |
|---|---|---|
trackHeight | --garn-box-md | semantic · identity |
fill | --garn-brand-solidbrandable | semantic |
fillEdge | --garn-brand-strongbrandable | semantic |
fillOff | --garn-muted | semantic |
ring | --garn-ringbrandable | semantic |
Private instance vars (never externalize): --sh
A setting that applies immediately — no Save step (notifications on/off, dark mode).
Reach for something else when
Switch flips a setting that takes effect the instant it changes — notifications on/off, dark mode, an enabled feature — with no Save step. The filled track and travelled thumb make the on/off state legible at a glance. If the choice is only confirmed later on submit (a form value), use a Checkbox; if there are more than two states, use a RadioGroup or Select.
Use a switch only when the change is immediate. The mental model is a physical toggle: flipping it does the thing now. If you find yourself wanting a Save button for a screen of switches, those should probably be checkboxes instead.
Label the setting, not the state. The Label says what the switch controls ("Email notifications") — the on/off position already communicates the state, so don't append "on/off" to the text. Reserve switches for clearly binary settings.
Reach for tone only when the state carries a status. The default brand fill is right for almost every toggle. Use success/warning/danger when the ON position itself signals something (a risky feature flag, a destructive automation) — not for decoration.
Use loading for async toggles. When flipping runs a request, set loading to show the in-thumb spinner and block interaction (it sets aria-busy + disabled) until the real result lands — then reflect it, reverting if it failed. Don't leave the switch looking done while the effect is still in flight.
Pair it with a Field for anything more than a bare toggle. A description, a card surface, or a settings row is the Field's job — drop the Switch in as the Field's control so the label, htmlFor, and aria-describedby are wired for you.
The switch flips back instantly / won't move.
Cause. Passing checked without onCheckedChange makes it controlled and frozen.
Fix. Use defaultChecked for uncontrolled, or pass checked + onCheckedChange.
No glyph shows inside the track.
Cause. In-track content is opt-in — there is no default glyph.
Fix. Pass checkedContent (and/or uncheckedContent) with an icon or 1–2 characters.
A tone other than brand doesn't change the OFF track.
Cause. tone only colours the ON (checked) track; the OFF track is a neutral surface for every tone.
Fix. That's intended — the tone reads as the on-state colour intent, not an always-on tint.
Thumb travel is wrong in RTL.
Cause. Built-in rtl: compiles to zero-specificity :where() and can lose to the base translate.
Fix. The component uses explicit [[dir=rtl]_&]: variants — don't replace them with plain rtl: utilities.
switchAccessibility requirements
Give every switch an accessible label (a <label htmlFor>, or aria-label).
when no associated <label> or aria-label/aria-labelledby
Keyboard
| SpaceEnter | Toggle on / off. |
Sent when someone replies to your thread.
A Monday summary of everything you missed.