Checkbox
betaA box people tick to pick an option, with a built-in label, tones, sizes, and a mixed state.
Preview
tone
size
states
wrap
group
Files to share
field
no text
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/checkbox
Use it for
- Picking any number of options from a list, like topics to follow.
- Agreeing to something before a form is sent, like the terms of service.
- Pass the label text as children. The box and the text share one label, so clicking either one toggles it.
- When the text has to sit apart from the box, give the box an id and point a label's htmlFor at it.
- A 'select all' box above a list. Set indeterminate when only some items are picked, and it shows the mixed state.
Avoid it for
- A setting that takes effect right away. Use a switch.
- Picking exactly one option from a list. Use a radio group.
- Options with a hint, a price, or a consequence. Use SelectionGroup multiple with a SelectionCard for each.
- An id with no label pointing at it. TypeScript accepts the id, but the box then has no name.
Props
- children
Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id.
ReactNode
- id
Links a separate label. A label whose htmlFor matches names the box, and clicking it toggles the box.
string
- tone
Color of the checked box.
"neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"
- size
Box size and label text size. Density scales them.
"sm" | "md" | "lg" · default "md"
- checked
Controlled state. Use it with onCheckedChange.
boolean
- defaultChecked
Starting state when you do not control it.
boolean · default false
- onCheckedChange
Called with the new state when someone toggles the box.
(checked: boolean, details) => void
- indeterminate
Shows a dash and sets aria-checked to mixed.
boolean · default false
- disabled
Blocks toggling and removes the box from the tab order. The label text dims too.
boolean · default false
- name
Form field name. The box sends its value with the form when checked.
string
- aria-invalid
Shows the danger border. Set it when validation fails, and link the message with aria-describedby.
boolean
- className
Classes for the box. Later classes win, so className='[--checkbox-size:1.5rem]' makes it bigger.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | - | Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id. |
| id | string | - | Links a separate label. A label whose htmlFor matches names the box, and clicking it toggles the box. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | Color of the checked box. |
| size | "sm" | "md" | "lg" | "md" | Box size and label text size. Density scales them. |
| checked | boolean | - | Controlled state. Use it with onCheckedChange. |
| defaultChecked | boolean | false | Starting state when you do not control it. |
| onCheckedChange | (checked: boolean, details) => void | - | Called with the new state when someone toggles the box. |
| indeterminate | boolean | false | Shows a dash and sets aria-checked to mixed. |
| disabled | boolean | false | Blocks toggling and removes the box from the tab order. The label text dims too. |
| name | string | - | Form field name. The box sends its value with the form when checked. |
| aria-invalid | boolean | - | Shows the danger border. Set it when validation fails, and link the message with aria-describedby. |
| className | string | - | Classes for the box. Later classes win, so className='[--checkbox-size:1.5rem]' makes it bigger. |
Accessibility
- Renders a span with role="checkbox" plus a hidden native checkbox, so the value is sent with the form.
- Space toggles it. Enter does not toggle it. Enter submits the form, as it does on a native checkbox.
- TypeScript requires visible children, aria-label, aria-labelledby, or an id. It cannot check that a label points at the id.
- In server HTML, only aria-label and aria-labelledby name the box. A label from children or an id is linked once JavaScript runs.
- Clicking the label text also toggles the box, so the target is bigger than the box.
- The unchecked border uses --input and the focus ring uses --ring. The theme tests hold both to 3:1 against the page and cards.
- The check mark uses the tone's foreground on its fill, which the theme tests hold to 4.5:1.
- Press scale and the check mark's pop-in follow --motion-* tokens. Reduced motion turns both off and keeps the fade.
Examples
With a label
<Checkbox name="terms" tone="brand">
I accept the{" "}
<a href="/terms" className="underline">
terms of service
</a>
</Checkbox>A list of options
<fieldset className="grid gap-3">
<legend className="mb-1 text-sm font-medium">Email me about</legend>
<Checkbox name="topics" value="releases" defaultChecked>
New releases
</Checkbox>
<Checkbox name="topics" value="tips">
Tips and guides
</Checkbox>
<Checkbox name="topics" value="events">
Events near me
</Checkbox>
</fieldset>Select all, with the mixed state
"use client";
import { useState } from "react";
const files = ["report.pdf", "notes.txt", "photo.png"];
function FilePicker() {
const [picked, setPicked] = useState(["report.pdf"]);
const all = picked.length === files.length;
return (
<div role="group" aria-labelledby="files-label" className="grid gap-3">
<p id="files-label" className="text-sm font-medium">Files</p>
<Checkbox
checked={all}
indeterminate={picked.length > 0 && !all}
onCheckedChange={(checked) => setPicked(checked ? files : [])}
>
Select all
</Checkbox>
<div className="grid gap-3 ps-6">
{files.map((file) => (
<Checkbox
key={file}
checked={picked.includes(file)}
onCheckedChange={(checked) => setPicked((now) => (checked ? [...now, file] : now.filter((f) => f !== file)))}
>
{file}
</Checkbox>
))}
</div>
</div>
);
}Controlled
"use client";
import { useState } from "react";
function MarketingConsent() {
const [checked, setChecked] = useState(false);
return (
<Checkbox checked={checked} onCheckedChange={setChecked}>
Send me product news
</Checkbox>
);
}No visible text, in a table row
<Checkbox aria-label="Select row: Invoice 1042" size="sm" />Named by a separate label
<div className="flex items-center gap-2">
<Checkbox id="beta" />
<label htmlFor="beta" className="text-sm">
Join the beta
</label>
</div>