Turns a single setting on or off, taking effect immediately. Three sizes, an optional description, start or end label placement for settings lists, and native form submission.
Users expect a switch to take effect the moment it's flipped, like a light
switch. If the change only applies after pressing Save or Submit, use a
Checkbox instead.
description adds secondary text under the label and aligns the track to the first line. Use it to explain what the setting does.
import { Switch } from "@/components/ui/switch";export default function SwitchDescription() { return ( <Switch className="max-w-xs" defaultSelected description="Pause all notifications from 10 PM to 7 AM in your local time zone." > Quiet hours </Switch> );}
isDisabled dims the switch and removes it from the tab order. isReadOnly keeps it focusable and announced, but ignores presses, which is useful while a change is saving.
import { Switch } from "@/components/ui/switch";export default function SwitchStates() { return ( <div className="grid grid-cols-2 gap-x-8 gap-y-4"> <Switch>Off</Switch> <Switch defaultSelected>On</Switch> <Switch isDisabled>Disabled</Switch> <Switch isDisabled defaultSelected> Disabled on </Switch> <Switch isReadOnly>Read-only</Switch> <Switch isReadOnly defaultSelected> Read-only on </Switch> </div> );}
When the label lives elsewhere in the layout, point to it with aria-labelledby (and to helper text with aria-describedby). A switch with no label at all needs an aria-label.
Dark mode
Follows your system by default.
import { MoonIcon } from "lucide-react";import { Switch } from "@/components/ui/switch";export default function SwitchStandalone() { return ( <div className="flex w-full max-w-xs items-center gap-3 rounded-lg border bg-card p-3"> <MoonIcon className="size-4 text-muted-foreground" /> <div className="flex-1"> <p id="dark-mode-label" className="font-medium text-sm"> Dark mode </p> <p id="dark-mode-hint" className="text-muted-foreground text-xs"> Follows your system by default. </p> </div> <Switch aria-labelledby="dark-mode-label" aria-describedby="dark-mode-hint" /> </div> );}
Because switches apply immediately, persist the change right away. Update optimistically, set isReadOnly while the request is in flight, and roll back with a message if it fails. The status text is in an aria-live region so screen-reader users hear the outcome.
import { useState } from "react";import { Spinner } from "@/components/ui/spinner";import { Switch } from "@/components/ui/switch";// Stand-in for a real request. Fails one time in four.const save = (value: boolean) => new Promise<boolean>((resolve, reject) => setTimeout( () => (Math.random() < 0.25 ? reject(new Error()) : resolve(value)), 800, ), );export default function SwitchAsync() { const [enabled, setEnabled] = useState(true); const [status, setStatus] = useState<"idle" | "saving" | "error">("idle"); const onChange = async (next: boolean) => { setEnabled(next); // optimistic setStatus("saving"); try { await save(next); setStatus("idle"); } catch { setEnabled(!next); // roll back setStatus("error"); } }; return ( <div className="flex flex-col items-start gap-2"> <Switch isSelected={enabled} onChange={onChange} isReadOnly={status === "saving"} > Auto-deploy on push </Switch> <p aria-live="polite" className="flex h-4 items-center gap-1.5 text-muted-foreground text-xs" > {status === "saving" && ( <> <Spinner size="xs" aria-hidden /> Saving… </> )} {status === "error" && ( <span className="text-destructive"> Couldn't save. Your change was undone. </span> )} </p> </div> );}
Give the switch a name and its value ("on" by default) is submitted when it's on. Like a native checkbox, nothing is submitted when it's off, so treat a missing key as false on the server.
import { useState } from "react";import { Form } from "react-aria-components";import { Button } from "@/components/ui/button";import { Switch } from "@/components/ui/switch";export default function SwitchForm() { const [result, setResult] = useState<string | null>(null); return ( <Form className="flex w-full max-w-xs flex-col gap-4" onSubmit={(e) => { e.preventDefault(); setResult( JSON.stringify(Object.fromEntries(new FormData(e.currentTarget))), ); }} > <Switch name="public" defaultSelected> Public repository </Switch> <Switch name="issues" value="enabled" defaultSelected> Enable issues </Switch> <Switch name="wiki" value="enabled"> Enable wiki </Switch> <Button type="submit" className="self-start"> Save </Button> {result && ( <code className="rounded-md bg-muted px-2 py-1 text-xs">{result}</code> )} </Form> );}
Switch doesn't support isRequired or validation. For a required "I agree" control, use a Checkbox.
Renders a native <input type="checkbox" role="switch">, visually hidden inside a <label>, so screen readers announce it as a switch that is on or off, and clicking the label toggles it.
Keep the label constant (e.g. "Wi-Fi"), not "Turn on Wi-Fi" / "Turn off Wi-Fi": the on/off state is announced separately.
A switch without children needs aria-label or aria-labelledby.
description is rendered inside the <label>, so it's read as part of the name. For long help text, render it outside and link it with aria-describedby.
If a change can fail or takes time, announce the outcome in an aria-live region.
The focus ring only appears for keyboard focus (data-focus-visible).