Skip to content

Switch

An immediate on and off setting that takes effect without a save.

Status
Stable
Level
Atom
Category
Selection
Adoption
Not used yet
import { Switch } from "@oration/canon/components/switch";
packages/canon/src/components/switch.tsx

Supplier notifications

  • Suppliers get a PDF after every payment run.

  • Weekly, until the W-9 or bank letter arrives.

  • Suppliers are paid whether or not a W-9 is on file.

import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const id = React.useId();    const [settings, setSettings] = React.useState({        advice: true,        reminders: true,        w9: false,    });    const rows = [        {            key: "advice" as const,            title: "Email remittance advice",            on: "Suppliers get a PDF after every payment run.",            off: "Suppliers aren't told when a payment is sent.",        },        {            key: "reminders" as const,            title: "Remind suppliers about missing documents",            on: "Weekly, until the W-9 or bank letter arrives.",            off: "No reminders. Payments may wait on missing forms.",        },        {            key: "w9" as const,            title: "Hold payment until a W-9 is on file",            on: "Invoices post; only payment waits.",            off: "Suppliers are paid whether or not a W-9 is on file.",        },    ];    return (        <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="px-4 pt-4 pb-2">                <p className="text-sm font-semibold">Supplier notifications</p>            </div>            <ul>                {rows.map((row) => (                    <li                        key={row.key}                        className="flex items-start justify-between gap-6 border-t border-border px-4 py-3 first:border-t-0"                    >                        <div className="flex flex-col gap-1">                            <Label                                htmlFor={`${id}-${row.key}`}                                className="leading-snug"                            >                                {row.title}                            </Label>                            <p                                id={`${id}-${row.key}-description`}                                className="text-13 text-muted-foreground"                            >                                {settings[row.key] ? row.on : row.off}                            </p>                        </div>                        <Switch                            id={`${id}-${row.key}`}                            aria-describedby={`${id}-${row.key}-description`}                            checked={settings[row.key]}                            onCheckedChange={(checked) => {                                setSettings((current) => ({                                    ...current,                                    [row.key]: checked,                                }));                                toast.add({                                    title: `${row.title} ${checked ? "on" : "off"}`,                                });                            }}                        />                    </li>                ))}            </ul>        </div>    );}

Usage#

Switch is an on and off setting that takes effect the moment it flips, with no save. The track fills Quiet Indigo when on, and the white thumb springs across, stretches into a pill on hover and can be dragged. It is the control for settings rows, display menus and per-row enablement across Oration. The distinction people miss is immediate versus submit: if the change waits for a Save button, the control is a Checkbox.

When to use

  • For a setting that applies right away and says so: Email remittance advice, Autopay.
  • In a settings row, with the label and description on the left and the switch at the right edge.
  • In a display or view menu, with the label prop and the compact size: Show paid invoices.
  • To enable or pause one item in a list, such as a webhook endpoint or an API key, with an aria-label that names the row.

When not to use

  • For a choice that is saved with the rest of a form. Use Checkbox
  • For choosing between two named modes, such as Monthly and Annual. Use Segmented control
  • For a pressed state on a toolbar button, such as bold or wrap lines. Use Toggle
  • For selecting rows or items. Use Checkbox
  • For an action with a result, such as Send or Sync now. A switch has no result to wait for. Use Button

Switch applies, Checkbox saves

A switch changes the setting the moment it flips and confirms it in place or with a toast. Never put switches in a form that has a Save button; the person can't tell which changes already happened.

The Quiet Indigo Rule

An on switch is one of the few places indigo is spent. The label beside it stays ink, and the switch never carries a second color for danger or success.

Anatomy#

Email remittance advice
  1. Track. 34 by 20px at the default size, 28 by 16px compact, fully round. Quiet Indigo when on, the accent gray when off.
  2. Thumb. A white circle, 16px (12px compact), 2px inside the track, with a small shadow. It stretches 2px on hover and 4px, squashed 4px shorter, while pressed.
  3. Label. Optional, through the label prop: 13px (12px compact), ink when on and Slate Meta when off. Omit it when a Label or row title already names the switch.

Examples#

Switch or checkbox

The switch applies the moment it flips and confirms with a toast. The checkbox waits for the form's Save button. If there is a Save button, the control is a checkbox.

Applies now: Switch

Saved the moment it flips. There is nothing to submit.

