Switch
An on/off toggle for a setting that applies at once.
Vibra moves the thumb and colours the track on the theme's motion tokens, focuses with the kit's solid accent outline, and keeps the unchecked track the opaque --input grey in both modes, where shadcn's goes translucent in the dark. Checked, the track is the accent through --primary, and the thumb travels to the inline end — left, on a right-to-left page. Label a switch with a statement that is true when it is on.
Install
npx shadcn@latest add @vibra/switchNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
export default function SwitchDemo() {
return (
<div className="flex w-full max-w-sm items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="weekly-digest">Weekly digest</Label>
<p id="weekly-digest-hint" className="text-sm text-muted-foreground">
Revenue, churn and new trials, every Monday.
</p>
</div>
<Switch id="weekly-digest" defaultChecked aria-describedby="weekly-digest-hint" />
</div>
)
}Says what on means
The label is a statement true when the switch is on, and the line under it says what the current position means.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
// The label is a statement that is true when the switch is on, and the line
// under it says, in words, what the current position means right now.
export default function SwitchState() {
const [open, setOpen] = React.useState(true)
return (
<div className="flex w-full max-w-sm items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="switch-state-public">Anyone with the link can see the status page</Label>
<p id="switch-state-public-hint" className="text-sm text-muted-foreground">
{open
? "On: status.northwind.example shows uptime and incidents to everyone."
: "Off: only signed-in members of Northwind can see it."}
</p>
</div>
<Switch
id="switch-state-public"
checked={open}
onCheckedChange={setOpen}
aria-describedby="switch-state-public-hint"
className="mt-0.5"
/>
</div>
)
}Disabled, with a reason
data-disabled on the Field dims the label with the switch; the reason and the way to change it keep their contrast.
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
// A switch the plan doesn't include. data-disabled on the Field dims the label
// with the switch; the reason, with the way to change it, stays at full
// strength and is joined to the switch.
export default function SwitchDisabled() {
return (
<Field orientation="horizontal" data-disabled className="max-w-sm">
<FieldContent>
<FieldLabel htmlFor="switch-disabled-saml">Sign in with SAML</FieldLabel>
<FieldDescription id="switch-disabled-saml-reason">
SAML comes with the Scale plan. <a href="/settings/billing">Compare plans</a>
</FieldDescription>
</FieldContent>
<Switch id="switch-disabled-saml" disabled aria-describedby="switch-disabled-saml-reason" />
</Field>
)
}Governs the ones under it
Off, the switches under it keep their positions but can't change, and say why — turning it back on restores them.
"use client"
import * as React from "react"
import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
const KINDS = [
{ id: "paid", label: "Invoice paid" },
{ id: "failed", label: "Payment failed" },
{ id: "dispute", label: "Dispute opened" },
]
// One switch governs the ones under it. Off, the others keep their positions
// but can't change, and say why — so turning the first back on restores them.
export default function SwitchDependent() {
const [email, setEmail] = React.useState(false)
const [kinds, setKinds] = React.useState<Record<string, boolean>>({ paid: true, failed: true, dispute: false })
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend variant="label">Billing emails</FieldLegend>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="switch-dependent-email">Send billing emails</FieldLabel>
<FieldDescription id="switch-dependent-email-hint">To finance@northwind.example.</FieldDescription>
</FieldContent>
<Switch id="switch-dependent-email" checked={email} onCheckedChange={setEmail} aria-describedby="switch-dependent-email-hint" />
</Field>
<FieldGroup className="gap-3 border-s-2 border-rule ps-4">
{KINDS.map((kind) => (
<Field key={kind.id} orientation="horizontal" data-disabled={!email || undefined}>
<FieldLabel htmlFor={`switch-dependent-${kind.id}`} className="font-normal">
{kind.label}
</FieldLabel>
<Switch
id={`switch-dependent-${kind.id}`}
size="sm"
checked={kinds[kind.id]}
disabled={!email}
aria-describedby={email ? undefined : "switch-dependent-why"}
onCheckedChange={(next) => setKinds((current) => ({ ...current, [kind.id]: next }))}
/>
</Field>
))}
{email ? null : (
<p id="switch-dependent-why" className="text-sm text-muted-foreground">
Turn on billing emails to choose which ones.
</p>
)}
</FieldGroup>
</FieldSet>
)
}Waits for the server
Read-only while it saves, never disabled, so the focus stays; a refusal puts it back and says why in a danger Callout.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
// The server has the last word. While it answers, the switch holds still —
// read-only, never disabled, so the focus stays on it — and says it is busy;
// a refusal puts the switch back and says why, where the reader is looking.
export default function SwitchSaving() {
const [on, setOn] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [refused, setRefused] = React.useState(false)
const attempts = React.useRef(0)
const control = React.useRef<HTMLElement>(null)
function save(next: boolean) {
setOn(next)
setSaving(true)
setRefused(false)
setTimeout(() => {
attempts.current += 1
// The first try is refused so the example shows both endings.
const ok = !next || attempts.current > 1
setSaving(false)
if (!ok) {
setOn(!next)
setRefused(true)
}
}, 700)
}
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="switch-saving-autocharge">Charge overdue invoices to the card on file</Label>
<p id="switch-saving-autocharge-status" role="status" aria-live="polite" className="flex items-center gap-1.5 text-sm text-muted-foreground">
{saving ? <Spinner aria-hidden="true" role={undefined} aria-label={undefined} className="size-3.5" /> : null}
{saving ? "Saving…" : on ? "On. Overdue invoices are charged at 09:00." : "Off. Overdue invoices wait for the customer."}
</p>
</div>
<Switch
ref={control}
id="switch-saving-autocharge"
checked={on}
readOnly={saving}
aria-busy={saving || undefined}
aria-describedby="switch-saving-autocharge-status"
onCheckedChange={(next) => save(next)}
className="mt-0.5"
/>
</div>
{refused ? (
<Callout variant="danger" role="alert" title="Couldn't turn it on">
The card ending 1881 expired in June.{" "}
<Button
variant="link"
className="h-auto p-0 underline"
onClick={() => {
// The button goes with the refusal, so the focus goes back to the switch first.
control.current?.focus()
save(true)
}}
>
Try again
</Button>
</Callout>
) : null}
</div>
)
}Asks before turning off
On is safe and immediate; off weakens every account, so it asks first, and either answer hands the focus back.
"use client"
import * as React from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
// Turning this on is safe and happens at once; turning it off weakens every
// account, so it asks first. Either answer hands the focus back to the switch.
export default function SwitchConfirm() {
const control = React.useRef<HTMLElement>(null)
const [required, setRequired] = React.useState(true)
const [asking, setAsking] = React.useState(false)
return (
<div className="flex w-full max-w-sm items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="switch-confirm-2fa">Everyone signs in with two steps</Label>
<p id="switch-confirm-2fa-hint" className="text-sm text-muted-foreground">
{required ? "On for all 48 members." : "Off: members choose for themselves."}
</p>
</div>
<Switch
ref={control}
id="switch-confirm-2fa"
checked={required}
aria-describedby="switch-confirm-2fa-hint"
onCheckedChange={(next) => (next ? setRequired(true) : setAsking(true))}
className="mt-0.5"
/>
<AlertDialog open={asking} onOpenChange={setAsking}>
<AlertDialogContent finalFocus={control}>
<AlertDialogHeader>
<AlertDialogTitle>Stop requiring two-step sign-in?</AlertDialogTitle>
<AlertDialogDescription>
Members who haven't set it up can sign in with a password alone. Owners keep it either way.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep requiring it</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setRequired(false)
setAsking(false)
}}
>
Stop requiring it
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}As a card
The whole card is the switch's label; on, it takes the kit's tint. The title names the switch and the line describes it.
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
// The whole card is the switch's label: click anywhere on it. On, the card
// takes the kit's tint; the title names the switch and the line describes it.
export default function SwitchCard() {
return (
<FieldLabel htmlFor="switch-card-guests" className="max-w-sm">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle id="switch-card-guests-title">Members can invite guests</FieldTitle>
<FieldDescription id="switch-card-guests-hint">
Guests see only the dashboards they are invited to, and never billing.
</FieldDescription>
</FieldContent>
<Switch
id="switch-card-guests"
defaultChecked
aria-labelledby="switch-card-guests-title"
aria-describedby="switch-card-guests-hint"
/>
</Field>
</FieldLabel>
)
}In a table
size="sm" keeps the row at its height, and each switch is named for its row, because "Enabled" alone says nothing.
"use client"
import * as React from "react"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const HOOKS = [
{ url: "https://hooks.blueharbor.example/northwind", events: "invoice.paid", on: true },
{ url: "https://ops.kestrel.example/alerts", events: "incident.opened", on: false },
{ url: "https://ledger.ferrolind.example/in", events: "payout.sent", on: true },
]
// Small switches in a table row: size="sm" keeps the row at its height, and
// each switch is named for its row, because "Enabled" alone says nothing. The
// last column keeps 12px at its end, the reach of the switch's touch area, so
// the table never scrolls sideways for it on a phone.
export default function SwitchTable() {
const [on, setOn] = React.useState(HOOKS.map((hook) => hook.on))
return (
<Table className="max-w-lg">
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead className="max-sm:hidden">Event</TableHead>
<TableHead className="w-20 pe-3 text-end">Enabled</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{HOOKS.map((hook, index) => (
<TableRow key={hook.url}>
<TableCell className="max-w-0 truncate font-mono text-xs" title={hook.url}>
{hook.url.replace("https://", "")}
</TableCell>
<TableCell className="font-mono text-xs max-sm:hidden">{hook.events}</TableCell>
<TableCell className="pe-3 text-end">
<Switch
size="sm"
checked={on[index]}
onCheckedChange={(next) => setOn((current) => current.map((value, i) => (i === index ? next : value)))}
aria-label={`Send to ${hook.url.replace("https://", "")}`}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}In a card's toolbar
A small switch beside its label that changes what the card shows.
"use client"
import * as React from "react"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
const MRR = { september: 84120, august: 79400 }
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
// A small switch in a card's strip, beside its label, that changes what the
// card shows. The figure it adds is part of the card, so no status line is
// needed: the switch's own state says what is on.
export default function SwitchToolbar() {
const [compare, setCompare] = React.useState(true)
const change = ((MRR.september - MRR.august) / MRR.august) * 100
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>MRR</CardTitle>
<CardDescription>September</CardDescription>
<CardAction className="flex items-center gap-2">
<Label htmlFor="switch-toolbar-compare" className="text-xs font-normal text-muted-foreground">
Compare with August
</Label>
<Switch id="switch-toolbar-compare" size="sm" checked={compare} onCheckedChange={setCompare} />
</CardAction>
</CardHeader>
<CardContent className="flex items-baseline gap-3">
<span className="text-3xl font-bold tabular-nums">{money.format(MRR.september)}</span>
{compare ? (
<span className="text-sm text-muted-foreground tabular-nums">
<span className="font-medium text-foreground">+{change.toFixed(1)}%</span> from {money.format(MRR.august)}
</span>
) : null}
</CardContent>
</Card>
)
}Right to left
Arabic settings: the switch at the row's left end, and on means the thumb has travelled left.
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
const ROWS = [
{ id: "digest", label: "الملخص الأسبوعي", hint: "الإيرادات والتجارب الجديدة، كل يوم اثنين.", on: true },
{ id: "alerts", label: "تنبيهات الدفع الفاشل", hint: "رسالة فورية عند فشل أي عملية دفع.", on: false },
]
// Arabic settings: the switch sits at the row's left end, and on means the
// thumb has travelled to the left — the direction the reader reads towards.
export default function SwitchRtl() {
return (
<div dir="rtl" lang="ar" className="flex w-full max-w-sm flex-col gap-4">
{ROWS.map((row) => (
<div key={row.id} className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor={`switch-rtl-${row.id}`}>{row.label}</Label>
<p id={`switch-rtl-${row.id}-hint`} className="text-sm text-muted-foreground">
{row.hint}
</p>
</div>
<Switch id={`switch-rtl-${row.id}`} defaultChecked={row.on} aria-describedby={`switch-rtl-${row.id}-hint`} className="mt-0.5" />
</div>
))}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "default" | "default" | A 32 × 18 track, or 24 × 14 for a dense row. |
| checked / defaultChecked | boolean | — | On or off, controlled or not. |
| onCheckedChange | (checked: boolean) => void | — | Called with the new position; a caller can hold it back, to confirm or to save first. |
| disabled | boolean | false | Out of the Tab order and dimmed; say why in text joined with aria-describedby. |
| readOnly | boolean | false | Can't be switched but keeps the focus — for the moment a change is being saved. |
Dependencies
Registry
Source
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-[background-color,border-color] duration-(--duration-fast) ease-(--ease-standard) focus-ring group-has-[:focus-visible]/field-label:border-transparent after:absolute after:-inset-x-3 after:-inset-y-2 aria-invalid:border-destructive data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] data-checked:bg-primary data-unchecked:bg-input data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
{/* The thumb starts at the inline start, so "on" travels towards the
inline end: right in a left-to-right page, left in a right-to-left one. */}
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform duration-(--duration-fast) ease-(--ease-standard) group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] rtl:group-data-[size=default]/switch:data-checked:-translate-x-[calc(100%-2px)] rtl:group-data-[size=sm]/switch:data-checked:-translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }