Density is set by a data-density ancestor — the Context system, not a Switch prop.
Installation
npx primitiv add switch
pnpm dlx primitiv add switch
yarn dlx primitiv add switch
bunx primitiv add switch
Import
import{Switch}from"@/components/ui/switch";
Copied into your project as .primitiv-switch — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
The same shape difference Checkbox has, for the same reason. @primitiv-ui/react exports Root and Thumb and leaves you to place the thumb; the copied file exports a single Switch that renders the track, the thumb and the label span itself — so there is no SwitchThumb to import under Styled. Either way the Root renders a real <label> wrapping a visually-hidden <input type="checkbox" role="switch">.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Switch.Root
Extends HTMLInputElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
checked
boolean
—
headless
Forbidden in uncontrolled mode — use defaultChecked instead.
The controlled checked state. Must be kept in sync by the parent via
onCheckedChange.
children
ReactNode
—
headless
Content of the switch — typically a single
SwitchThumbProps`Switch.Thumb`.
defaultChecked
boolean
false
headless
Checked state on first render. Defaults to false when omitted.
Forbidden in controlled mode — use checked instead.
onCheckedChange
(checked: boolean) => void
—
headless
Called with the new boolean state whenever the switch toggles. Optional
in uncontrolled mode.
Called with the new boolean state whenever the user requests a toggle.
Required in controlled mode.
ref
Ref<HTMLInputElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Forwarded to the underlying native HTMLInputElement (the real,
visually-hidden checkbox), not the visible track.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control size; data-density scales each size further.
Switch.Thumb
Headless only — the copied file renders this part for you and exports no separate component, so there is nothing to import under Styled.
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When true, render the consumer's own element as the thumb instead of the
default <span>, merging aria-hidden and data-state onto it via the
Slot pattern.
children
ReactNode
—
headless
Optional thumb content (rarely needed — the thumb is usually styled
purely with CSS).
Styling contract
16 CSS custom properties on .primitiv-switch — mode-agnostic. These names are the stable surface; the values are not.
Entirely native, because the control really is a checkbox — which makes the useful row the one that is absent.
Key
Behaviour
Space
Toggle the switch.
Tab
Move focus to or from the switch. A disabled switch is skipped.
Enter
Does nothing. A checkbox-based control responds to Space only, and this one is a real checkbox — so a switch inside a <form> will not submit it either.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Switch
className:.primitiv-switch
Attribute
Value
When
data-state
checked | unchecked
checked / unchecked
data-disabled
""
disabled
Switch.Root
Attribute
Value
When
data-state
checked | unchecked
checked / unchecked
data-disabled
""
disabled
Accessibility
The hidden input carries role="switch", so it is announced as on/off rather than checked/unchecked. That is the whole reason to reach for this over Checkbox: a switch is an immediate action, a checkbox is a selection that takes effect when the form is submitted. If the setting only applies after a Save button, you want a checkbox.
The input is visually hidden, not display: none — a hidden-by-display input is removed from the accessibility tree and from form submission entirely. It stays focusable and reachable by every assistive technology.
A visible label is a real <label> wrapping the input, so clicking the text toggles the switch and the accessible name comes for free. With no visible label you must supply aria-label yourself.
disabled uses the native attribute rather than aria-disabled, so the switch leaves the tab order. That is right for a setting that is genuinely unavailable; if you need it to stay discoverable, keep it enabled and explain the constraint in nearby text.
Do not put the on/off state in the label text. The role already announces it, so “Email notifications: on” is read twice — label the thing, and let the switch report its own state.
data-state on the track is a best-effort mirror for CSS, and it is not authoritative: a native form reset changes the input without firing a React event. Key visual state off :checked (the shipped stylesheet uses :has(> input:checked)), which stays correct through a reset.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Sizes and density
Five sizes, each rescaling again with the nearest data-density ancestor. The track height is deliberately the same as Checkbox's and Radio's box at every size and density, so a column mixing all three keeps one baseline — change the density above and the whole ramp shifts together.
A switch is a real, visually-hidden <input type="checkbox">, so with name and value it submits and resets like any native control — no wiring, no hidden field. Pass defaultChecked and let the browser own the state. Press Reset: the switch returns to its default and no React event fires, which is exactly why the shipped stylesheet paints the on/off look from the input's native :checked rather than from data-state.
Pass checked and onCheckedChange together and the parent owns the value. The two are a pair: the props table flattens the controlled and uncontrolled shapes into one list, but TypeScript accepts only one of them at a time — checked with onCheckedChange, or defaultChecked alone. Mixing them is a type error, not a runtime surprise.
disabled on the root sets the native attribute on the hidden input, so the switch stops responding to clicks and leaves the tab order — the platform does the work, not a handler that returns early. data-disabled lands on the track for styling. A disabled switch still submits nothing, exactly as a disabled checkbox does.
Omit the children and you get the bare track — for a switch in a table row or a toolbar, where a neighbouring cell or heading already names it. It then needs an aria-label, because nothing else does: the label span is where the accessible name normally comes from, and a switch announced as just “switch, off” tells you nothing about what it controls.