Saved with the form: Checkbox
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ImmediateVsSubmit() {    const id = React.useId();    const [advice, setAdvice] = React.useState(true);    const [secondApprover, setSecondApprover] = React.useState(false);    return (        <div className="grid w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">            <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border">                <span className="text-xs text-muted-foreground">                    Applies now: Switch                </span>                <div className="flex items-start justify-between gap-4">                    <Label htmlFor={`${id}-advice`} className="leading-snug">                        Email remittance advice                    </Label>                    <Switch                        id={`${id}-advice`}                        checked={advice}                        onCheckedChange={(checked) => {                            setAdvice(checked);                            toast.add({                                title: checked                                    ? "Remittance advice on"                                    : "Remittance advice off",                            });                        }}                    />                </div>                <p className="text-13 text-muted-foreground">                    Saved the moment it flips. There is nothing to submit.                </p>            </div>            <form                className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"                onSubmit={(event) => {                    event.preventDefault();                    toast.add({                        type: "success",                        title: "Approval policy saved",                        description: secondApprover                            ? "Runs over $25,000 need a second approver."                            : "One approver can release any run.",                    });                }}            >                <span className="text-xs text-muted-foreground">                    Saved with the form: Checkbox                </span>                <Label className="leading-snug">                    <Checkbox                        checked={secondApprover}                        onCheckedChange={setSecondApprover}                    />                    Require a second approver over $25,000                </Label>                <Button type="submit" size="sm" className="self-end">                    Save policy                </Button>            </form>        </div>    );}

Settings row

The row's Label points at the switch with htmlFor, the description updates with the state, and the switch sits at the right edge, aligned to the label's first line.

Someone schedules each Northwind Freight payment by hand.

import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function SettingsRow() {    const id = React.useId();    const [autopay, setAutopay] = React.useState(false);    return (        <div className="flex w-full max-w-md items-start justify-between gap-6">            <div className="flex flex-col gap-1">                <Label htmlFor={`${id}-autopay`}>Autopay</Label>                <p                    id={`${id}-autopay-description`}                    className="text-13 text-muted-foreground"                >                    {autopay                        ? "Approved Northwind Freight invoices join the next payment run by themselves."                        : "Someone schedules each Northwind Freight payment by hand."}                </p>            </div>            <Switch                id={`${id}-autopay`}                aria-describedby={`${id}-autopay-description`}                checked={autopay}                onCheckedChange={(checked) => {                    setAutopay(checked);                    toast.add({                        title: checked                            ? "Autopay on for Northwind Freight"                            : "Autopay off for Northwind Freight",                    });                }}            />        </div>    );}

With the label prop

In display menus, pass label and the compact size. The whole padded row is the click target, and the label dims to Slate Meta when off.

Show paid invoices
Show zero-dollar invoices
Group by supplier

Showing 212 invoices from 48 suppliers

import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function LabelProp() {    const [showPaid, setShowPaid] = React.useState(false);    const [showZero, setShowZero] = React.useState(true);    const [group, setGroup] = React.useState(true);    const count = 212 + (showPaid ? 1480 : 0) - (showZero ? 0 : 6);    return (        <div className="flex w-full max-w-xs flex-col gap-3">            <div className="flex flex-col rounded-xl bg-card py-1 shadow-border">                <Switch                    size="compact"                    label="Show paid invoices"                    checked={showPaid}                    onCheckedChange={setShowPaid}                />                <Switch                    size="compact"                    label="Show zero-dollar invoices"                    checked={showZero}                    onCheckedChange={setShowZero}                />                <Switch                    size="compact"                    label="Group by supplier"                    checked={group}                    onCheckedChange={setGroup}                />            </div>            <p className="text-13 text-muted-foreground tabular-nums">                Showing {count.toLocaleString("en-US")} invoices                {group ? " from 48 suppliers" : ""}            </p>        </div>    );}

Sizes

Default is 34 by 20px with a 16px thumb. Compact (size="compact", or inside a compact SizeProvider) is 28 by 16px with a 12px thumb, for toolbars, menus and table rows.

Default, 34 by 20
Compact, 28 by 16
import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function Sizes() {    const [on, setOn] = React.useState(true);    const [compactOn, setCompactOn] = React.useState(true);    return (        <div className="flex items-center gap-10">            <div className="flex flex-col items-center gap-2">                <Switch                    aria-label="Default size"                    checked={on}                    onCheckedChange={setOn}                />                <span className="text-xs text-muted-foreground">                    Default, 34 by 20                </span>            </div>            <div className="flex flex-col items-center gap-2">                <Switch                    size="compact"                    aria-label="Compact size"                    checked={compactOn}                    onCheckedChange={setCompactOn}                />                <span className="text-xs text-muted-foreground">                    Compact, 28 by 16                </span>            </div>        </div>    );}

