---
title: "Switch"
description: "A labelled on-or-off control that commits the moment it is flipped, for a single setting rather than a form value submitted later."
url: "https://opsinjs.pensievelabs.org/components/switch"
source: "https://opsinjs.pensievelabs.org/components/switch.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["toggle"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="switch"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured: the card thumb against the primary fill when on, against the muted fill when off, and the hairline round both.&#x22;,
  &#x22;Nobody has listened to this switch in a screen reader, so whether role=\&#x22;switch\&#x22; with its label and helper line reads usefully is reasoned, not heard.&#x22;,
  &#x22;Forced colours, prefers-contrast: more, right-to-left layout and prefers-reduced-motion are unhandled, so the thumb slides and the fills swap untested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="switch" />

## Installation [#installation]

<ComponentInstall name="switch" unbuilt="false" importPath="@/components/ui/switch" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { Switch } from "@/components/ui/switch"
```

```tsx
<Switch
  label="Larger text"
  checked={largerText}
  onCheckedChange={setLargerText}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A single setting, such as a Larger text preference, that takes effect the moment it is flipped.&#x22;,
  &#x22;A binary state the product commits straight away, with nothing submitted later and the reader free to flip it back.&#x22;,
]"
  avoid="[
  { case: &#x22;The toggle would record that the reader agreed to something. Consent is a decision with a record.&#x22;, instead: &#x22;consent-sheet&#x22; },
  { case: &#x22;The value is proposed now and submitted later with a form.&#x22;, instead: &#x22;checkbox&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="switch"
  parts="[
  {
    name: &#x22;Switch&#x22;,
    describes: &#x22;The root, a label element floored at the 44pt target. The whole row is pressable.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Switch.Label&#x22;,
    describes: &#x22;The visible words and the accessible name, applied to the control through aria-labelledby.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Switch.Description&#x22;,
    describes: &#x22;The optional helper line, read as the control's description through aria-describedby.&#x22;,
    prop: &#x22;description&#x22;,
  },
  {
    name: &#x22;Switch.Control&#x22;,
    describes: &#x22;The track, a Base UI Switch.Root with role=\&#x22;switch\&#x22; and aria-checked. Muted when off, primary fill when on.&#x22;,
    prop: &#x22;checked&#x22;,
  },
  {
    name: &#x22;Switch.Thumb&#x22;,
    describes: &#x22;The disc that slides: left for off, right for on, so position carries the state too.&#x22;,
  },
]"
/>

<CompositionTree
  name="switch"
  tree="[
  {
    part: &#x22;Switch&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;switch\&#x22;, the whole target&#x22;,
    children: [
      {
        part: &#x22;Switch.Label&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;data-slot=\&#x22;switch-label\&#x22;&#x22;,
      },
      {
        part: &#x22;Switch.Description&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;data-slot=\&#x22;switch-description\&#x22;, only when description is set&#x22;,
      },
      {
        part: &#x22;Switch.Control&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;data-slot=\&#x22;switch-control\&#x22;, role=\&#x22;switch\&#x22;&#x22;,
        children: [
          {
            part: &#x22;Switch.Thumb&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;switch-thumb\&#x22;&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### A setting that applies now [#a-setting-that-applies-now]

`checked` and `onCheckedChange` commit "Larger text" on the flip, not on a later
submit. No helper line, because the label alone says what the setting does.

<ComponentPreview name="switch-a-setting-that-applies-now" kind="example" align="start" />

### A setting with a helper line [#a-setting-with-a-helper-line]

`description` adds one sentence under the label saying what turning the switch on
changes. It is read as the description, so the accessible name stays the label.

<ComponentPreview name="switch-with-a-description" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Name the setting the switch turns on, not its state: *Larger text*, never *On* or
*Turn on larger text*. Sentence case, a few words, no full stop. The helper line
says what the setting does; it never repeats the label or carries a reading, a
threshold or a unit.

<DoDont>
  <DoDont.Do>
    **"Larger text"** names the setting and stays true whichever way the switch is flipped.
  </DoDont.Do>

  <DoDont.Dont>
    **"I agree"** records consent, which belongs in a [ConsentSheet](./consent-sheet.mdx).
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA.** A source pass over the full rubric and a rendered
pass with axe, both author-run rather than an independent review, with clinical
review still pending. The audit found nothing to change in this component's source.

**What the audit confirmed.**

* `role="switch"` comes from Base UI's Switch.Root, the accessible name is a visible
  label span tied by `aria-labelledby`, and state rides `aria-checked`.
* On and off are carried by thumb position and `aria-checked`, not the fill alone,
  and a `border-border` hairline survives `forced-colors: active` and a stripped fill.
* The 24px AA target floor is met by the track, and the 44pt house floor by the
  enclosing label row, which toggles the control.
* Focus sits on the track with the house ring, and both transitions collapse under
  `prefers-reduced-motion: reduce` through the fast-duration token.
* `pnpm run check:a11y` runs on every commit: only neutral chrome and the bridged
  primary fill, so no colour literal, no type size in `px`, and no banned word.

**Still unmeasured.** No contrast pair has been read yet, so the report below stands
in for numbers nobody has produced.

<KeyboardTable
  name="switch"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus onto the switch&#x22;,
    notes: &#x22;A single tab stop. A disabled switch is skipped.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back off the switch&#x22;,
    notes: &#x22;One stop in reverse too.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Flips the switch&#x22;,
    notes: &#x22;onCheckedChange fires with the new boolean.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Flips the switch&#x22;,
    notes: &#x22;Base UI binds Enter as well as Space.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;Nothing to move focus between.&#x22;,
  },
]"
/>

<ContrastReport component="switch" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="switch"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every inner part&#x22;,
    value: &#x22;switch, switch-label, switch-description, switch-control, switch-thumb&#x22;,
  },
  {
    attribute: &#x22;data-checked / data-unchecked&#x22;,
    condition: &#x22;On the control and the thumb, one or the other&#x22;,
    value: &#x22;Present with no value; reflects checked&#x22;,
  },
  {
    attribute: &#x22;data-disabled&#x22;,
    condition: &#x22;On the control while disabled&#x22;,
    value: &#x22;Present with no value&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="SwitchProps" />

`checked` and `onCheckedChange` make this a controlled component with no internal
state. `label` is required: a missing or empty label raises a development warning.
`disabled` keeps the switch visible and labelled while refusing focus and every
key. `className` is merged onto the root row and must carry neither colour axis.

## Related [#related]

* [Checkbox](./checkbox.mdx) proposes a value a form submits later. A switch commits on the flip.
* [ConsentSheet](./consent-sheet.mdx) records a decision with a who and a when. A switch keeps no record.
* [SegmentedControl](./segmented-control.mdx) chooses one of several options. A switch is the two-state case.
