Skip to content
Primitiv home
Framework
Consumption mode

Switch

stableSource Figma

An on/off toggle with an optional inline label — a native switch input for an immediate binary setting.

Playground

Density

Preview

Size
import { Switch } from "@/components/ui/switch";
<Switch size="md">Email notifications</Switch>

Density is set by a data-density ancestor — the Context system, not a Switch prop.

Installation

npx 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

<Switch>Email notifications</Switch>

Props

Switch.Root

Extends HTMLInputElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
checkedbooleanheadlessForbidden in uncontrolled mode — use defaultChecked instead. The controlled checked state. Must be kept in sync by the parent via onCheckedChange.
childrenReactNodeheadlessContent of the switch — typically a single SwitchThumbProps`Switch.Thumb`.
defaultCheckedbooleanfalseheadlessChecked state on first render. Defaults to false when omitted. Forbidden in controlled mode — use checked instead.
onCheckedChange(checked: boolean) => voidheadlessCalled 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.
refRef<HTMLInputElement>headlessAllows 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"mdstyledControl 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen 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.
childrenReactNodeheadlessOptional thumb content (rarely needed — the thumb is usually styled purely with CSS).

Styling contract

--primitiv-switch-track-bg--primitiv-switch-track-bg-checked--primitiv-switch-track-width--primitiv-switch-track-height--primitiv-switch-track-radius--primitiv-switch-thumb-bg--primitiv-switch-thumb-size--primitiv-switch-thumb-margin--primitiv-switch-thumb-radius--primitiv-switch-thumb-shadow--primitiv-switch-gap--primitiv-switch-label-color--primitiv-switch-label-font-family--primitiv-switch-label-font-size--primitiv-switch-label-font-weight--primitiv-switch-label-line-height

Keyboard

KeyBehaviour
SpaceToggle the switch.
TabMove focus to or from the switch. A disabled switch is skipped.
EnterDoes 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

Switch

className: .primitiv-switch

AttributeValueWhen
data-statechecked | uncheckedchecked / 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

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.

Density
import { Switch } from "@/components/ui/switch";
<div data-density="comfortable">  <Switch size="xs">Email notifications</Switch>  <Switch size="sm">Email notifications</Switch>  <Switch size="md">Email notifications</Switch>  <Switch size="lg">Email notifications</Switch>  <Switch size="xl">Email notifications</Switch></div>

In a form (uncontrolled)

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.

Density
import { Switch } from "@/components/ui/switch";import { Button } from "@/components/ui/button";
<form>  <Switch name="notify" value="on" defaultChecked>Email notifications</Switch>  <Button type="reset" variant="secondary">Reset</Button></form>

Controlled

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.

Density
import { useState } from "react";import { Switch } from "@/components/ui/switch";
const [enabled, setEnabled] = useState(false);
<Switch checked={enabled} onCheckedChange={setEnabled}>Email notifications</Switch>

Disabled

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.

Density
import { Switch } from "@/components/ui/switch";import { Stack } from "@/components/ui/stack";
<Stack gap="sm">  <Switch disabled>Email notifications</Switch>  <Switch disabled defaultChecked>Push notifications</Switch></Stack>

Without a visible label

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.

Density
import { Switch } from "@/components/ui/switch";
<Switch aria-label="Email notifications" />