In a list

One switch per row enables or pauses that item. With no visible label, aria-label names the row: Autopay for Northwind Freight.

  • Northwind FreightNet 30
  • Halcyon LogisticsNet 45
  • Orchard Street SupplyNet 15
import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function PerRow() {    const [suppliers, setSuppliers] = React.useState([        { name: "Northwind Freight", terms: "Net 30", autopay: true },        { name: "Halcyon Logistics", terms: "Net 45", autopay: false },        { name: "Orchard Street Supply", terms: "Net 15", autopay: true },    ]);    return (        <ul className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border">            {suppliers.map((supplier) => (                <li                    key={supplier.name}                    className="flex h-11 items-center gap-3 border-b border-border px-3 text-13 last:border-b-0"                >                    <span className="min-w-0 flex-1 truncate font-medium">                        {supplier.name}                    </span>                    <span className="w-14 text-muted-foreground">                        {supplier.terms}                    </span>                    <Switch                        size="compact"                        aria-label={`Autopay for ${supplier.name}`}                        checked={supplier.autopay}                        onCheckedChange={(checked) => {                            setSuppliers((current) =>                                current.map((item) =>                                    item.name === supplier.name                                        ? { ...item, autopay: checked }                                        : item,                                ),                            );                            toast.add({                                title: `Autopay ${checked ? "on" : "off"} for ${supplier.name}`,                            });                        }}                    />                </li>            ))}        </ul>    );}

Disabled

Dim the row's text with the switch and say what would make the setting available.

Halcyon Logistics hasn't accepted card payments yet.

import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function Disabled() {    const id = React.useId();    return (        <div className="flex w-full max-w-md items-start justify-between gap-6">            <div className="flex flex-col gap-1 opacity-50">                <Label htmlFor={`${id}-card`}>Pay by virtual card</Label>                <p                    id={`${id}-card-description`}                    className="text-13 text-muted-foreground"                >                    Halcyon Logistics hasn't accepted card payments yet.                </p>            </div>            <Switch                id={`${id}-card`}                aria-describedby={`${id}-card-description`}                checked={false}                disabled            />        </div>    );}

States#

Off
On
Disabled off
Disabled on
Compact off
Compact on
import { Switch } from "@oration/canon/components/switch";export function StatesRow() {    const states = [        {            name: "Off",            checked: false,            disabled: false,            size: "default" as const,        },        {            name: "On",            checked: true,            disabled: false,            size: "default" as const,        },        {            name: "Disabled off",            checked: false,            disabled: true,            size: "default" as const,        },        {            name: "Disabled on",            checked: true,            disabled: true,            size: "default" as const,        },        {            name: "Compact off",            checked: false,            disabled: false,            size: "compact" as const,        },        {            name: "Compact on",            checked: true,            disabled: false,            size: "compact" as const,        },    ];    return (        <div className="grid w-full grid-cols-3 gap-y-6 sm:grid-cols-6">            {states.map((state) => (                <div                    key={state.name}                    className="flex flex-col items-center gap-3"                >                    <div inert className="flex h-5 items-center">                        <Switch                            aria-label={state.name}                            checked={state.checked}                            disabled={state.disabled}                            size={state.size}                        />                    </div>                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                </div>            ))}        </div>    );}
States
StateTreatment
OffAccent gray track, thumb at the start.
OnQuiet Indigo track, thumb at the end.
HoverMouse only. The track darkens (indigo mixed with 10% black, or gray with 10% ink) and the thumb stretches 2px into a pill toward the center.
PressedThe thumb stretches 4px and squashes 4px shorter (3px compact) while the pointer is down.
DraggingAfter 2px of travel the thumb follows the pointer. On release it settles on whichever side of the midpoint it is on.
Focus visibleA 1px Focus Indigo ring at 50%, 2px outside the track.
DisabledThe whole switch and its label at 50% opacity, with no pointer events.
Read-onlyIgnores clicks, drags and keys, with no visual change.

Behavior#

  • Controlled only: pass checked and update it in onCheckedChange(next). onToggle() fires after every change without the value.
  • Base UI renders the track as <span role="switch"> with a hidden checkbox input that carries id, name, value and uncheckedValue, so <Label htmlFor> works and the value submits with a native form.
  • The thumb moves on the house moderate spring: 160ms with no bounce. Pass thumbTransition to change it. The track color changes over 80ms.
  • Clicking the label text, the track or anywhere on the wrapper toggles it. A drag that ends on the same side doesn't.
  • Omit size and the switch follows the nearest SizeProvider; "sm" is kept as an alias for "compact".
  • With label, the wrapper becomes a padded row (12px sides and 8px top and bottom by default) built for menus and lists.

Do and don't#

Do. Apply the change when the switch flips and confirm it in place.
Don't. Put switches in a form with a Save button. Nobody can tell which settings already changed.
Email remittance advice
Do. Name the setting in the label and let the track show the state.
Enabled
Don't. Label the switch with its state, such as On or Enabled. The label then contradicts itself every time it flips.

Content#

  • Name the setting as a noun phrase or a short verb phrase: Autopay, Email remittance advice.
  • Don't write On, Off, Enabled or Disabled next to a switch; the track already says it.
  • Put the consequence in the description, and update it with the state when that helps: Suppliers get a PDF after every payment run.
  • Confirm changes that affect other people with a toast that names what changed: Autopay on for Northwind Freight.

Accessibility#

  • Name every switch: the label prop, a <Label htmlFor> pointing at its id, or an aria-label that names the row (Autopay for Northwind Freight).
  • Link a description with aria-describedby; it is passed to the switch element.
  • Screen readers announce the role as switch and the state as on or off from aria-checked.
  • The track is 20px tall at the default size and 16px compact. In touch layouts, keep the default size and put the switch in a row whose label also toggles it.
  • The thumb's slide uses animate() directly, which the app's MotionConfig reducedMotion="user" doesn't govern, so it still springs with reduced motion on. The spring is short and has no bounce.
Keyboard interactions
KeysAction
TabMoves focus to the switch.
SpaceToggles it.
EnterToggles it.

Design tokens#

Design tokens
TokenUsed for
--primaryOn track; mixed with 10% black on hover
--accentOff track; mixed with 10% --overlay on hover
--ring1px focus ring at 50%
--backgroundThe 2px focus ring offset
--foregroundLabel text when on
--muted-foregroundLabel text when off
spring.moderateThumb travel, 160ms with no bounce

API reference#

Switch

A Base UI Switch drawn with a motion thumb, with hover, press and drag handled by the wrapper. The ref goes to the wrapper <div>.

Other props spread onto <div> (HTMLAttributes<HTMLDivElement> without onToggle).

Props of Switch
PropTypeDefaultDescription
checkedbooleanfalseWhether it is on. Always pass it; the switch is controlled.
onCheckedChange(checked: boolean) => voidNo defaultCalled with the next value on click, key or drag.
onToggle() => voidNo defaultCalled after a change, without the value.
labelstringNo defaultText beside the track, which also names it. Omit it when an external label names the switch.
size"default" | "compact" | "sm"No default34 by 20px or 28 by 16px. Omitted, it follows the surrounding SizeProvider. "sm" maps to "compact".
disabledbooleanfalseDims the switch and its label to 50% and blocks interaction.
readOnlybooleanNo defaultBlocks changes without dimming.
thumbTransitionTransitionspring.moderateA motion transition for the thumb.
defaultCheckedbooleanNo defaultForwarded to Base UI, but has no effect because checked is always set. See known gaps.
idstringNo defaultSet on the hidden input, for <Label htmlFor>.
namestringNo defaultForm field name.
valuestringNo defaultSubmitted when on.
uncheckedValuestringNo defaultSubmitted when off.
formstringNo defaultThe id of a form outside the switch's tree.
requiredbooleanNo defaultMust be on for the form to submit.
inputRefRef<HTMLInputElement>No defaultRef to the hidden input.
aria-labelstringNo defaultThe name when there is no label and no linked Label.
classNamestringNo defaultMerged onto the wrapper, not the track.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The switch is controlled only. checked defaults to false and is always passed to Base UI, so defaultChecked is ignored and an uncontrolled <Switch /> never moves.

The track is 34 by 20px; DESIGN.md specifies 32 by 18px.

The off track reads --accent, and its hover mixes in the legacy --overlay token. DESIGN.md makes the unchecked track Field Stroke (--input).

Focus is a 1px ring at a 2px offset, not the 3px ring at 40 to 50% every other control uses.

The thumb is hard-coded bg-white in both themes.

The label turns Slate Meta when off, which can read as a disabled setting next to other ink labels.