Skip to content
Drivn logoDrivn
5 min read

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.

Checkboxes are the workhorse of every form — terms acceptance, multi-select filters, notification preferences, feature toggles. The Drivn Checkbox component is a native <input type="checkbox"> element hidden via sr-only inside a <label>, with a styled <span> rendering the visual box and a lucide-react Check icon as the glyph. About 50 lines of TSX with zero runtime dependencies beyond lucide-react. The native input means form submission, react-hook-form integration, and HTML5 validation all work without any extra wiring.

This page collects the patterns that come up in production. Each snippet is copy-paste ready and stays consistent with the Checkbox source the Drivn CLI writes into your repo. The default render is a 16x16 pixel rounded box with a 1px border, transitioning to bg-primary when checked. The label prop ships built-in so you do not need a separate <Label> component — pass the text directly and the click target spans both the box and the label, because everything lives inside one <label> element.

The component supports controlled mode (checked prop), uncontrolled mode (defaultChecked prop), and a disabled flag that drops opacity and changes the cursor. All standard HTML input attributes are forwarded via ...props, so name, value, required, aria-describedby, and any other input attribute work without wrapper changes. The examples below cover the six shapes that come up most often: with label, controlled, default checked, disabled, group of checkboxes, and inside a form.

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.

1import { Checkbox } from "@/components/ui/checkbox"
2
3export 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'
2import { useState } from "react"
3import { Checkbox } from "@/components/ui/checkbox"
4
5export 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

For multi-select fields — notification preferences, feature flags, category filters — render a stack of Checkboxes inside a <div className="flex flex-col gap-3">. Each checkbox is independent, with its own name and checked state. There is no CheckboxGroup wrapper in Drivn because the native HTML pattern already supports independent checkboxes inside a <form> without any grouping component.

For a "select all" toggle that controls a group, lift the state into the parent — track an array of selected values, derive allChecked from selected.length === options.length, and toggle the array on each child change. The pattern is twenty lines of plain React without any new component.

1'use client'
2import { useState } from "react"
3import { Checkbox } from "@/components/ui/checkbox"
4
5const options = ['Email', 'SMS', 'Push'] as const
6
7export 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.

1import { Checkbox } from "@/components/ui/checkbox"
2
3export 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}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

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.