Checkbox
pnpm dlx shadcn@latest add …/r/checkbox.jsonAn accessible React checkbox for Next.js forms, with a check that draws itself in and an indeterminate state, built on Radix Checkbox.
import { Checkbox } from "@/components/ui/checkbox"
export function Terms() {
return (
<>
<Checkbox id="terms" />
<label htmlFor="terms">Accept terms and conditions</label>
</>
)
}import { Checkbox } from "@/components/ui/checkbox";
export default function CheckboxDefault() {
return <Checkbox aria-label="Accept terms" defaultChecked />;
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { motion, useReducedMotion } from "motion/react"; import { Checkbox as CheckboxPrimitive } from "radix-ui"; import { type ComponentProps, useState } from "react"; /* * A checkbox on Radix Checkbox (owner, 2026-10-15). * - Motion (.agents/rules/motion.md): the fill grows from the centre as it fades in (CSS, the same `before:` * layer as Button's tint) and the check draws itself in (pathLength, the tiny-stroke exception). * Unchecking is checking played backwards: the check erases, then the fill shrinks and fades. Indeterminate is a dash that draws in the same way; * between checked and indeterminate the fill stays and one mark erases as the other draws. * - Nothing animates on first paint. With reduced motion the marks appear and go at once. * - The state is held here and always passed to Radix, controlled or not, so the marks know what to draw. * When its form resets, Radix restores the first state through onCheckedChange, so that still works. * - Sizes match Switch's track heights (16 and 20px), so the two line up in a form. Flat, no shadow. */ const checkboxVariants = cva( [ "peer relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center border border-input bg-transparent text-primary-foreground", "transition-colors duration-(--duration-fast) ease-out enabled:hover:border-ring", // The fill covers the border, so checked is one solid shape. It grows from 60% as it fades in (owner, // 2026-10-15: "the BG can also animate a little"). Unchecking, it waits for the check to start erasing, then // shrinks and fades a little quicker. "before:pointer-events-none before:absolute before:-inset-px before:-z-10 before:bg-primary before:opacity-0 before:scale-60", "before:transition before:duration-(--duration-fast) before:ease-in data-[state=unchecked]:before:delay-(--duration-instant)", "not-data-[state=unchecked]:before:scale-100 not-data-[state=unchecked]:before:opacity-100 not-data-[state=unchecked]:before:duration-(--duration-base) not-data-[state=unchecked]:before:ease-out", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "aria-invalid:border-destructive aria-invalid:outline-destructive", "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)", ], { variants: { size: { sm: "size-4 rounded-xs before:rounded-xs", md: "size-5 rounded-sm before:rounded-sm", }, }, defaultVariants: { size: "md" }, }, ); /** The same check as Copy Button and Loading Button, and a dash for indeterminate */ const CHECK = "M5 12.5l4.5 4.5L19 7.5"; const DASH = "M7 12h10"; /** * A mark that draws itself in, after the fill has started, and erases the way it came. Its opacity goes * with it, so a round cap never leaves a dot behind. */ function Mark({ d, shown, reduce }: { d: string; shown: boolean; reduce: boolean }) { return ( <motion.path d={d} stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" initial={false} animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0 }} transition={ reduce ? { duration: 0 } : shown ? { pathLength: { duration: duration.base, ease: ease.out, delay: duration.instant }, opacity: { duration: 0, delay: duration.instant }, } : { duration: duration.fast, ease: ease.in } } /> ); } export type CheckboxProps = ComponentProps<typeof CheckboxPrimitive.Root> & VariantProps<typeof checkboxVariants>; /** * A checkbox for choices submitted with a form, built on Radix Checkbox (role="checkbox", Space toggles). * Set `checked` to "indeterminate" for a select-all box when only some are picked. Give it a visible label * (`<label htmlFor>`). */ function Checkbox({ className, size, checked, defaultChecked, onCheckedChange, ...props }: CheckboxProps) { const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false); const state = checked ?? uncontrolled; const reduce = useReducedMotion() ?? false; return ( <CheckboxPrimitive.Root data-slot="checkbox" data-size={size ?? "md"} checked={state} onCheckedChange={(value) => { if (checked === undefined) setUncontrolled(value); onCheckedChange?.(value); }} className={cn(checkboxVariants({ size }), className)} {...props} > <svg data-slot="checkbox-indicator" viewBox="0 0 24 24" fill="none" aria-hidden className="size-full"> <Mark d={CHECK} shown={state === true} reduce={reduce} /> <Mark d={DASH} shown={state === "indeterminate"} reduce={reduce} /> </svg> </CheckboxPrimitive.Root> ); } export { Checkbox, checkboxVariants };Update the import paths to match your project.
Examples
Label and hint
A visible label, and a hint linked with aria-describedby.
You agree to the Terms of Service and the Privacy Policy.
import { Checkbox } from "@/components/ui/checkbox";
import { useId } from "react";
export default function CheckboxHint() {
const id = useId();
return (
<div className="flex w-full max-w-sm items-start gap-3">
<Checkbox id={id} aria-describedby={`${id}-hint`} />
<div className="grid gap-0.5">
<label htmlFor={id} className="text-sm font-medium">
Accept terms and conditions
</label>
<p id={`${id}-hint`} className="text-sm text-muted-foreground">
You agree to the Terms of Service and the Privacy Policy.
</p>
</div>
</div>
);
}Select all
The parent shows a dash while only some are picked; checking it picks them all. Checked and dash swap marks without the fill moving.
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { useId, useState } from "react";
const channels = [
{ key: "email", label: "Email" },
{ key: "push", label: "Push notifications" },
{ key: "sms", label: "Text messages" },
];
/** The parent is checked when every channel is, a dash when only some are, and empty when none are */
export default function CheckboxSelectAll() {
const id = useId();
const [picked, setPicked] = useState<string[]>(["email"]);
const all = picked.length === channels.length ? true : picked.length > 0 ? "indeterminate" : false;
return (
<fieldset className="grid gap-3">
<legend className="sr-only">Notification channels</legend>
<div className="flex items-center gap-3">
<Checkbox
id={`${id}-all`}
checked={all}
onCheckedChange={(checked) =>
setPicked(checked === true ? channels.map((channel) => channel.key) : [])
}
/>
<label htmlFor={`${id}-all`} className="text-sm font-medium">
All channels
</label>
</div>
{/* Indented to line up with the parent's label: the 20px box plus the 12px gap */}
<div className="grid gap-3 ps-8">
{channels.map((channel) => (
<div key={channel.key} className="flex items-center gap-3">
<Checkbox
id={`${id}-${channel.key}`}
checked={picked.includes(channel.key)}
onCheckedChange={(checked) =>
setPicked((current) =>
checked === true ? [...current, channel.key] : current.filter((key) => key !== channel.key),
)
}
/>
<label htmlFor={`${id}-${channel.key}`} className="text-sm">
{channel.label}
</label>
</div>
))}
</div>
</fieldset>
);
}Sizes
import { Checkbox } from "@/components/ui/checkbox";
export default function CheckboxSizes() {
return (
<div className="flex items-center gap-6">
<Checkbox size="sm" aria-label="Small checkbox" defaultChecked />
<Checkbox aria-label="Medium checkbox" defaultChecked />
</div>
);
}Disabled
import { Checkbox } from "@/components/ui/checkbox";
import { useId } from "react";
export default function CheckboxDisabled() {
const id = useId();
return (
<div className="grid gap-3">
<div className="flex items-center gap-3">
<Checkbox id={`${id}-backup`} disabled defaultChecked />
<label htmlFor={`${id}-backup`} className="text-sm font-medium text-muted-foreground">
Back up automatically
</label>
</div>
<div className="flex items-center gap-3">
<Checkbox id={`${id}-mobile`} disabled />
<label htmlFor={`${id}-mobile`} className="text-sm font-medium text-muted-foreground">
Sync over mobile data
</label>
</div>
</div>
);
}API reference
Also accepts every Radix Checkbox prop. Inside a form it submits through a hidden native checkbox, and a form reset restores its first state.
| Prop | Type | Default | Description |
|---|---|---|---|
checked? | boolean | "indeterminate" | – | The state, when you control it. Indeterminate shows a dash. |
defaultChecked? | boolean | "indeterminate" | false | The initial state. |
onCheckedChange? | (checked: boolean | "indeterminate") => void | – | Called when the user checks or unchecks it. An indeterminate box becomes checked. |
size? | "sm" | "md" | "md" | 16 or 20px, the heights of Switch's sizes. |
disabled? | boolean | false | Prevents interaction. |
required? | boolean | false | Must be checked to submit the form. |
name? | string | – | Submits the value with a form. |
value? | string | "on" | The value submitted when checked. |
Related components
An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.
An accessible React toggle group for Next.js: buttons you switch on and off independently, like Bold, Italic and Underline in a toolbar.
An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.