Checkbox
A square check on the card plane that fills in the accent when it is ticked.
Vibra puts the box on the card plane with its --input boundary in both modes, where shadcn's is transparent and translucent in the dark, gives it the kit's solid focus outline, and fades the check in and out over --duration-fast instead of letting it pop. Ticked, it fills in the accent through --primary; mixed, it fills the same way and shows a dash. Disabled, the box and a Label beside it dim through Base UI's data-disabled — the box is a span, which is never :disabled.
Install
npx shadcn@latest add @vibra/checkboxNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Checkbox } from "@/components/ui/checkbox"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
export default function CheckboxDemo() {
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend variant="label">Email me when</FieldLegend>
<div className="flex items-start gap-3">
<Checkbox id="notify-invoices" defaultChecked aria-describedby="notify-invoices-hint" />
<div className="flex flex-col gap-1">
<Label htmlFor="notify-invoices">An invoice is paid</Label>
<p id="notify-invoices-hint" className="text-sm text-muted-foreground">
Sent to finance@northwind.example.
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Checkbox id="notify-failed" />
<Label htmlFor="notify-failed">A payment fails</Label>
</div>
</FieldSet>
)
}Side by side
Short options in a row that wraps on a phone. Each label shows the short day and says the whole one.
import { Checkbox } from "@/components/ui/checkbox"
import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const DAYS = [
["mon", "Mon", "Monday", true],
["tue", "Tue", "Tuesday", false],
["wed", "Wed", "Wednesday", true],
["thu", "Thu", "Thursday", false],
["fri", "Fri", "Friday", true],
["sat", "Sat", "Saturday", false],
["sun", "Sun", "Sunday", false],
] as const
// Short options side by side, wrapping on a phone. The label shows the short
// day and says the whole one, so a screen reader never reads "Thu" aloud.
export default function CheckboxInline() {
return (
<FieldSet className="w-full max-w-lg gap-3" aria-describedby="checkbox-inline-hint">
<FieldLegend variant="label">Send the pipeline report on</FieldLegend>
<div className="flex flex-wrap gap-x-5 gap-y-3">
{DAYS.map(([value, short, day, checked]) => (
<div key={value} className="flex items-center gap-2">
<Checkbox id={`checkbox-inline-${value}`} name="days" value={value} defaultChecked={checked} />
<Label htmlFor={`checkbox-inline-${value}`} className="font-normal">
<span aria-hidden="true">{short}</span>
<span className="sr-only">{day}</span>
</Label>
</div>
))}
</div>
<FieldDescription id="checkbox-inline-hint">At 08:00 Dublin time, to the sales team.</FieldDescription>
</FieldSet>
)
}Disabled, with a reason
Two boxes that can't change, for two reasons, each said under its box. The box and label dim; the reason doesn't.
import { Checkbox } from "@/components/ui/checkbox"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const ROWS = [
{ id: "security", label: "Security alerts", checked: true, reason: "Always sent: they can't be turned off." },
{ id: "sla", label: "Monthly SLA report", checked: false, reason: "Comes with the Scale plan." },
]
// Two boxes that can't change, for two different reasons, each said under its
// box. The box and the label beside it dim; the reason keeps its full contrast.
export default function CheckboxDisabled() {
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend variant="label">Workspace emails</FieldLegend>
{ROWS.map((row) => (
<div key={row.id} className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1">
<Checkbox
id={`checkbox-disabled-${row.id}`}
disabled
defaultChecked={row.checked}
aria-describedby={`checkbox-disabled-${row.id}-reason`}
/>
<Label htmlFor={`checkbox-disabled-${row.id}`}>{row.label}</Label>
<p id={`checkbox-disabled-${row.id}-reason`} className="col-start-2 text-sm text-muted-foreground">
{row.reason}
</p>
</div>
))}
</FieldSet>
)
}Required, with an error
"Required" is in the label. Pressing on without the box puts the focus on it, marked invalid and described by what to do.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
// A box that has to be ticked before the form goes: "required" is in the label,
// and pressing on without it puts the focus on the box, marked invalid and
// described by what to do.
export default function CheckboxRequired() {
const box = React.useRef<HTMLElement>(null)
const [understood, setUnderstood] = React.useState(false)
const [error, setError] = React.useState(false)
const [done, setDone] = React.useState(false)
return (
<form
noValidate
className="flex w-full max-w-sm flex-col gap-3"
onSubmit={(event) => {
event.preventDefault()
if (!understood) {
setError(true)
box.current?.focus()
return
}
setDone(true)
}}
>
<div className="flex items-start gap-3">
<Checkbox
ref={box}
id="checkbox-required-ack"
required
checked={understood}
aria-invalid={error || undefined}
aria-describedby={error ? "checkbox-required-error" : undefined}
onCheckedChange={(next) => {
setUnderstood(next)
if (next) setError(false)
}}
/>
<Label htmlFor="checkbox-required-ack" className="block leading-snug font-normal">
I understand the team loses access to reports on 1 October <span className="text-muted-foreground">(required)</span>
</Label>
</div>
<FieldError id="checkbox-required-error" className="ms-7">
{error ? "Tick the box to confirm, or keep the plan." : null}
</FieldError>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" variant="destructive" disabled={done} focusableWhenDisabled>
Cancel the Scale plan
</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{done ? "Cancelled. Reports stay open until 1 October." : null}
</p>
</div>
</form>
)
}At least one
A rule for the group: exporting with nothing ticked marks every box invalid with one message and moves the focus to the first.
"use client"
import * as React from "react"
import { DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError, FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const PARTS = [
["invoices", "Invoices"],
["credit-notes", "Credit notes"],
["payouts", "Payouts"],
] as const
// The rule is the group's, not one box's: exporting with nothing ticked marks
// every box invalid, describes each by the one message, and moves the focus
// to the first — ticking any of them clears it.
export default function CheckboxAtLeastOne() {
const first = React.useRef<HTMLElement>(null)
const [parts, setParts] = React.useState<string[]>([])
const [error, setError] = React.useState(false)
const [exported, setExported] = React.useState<string | null>(null)
return (
<form
noValidate
className="flex w-full max-w-sm flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
if (parts.length === 0) {
setError(true)
setExported(null)
first.current?.focus()
return
}
setExported(`Exporting ${parts.length} of 3 for September as CSV.`)
}}
>
<FieldSet className="gap-3" aria-describedby={error ? "checkbox-at-least-one-error" : undefined}>
<FieldLegend variant="label">Include in the export</FieldLegend>
{PARTS.map(([value, label], index) => (
<div key={value} className="flex items-center gap-3">
<Checkbox
ref={index === 0 ? first : undefined}
id={`checkbox-at-least-one-${value}`}
checked={parts.includes(value)}
aria-invalid={error || undefined}
aria-describedby={error ? "checkbox-at-least-one-error" : undefined}
onCheckedChange={(next) => {
setParts((current) => (next ? [...current, value] : current.filter((part) => part !== value)))
if (next) setError(false)
}}
/>
<Label htmlFor={`checkbox-at-least-one-${value}`} className="font-normal">
{label}
</Label>
</div>
))}
<FieldError id="checkbox-at-least-one-error">{error ? "Choose at least one thing to export." : null}</FieldError>
</FieldSet>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" variant="outline">
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export September
</Button>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{exported}
</p>
</div>
</form>
)
}With a link in the label
The document opens without ticking the box and says it opens a new tab; the action waits, focusable, until it is ticked.
"use client"
import * as React from "react"
import { ExternalLinkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
// The document is a link inside the label: it opens without ticking the box,
// and says it opens a new tab. Until the box is ticked, the button stays in the
// Tab order and is described by what it waits for.
export default function CheckboxConsent() {
const [agreed, setAgreed] = React.useState(false)
const [on, setOn] = React.useState(false)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex items-start gap-3">
<Checkbox id="checkbox-consent-dpa" checked={agreed} onCheckedChange={setAgreed} />
<Label htmlFor="checkbox-consent-dpa" className="block leading-snug font-normal">
I agree to the{" "}
<a href="/legal/dpa" target="_blank" rel="noreferrer" className="font-medium text-brand underline underline-offset-4">
Data Processing Addendum
<ExternalLinkIcon aria-hidden="true" className="ms-0.5 inline size-3 align-baseline" />{" "}
<span className="sr-only">(opens in a new tab)</span>
</a>{" "}
for this workspace.
</Label>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={!agreed || on}
focusableWhenDisabled
aria-describedby={agreed ? undefined : "checkbox-consent-waiting"}
onClick={() => setOn(true)}
>
{on ? "EU data residency is on" : "Turn on EU data residency"}
</Button>
{agreed ? null : (
<span id="checkbox-consent-waiting" className="text-sm text-muted-foreground">
Agree to the addendum first.
</span>
)}
</div>
</div>
)
}Select all
The parent box is mixed while some rows are chosen. Chosen rows are a tint, and the action counts what it will do.
"use client"
import * as React from "react"
import { SendIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const INVOICES = [
{ id: "INV-2041", customer: "Blue Harbor Logistics", amount: "$1,280.00" },
{ id: "INV-2038", customer: "Kestrel Health", amount: "$640.00" },
{ id: "INV-2035", customer: "Ferro & Lind", amount: "$2,115.50" },
]
// The parent box is mixed while some rows are chosen, and ticking it chooses
// them all. Chosen rows are a tint, never a stroke; the button counts what it
// will act on and, with nothing chosen, stays focusable and says why it waits.
export default function CheckboxSelectAll() {
const [chosen, setChosen] = React.useState<string[]>(["INV-2041"])
const all = chosen.length === INVOICES.length
const some = chosen.length > 0 && !all
return (
<FieldSet className="w-full max-w-md gap-2">
<FieldLegend variant="label">Overdue invoices</FieldLegend>
<div className="flex items-center gap-3 border-b border-rule px-2 pb-2">
<Checkbox
id="checkbox-select-all-all"
checked={all}
indeterminate={some}
aria-describedby="checkbox-select-all-count"
onCheckedChange={() => setChosen(all ? [] : INVOICES.map((invoice) => invoice.id))}
/>
<Label htmlFor="checkbox-select-all-all" className="font-normal">
Choose all
</Label>
<span id="checkbox-select-all-count" className="ms-auto text-sm text-muted-foreground tabular-nums">
{chosen.length} of {INVOICES.length} chosen
</span>
</div>
<ul className="flex flex-col">
{INVOICES.map((invoice) => {
const on = chosen.includes(invoice.id)
return (
<li key={invoice.id} className={cn("flex items-center gap-3 rounded-md px-2 py-1.5", on && "bg-brand-muted")}>
<Checkbox
id={`checkbox-select-all-${invoice.id}`}
checked={on}
onCheckedChange={(next) =>
setChosen((current) => (next ? [...current, invoice.id] : current.filter((id) => id !== invoice.id)))
}
/>
<Label htmlFor={`checkbox-select-all-${invoice.id}`} className="flex-1 justify-between font-normal">
<span className="truncate">
<span className="font-mono text-xs">{invoice.id}</span> · {invoice.customer}
</span>
<span className="tabular-nums">{invoice.amount}</span>
</Label>
</li>
)
})}
</ul>
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
size="sm"
disabled={chosen.length === 0}
focusableWhenDisabled
aria-describedby={chosen.length === 0 ? "checkbox-select-all-waiting" : undefined}
>
<SendIcon data-icon="inline-start" aria-hidden="true" className="rtl:-scale-x-100" />
Send {chosen.length === 1 ? "1 reminder" : `${chosen.length} reminders`}
</Button>
{chosen.length === 0 ? (
<span id="checkbox-select-all-waiting" className="text-sm text-muted-foreground">
Choose an invoice first.
</span>
) : null}
</div>
</FieldSet>
)
}Reveals a field
Ticking the box opens the field it needs, right under it; the box says what it controls and whether it is open.
"use client"
import * as React from "react"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// Ticking the box opens the one field it needs, right under it. The box says it
// controls that field and whether it is open, and the focus stays on the box.
export default function CheckboxReveal() {
const [copy, setCopy] = React.useState(true)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-start gap-3">
<Checkbox
id="checkbox-reveal-copy"
checked={copy}
onCheckedChange={setCopy}
aria-expanded={copy}
aria-controls="checkbox-reveal-panel"
/>
<Label htmlFor="checkbox-reveal-copy" className="leading-snug">
Send a copy of every invoice to another address
</Label>
</div>
<div id="checkbox-reveal-panel" hidden={!copy} className="ms-7 flex flex-col gap-2 border-s-2 border-rule ps-4">
<Label htmlFor="checkbox-reveal-email">Copy to</Label>
<Input id="checkbox-reveal-email" type="email" autoComplete="off" defaultValue="ap@blueharbor.example" />
</div>
</div>
)
}Choice cards
Several may be chosen, so each card holds a checkbox named by its title and described by its line.
import { BellRingIcon, MailIcon, MessageSquareIcon } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle } from "@/components/ui/field"
const CHANNELS = [
{ value: "email", icon: MailIcon, title: "Email", hint: "To the on-call's work address.", checked: true },
{ value: "sms", icon: MessageSquareIcon, title: "Text message", hint: "Only for incidents that page.", checked: true },
{ value: "push", icon: BellRingIcon, title: "Mobile push", hint: "Needs the Northwind app.", checked: false },
]
// Several may be chosen, so each card holds a checkbox. A chosen card is the
// kit's tint; the icon is decoration, and each box is named by its title.
export default function CheckboxCards() {
return (
<FieldSet className="w-full max-w-md gap-3">
<FieldLegend variant="label">Alert the on-call by</FieldLegend>
{CHANNELS.map((channel) => (
<FieldLabel key={channel.value} htmlFor={`checkbox-cards-${channel.value}`}>
<Field orientation="horizontal">
<channel.icon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<FieldContent>
<FieldTitle id={`checkbox-cards-${channel.value}-title`}>{channel.title}</FieldTitle>
<FieldDescription id={`checkbox-cards-${channel.value}-hint`}>{channel.hint}</FieldDescription>
</FieldContent>
<Checkbox
id={`checkbox-cards-${channel.value}`}
name="channels"
value={channel.value}
defaultChecked={channel.checked}
aria-labelledby={`checkbox-cards-${channel.value}-title`}
aria-describedby={`checkbox-cards-${channel.value}-hint`}
/>
</Field>
</FieldLabel>
))}
</FieldSet>
)
}With faces
People to choose, each row a label, the face hidden beside the name and the box at the row's end.
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const PEOPLE = [
{ name: "Maren Kovač", role: "Finance lead", initials: "MK", checked: true },
{ name: "Tomás Rivera", role: "Head of support", initials: "TR", checked: false },
{ name: "Aisha Adeyemi", role: "Controller", initials: "AA", checked: true },
]
// People to choose, each row a label: the face is a picture of the name beside
// it, so it is hidden, and the box sits at the row's end, where a list of
// people keeps its controls.
export default function CheckboxFaces() {
return (
<FieldSet className="w-full max-w-sm gap-2" aria-describedby="checkbox-faces-hint">
<FieldLegend variant="label">Approve refunds over $1,000</FieldLegend>
<FieldDescription id="checkbox-faces-hint">Any one of them can approve.</FieldDescription>
<ul className="flex flex-col divide-y divide-border rounded-lg border">
{PEOPLE.map((person) => {
const id = `checkbox-faces-${person.initials.toLowerCase()}`
return (
<li key={person.name}>
<Label htmlFor={id} className="w-full cursor-pointer gap-3 px-3 py-2.5 font-normal">
<Avatar aria-hidden="true" size="sm">
<AvatarImage src={avatarFor(person.name)} alt="" />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{person.name}</span>
<span className="truncate text-xs text-muted-foreground">{person.role}</span>
</span>
<Checkbox id={id} name="approvers" value={person.name} defaultChecked={person.checked} />
</Label>
</li>
)
})}
</ul>
</FieldSet>
)
}As a to-do
A done task is struck through for the eye; the box is what says it is done, and the count is a status.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const TASKS = ["Reconcile card payouts", "Chase the three overdue invoices", "File the Q3 VAT return"]
// A checklist: a done task is struck through and dimmed, but the box is what
// says it is done — the strike is only for the eye. The count is a status.
export default function CheckboxTodo() {
const [done, setDone] = React.useState<string[]>([TASKS[0]])
return (
<FieldSet className="w-full max-w-sm gap-3">
<FieldLegend variant="label">Month-end close</FieldLegend>
{TASKS.map((task, index) => {
const checked = done.includes(task)
return (
<div key={task} className="flex items-center gap-3">
<Checkbox
id={`checkbox-todo-${index}`}
checked={checked}
onCheckedChange={(next) => setDone((current) => (next ? [...current, task] : current.filter((t) => t !== task)))}
/>
<Label
htmlFor={`checkbox-todo-${index}`}
className={cn("font-normal transition-colors", checked && "text-muted-foreground line-through")}
>
{task}
</Label>
</div>
)
})}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{done.length === TASKS.length ? "All done. The books are closed." : `${done.length} of ${TASKS.length} done`}
</p>
</FieldSet>
)
}Right to left
An Arabic group with each box at the right of its label and the parent box mixed while some are ticked.
"use client"
import * as React from "react"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
const OPTIONS = [
["paid", "دُفعت فاتورة"],
["failed", "فشلت عملية دفع"],
["refund", "صدر استرداد"],
] as const
// An Arabic group: each box sits at the right of its label, and the parent
// box shows the mixed state while some of the three are ticked.
export default function CheckboxRtl() {
const [on, setOn] = React.useState<string[]>(["paid"])
const all = on.length === OPTIONS.length
return (
<FieldSet dir="rtl" lang="ar" className="w-full max-w-sm gap-3">
<FieldLegend variant="label">أرسل لي بريدًا عندما</FieldLegend>
<div className="flex items-center gap-3">
<Checkbox
id="checkbox-rtl-all"
checked={all}
indeterminate={on.length > 0 && !all}
onCheckedChange={() => setOn(all ? [] : OPTIONS.map(([value]) => value))}
/>
<Label htmlFor="checkbox-rtl-all">الكل</Label>
</div>
{OPTIONS.map(([value, label]) => (
<div key={value} className="ms-7 flex items-center gap-3">
<Checkbox
id={`checkbox-rtl-${value}`}
checked={on.includes(value)}
onCheckedChange={(next) => setOn((current) => (next ? [...current, value] : current.filter((v) => v !== value)))}
/>
<Label htmlFor={`checkbox-rtl-${value}`} className="font-normal">
{label}
</Label>
</div>
))}
</FieldSet>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| checked / defaultChecked | boolean | — | Ticked or not, controlled or not. |
| onCheckedChange | (checked: boolean) => void | — | Called with the new state when the box is pressed or its label is clicked. |
| indeterminate | boolean | false | The mixed state of a parent box while only some of its children are ticked: aria-checked="mixed", filled, with a dash. |
| disabled | boolean | false | Out of the Tab order and dimmed, with a Label beside it; say why in text joined with aria-describedby. |
| required | boolean | false | The box must be ticked before its form submits. Say "required" in the label too. |
Dependencies
Registry
Source
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon, MinusIcon } from "lucide-react"
function Checkbox({ className, indeterminate, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
indeterminate={indeterminate}
// Base UI renders a span, which is never :disabled — the disabled look
// answers data-disabled. A mixed box fills like a checked one and says
// so with a dash: Base UI marks it data-indeterminate, not data-checked.
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-card transition-colors focus-ring group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-opacity duration-(--duration-fast) ease-(--ease-standard) data-unchecked:opacity-0 [&>svg]:size-3.5"
>
{indeterminate ? <MinusIcon /> : <CheckIcon />}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }