Switch
An immediate on and off setting that takes effect without a save.
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
labelprop 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-labelthat 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
The Quiet Indigo Rule
Anatomy#
- Track. 34 by 20px at the default size, 28 by 16px compact, fully round. Quiet Indigo when on, the accent gray when off.
- 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.
- Label. Optional, through the
labelprop: 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.
Saved the moment it flips. There is nothing to submit.
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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Off | Accent gray track, thumb at the start. |
| On | Quiet Indigo track, thumb at the end. |
| Hover | Mouse 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. |
| Pressed | The thumb stretches 4px and squashes 4px shorter (3px compact) while the pointer is down. |
| Dragging | After 2px of travel the thumb follows the pointer. On release it settles on whichever side of the midpoint it is on. |
| Focus visible | A 1px Focus Indigo ring at 50%, 2px outside the track. |
| Disabled | The whole switch and its label at 50% opacity, with no pointer events. |
| Read-only | Ignores clicks, drags and keys, with no visual change. |
Behavior#
- Controlled only: pass
checkedand update it inonCheckedChange(next).onToggle()fires after every change without the value. - Base UI renders the track as
<span role="switch">with a hidden checkbox input that carriesid,name,valueanduncheckedValue, 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
thumbTransitionto 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
sizeand 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#
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
labelprop, a<Label htmlFor>pointing at itsid, or anaria-labelthat 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'sMotionConfig reducedMotion="user"doesn't govern, so it still springs with reduced motion on. The spring is short and has no bounce.
| Keys | Action |
|---|---|
| Tab | Moves focus to the switch. |
| Space | Toggles it. |
| Enter | Toggles it. |
Design tokens#
| Token | Used for |
|---|---|
--primary | On track; mixed with 10% black on hover |
--accent | Off track; mixed with 10% --overlay on hover |
--ring | 1px focus ring at 50% |
--background | The 2px focus ring offset |
--foreground | Label text when on |
--muted-foreground | Label text when off |
spring.moderate | Thumb 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).
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | false | Whether it is on. Always pass it; the switch is controlled. |
onCheckedChange | (checked: boolean) => void | No default | Called with the next value on click, key or drag. |
onToggle | () => void | No default | Called after a change, without the value. |
label | string | No default | Text beside the track, which also names it. Omit it when an external label names the switch. |
size | "default" | "compact" | "sm" | No default | 34 by 20px or 28 by 16px. Omitted, it follows the surrounding SizeProvider. "sm" maps to "compact". |
disabled | boolean | false | Dims the switch and its label to 50% and blocks interaction. |
readOnly | boolean | No default | Blocks changes without dimming. |
thumbTransition | Transition | spring.moderate | A motion transition for the thumb. |
defaultChecked | boolean | No default | Forwarded to Base UI, but has no effect because checked is always set. See known gaps. |
id | string | No default | Set on the hidden input, for <Label htmlFor>. |
name | string | No default | Form field name. |
value | string | No default | Submitted when on. |
uncheckedValue | string | No default | Submitted when off. |
form | string | No default | The id of a form outside the switch's tree. |
required | boolean | No default | Must be on for the form to submit. |
inputRef | Ref<HTMLInputElement> | No default | Ref to the hidden input. |
aria-label | string | No default | The name when there is no label and no linked Label. |
className | string | No default | Merged 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.