React Checkbox Component Examples
Drop-in React Checkbox examples: with label, controlled state, disabled, default checked, and form integration. Native input under the hood, no Radix.
Every form ends up with checkboxes in it — terms you have to accept, notification channels you can toggle, filter facets you multi-select, feature flags behind a settings pane. Drivn's Checkbox covers all of them in roughly fifty lines of TSX: a real <input type="checkbox"> carrying the class peer sr-only, a sibling <span> styled into the visible w-4 h-4 box, and a lucide-react Check glyph that renders only while the box is checked. Nothing else sits in the dependency list.
That structure is why the component disappears into whatever form stack you already run. Because the input is native and still in the DOM, FormData picks it up on submit, HTML5 required blocks the submit itself, and register() from react-hook-form spreads straight in without a <Controller>. The visual layer is a <span> that flips to bg-primary border-primary when checked — styling only, never the source of truth. The label prop lives inside the same <label> element, so the click target spans the box, the text, and the gap between them.
The examples below walk the six shapes that keep recurring: a labelled checkbox, a controlled one driven by parent state, an uncontrolled defaultChecked default, a disabled lock, a multi-select group, and a plain HTML form. Each is copy-paste ready against the source the Drivn CLI writes into your project.
Checkbox with label
The minimum Drivn Checkbox is one tag with a label prop. The component renders an <input type="checkbox"> hidden via sr-only, a styled <span> for the visual box, and a <span> for the label text — all inside a single <label> element. Click anywhere on the row (box or text) toggles the checkbox because the native <label> element forwards clicks to the input automatically.
The label text uses text-sm text-foreground select-none per the Checkbox source — small body type, foreground color, and select-none so double-click on the label does not select text accidentally. For longer descriptions or paragraph-style copy next to a checkbox, render the text outside the component and use aria-describedby to associate it. For a one-line label, the prop is enough.
1 import { Checkbox } from "@/components/ui/checkbox" 2 3 export default function Page() { 4 return <Checkbox label="Accept terms" /> 5 }
Controlled checkbox
Pass checked and onChange to control the checkbox from parent state. The Drivn Checkbox source detects the controlled mode via const isControlled = checked !== undefined and skips the internal useState update, forwarding the change event to your handler unchanged. Read event.target.checked for the boolean — the onChange signature is the standard ChangeEventHandler<HTMLInputElement>, the same one you use on every other form input.
Use controlled mode when the checkbox state needs to drive other UI — toggling a downstream form section, filtering a list, enabling a submit button. For pure toggle state with no side effects, defaultChecked keeps the component uncontrolled and skips the re-render of the parent on every toggle.
1 'use client' 2 import { useState } from "react" 3 import { Checkbox } from "@/components/ui/checkbox" 4 5 export default function Page() { 6 const [accepted, setAccepted] = useState(false) 7 8 return ( 9 <div className="flex flex-col gap-3"> 10 <Checkbox 11 label="I accept the terms" 12 checked={accepted} 13 onChange={(e) => setAccepted(e.target.checked)} 14 /> 15 <button disabled={!accepted}>Continue</button> 16 </div> 17 ) 18 }
Default checked (uncontrolled)
Pass defaultChecked for an uncontrolled checkbox that starts in the checked state. The Drivn Checkbox stores the state internally via useState(defaultChecked ?? false) and updates it on every change without notifying the parent unless you pass an onChange. This is the right shape for newsletter opt-ins, "remember me" toggles, and any preference that lives entirely inside a form payload.
Uncontrolled checkboxes pair well with native form submission — the input's name attribute serializes the boolean value into FormData when the user submits, with no React state ferrying needed. For react-hook-form, defaultChecked is overridden by register() because the form library takes ownership of the input via refs.
1 <div className="flex flex-col gap-3"> 2 <Checkbox label="Email notifications" defaultChecked /> 3 <Checkbox label="SMS notifications" /> 4 <Checkbox label="Push notifications" defaultChecked /> 5 </div>
Disabled checkbox
Pass disabled to lock the checkbox into its current state. The Checkbox source applies opacity-50 cursor-default to the wrapping <label> via cn(styles.base, disabled && 'opacity-50 cursor-default', className) and forwards the disabled attribute to the native <input> so click events are blocked at the DOM level. Combine with checked or defaultChecked to render a locked-on or locked-off state.
Disabled checkboxes are common for cannot-change settings — already-accepted terms, plan limits, organization-wide policies. Pair them with a tooltip or explanatory text via aria-describedby so screen readers explain why the field is locked rather than just announcing "disabled".
1 <div className="flex flex-col gap-3"> 2 <Checkbox label="Cannot change" disabled /> 3 <Checkbox label="Already accepted" checked disabled /> 4 </div>
Group of checkboxes
Drivn ships no CheckboxGroup, and that is not an omission. Independent checkboxes inside a <form> are already a complete HTML pattern — each input carries its own name, each serializes on its own, and a wrapper component would only re-implement what the browser does for free. Stack them in a <div className="flex flex-col gap-3"> and the group is finished.
Where shared state does earn its keep is a derived selection array: filter facets, notification channels, permission matrices. Hold a string[] in the parent, pass checked={selected.includes(option)} to each Checkbox, and add or remove the value in onChange. That one array also powers a "select all" control — selected.length === options.length gives you the parent checkbox state, and clicking it either fills the array with every option or empties it. For single-select rather than multi-select, reach for Radio Group, which enforces mutual exclusivity through the native name attribute instead of through your state shape.
1 'use client' 2 import { useState } from "react" 3 import { Checkbox } from "@/components/ui/checkbox" 4 5 const options = ['Email', 'SMS', 'Push'] as const 6 7 export default function Page() { 8 const [selected, setSelected] = useState<string[]>([]) 9 10 const toggle = (value: string) => { 11 setSelected((prev) => 12 prev.includes(value) 13 ? prev.filter((v) => v !== value) 14 : [...prev, value] 15 ) 16 } 17 18 return ( 19 <div className="flex flex-col gap-3"> 20 {options.map((option) => ( 21 <Checkbox 22 key={option} 23 label={option} 24 checked={selected.includes(option)} 25 onChange={() => toggle(option)} 26 /> 27 ))} 28 </div> 29 ) 30 }
Inside a form
Drop a Drivn Checkbox inside a <form> with name and value attributes and the field serializes natively on submit — no FormData wiring, no controlled-state ferrying. The native <input type="checkbox"> element participates in form submission directly because the visible row is built around a real input, not a button-with-aria-checked. Set required to enforce the value via HTML5 validation, and the browser will block submit until the user checks the box.
For react-hook-form, register() works without a <Controller> wrapper. Spread the register return into the Checkbox — <Checkbox {...register("agree", { required: true })} label="I agree" /> — and validation, error state, and submit serialization all work via the native input. The same pattern works for Formik, Conform, or any form library that targets standard HTML inputs.
1 import { Checkbox } from "@/components/ui/checkbox" 2 3 export default function PreferencesForm() { 4 return ( 5 <form action="/api/preferences" method="post" className="flex flex-col gap-3"> 6 <Checkbox name="newsletter" label="Subscribe to newsletter" /> 7 <Checkbox name="updates" label="Product updates" defaultChecked /> 8 <Checkbox name="terms" label="Accept terms" required /> 9 <button type="submit">Save preferences</button> 10 </form> 11 ) 12 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
No. The Checkbox imports Check from lucide-react for the visual glyph and cn from the local @/utils/cn utility — that is the entire dependency surface. The element underneath is a native <input type="checkbox"> hidden via sr-only, with a styled <span> rendering the visual box. No Radix, no cva, no class-variance-authority, no clsx wrapper.
The shipped Checkbox does not include an indeterminate state because the icon is just <Check />. To add one, edit the Checkbox source to accept a state?: 'checked' | 'indeterminate' | 'unchecked' prop, swap the icon based on the value, and apply the indeterminate property to the input via a ref effect — useEffect(() => { ref.current.indeterminate = state === 'indeterminate' }, [state]). About ten lines of additions.
Yes. Because the underlying element is a native input, spread register("name") directly into the Checkbox — <Checkbox {...register("agree")} label="I agree" />. There is no need for <Controller> because the visible element is a real input, not a button. Validation, error state, and submit serialization all work via the native input attributes.
The Drivn Checkbox is rendered inside a single <label> element that contains the hidden <input>, the visual box, and the label text. The native HTML <label> element forwards click events to the wrapped input, so clicking the visual box, the label text, or any whitespace between them toggles the checkbox. This is the original HTML pattern that pre-dates JavaScript form widgets — no JavaScript wiring needed.
The sr-only class moves the input off-screen visually while keeping it in the accessibility tree, the focus order, and the form submission. opacity-0 would still render the input on top of the styled span, blocking clicks on the visible box. visibility: hidden removes the input from the focus order entirely, breaking keyboard navigation. The sr-only pattern is the standard for visually-hidden form inputs paired with a custom-styled visual element.

