Skip to content
Drivn logoDrivn
5 min read

Drivn vs shadcn/ui — Checkbox Component Compared

Drivn vs shadcn/ui React Checkbox: Drivn ships a native input with built-in label prop and zero Radix dependency — shadcn uses @radix-ui/react-checkbox.

Drivn and shadcn/ui take opposite approaches to the Checkbox primitive, even though the rendered result looks nearly identical. shadcn wraps @radix-ui/react-checkbox, which renders a <button role="checkbox"> with a hidden form input underneath and exposes an indeterminate state via the Radix API. Drivn skips Radix entirely and ships a native <input type="checkbox"> element hidden via sr-only, wrapped in a <label> that owns the click target and the visual box. The runtime difference is one dependency (@radix-ui/react-checkbox) and one rendered DOM tree.

The API surface diverges in three places: the label prop, controlled-vs-uncontrolled defaults, and indeterminate state. Drivn ships a label prop directly on the Checkbox so a single tag renders the box plus the text — <Checkbox label="Accept terms" />. shadcn requires a separate <Label htmlFor="..."> element next to a <Checkbox id="..." /> and you wire the association manually. Drivn supports both controlled (checked) and uncontrolled (defaultChecked) without you opting in. shadcn defers to Radix' controlled/uncontrolled rules, which work the same way but require reading the Radix docs to confirm.

This page walks through every surface where the two diverge: the DOM tree, the label prop, controlled state, indeterminate support, and form integration. Every snippet below compiles against the Checkbox source the Drivn CLI writes into your repo. If you have shadcn's Checkbox today, the migration is removing the Radix import and collapsing the <Label> next to the <Checkbox> into a single label prop.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveNative input[type=checkbox]@radix-ui/react-checkbox
Rendered elementlabel > input.sr-only + spanbutton[role="checkbox"]
Built-in label prop
Indeterminate stateVia checked={"indeterminate"}
Controlled APIchecked + onChangechecked + onCheckedChange
onChange signatureChangeEvent<HTMLInputElement>(checked: boolean) => void
Form submissionNative — no extra wiringHidden input via Radix
Runtime UI depslucide-react only@radix-ui/react-checkbox
LicenseMITMIT
Copy-paste install

API side-by-side

shadcn's Checkbox renders a Radix Root and pairs it with a separate <Label> element you import from @/components/ui/label. The two are connected via matching id and htmlFor attributes you wire by hand. Drivn collapses both into one component — pass a label prop and the Checkbox source renders the <input>, the visual box, and the label text inside a single <label> element so click-anywhere works without an explicit htmlFor.

The onChange signature also differs. shadcn's Radix Checkbox emits (checked: boolean | "indeterminate") => void via onCheckedChange. Drivn emits a plain ChangeEvent<HTMLInputElement> because the underlying element is a native input — the same handler signature you already use on every other form input in your app. Read event.target.checked for the boolean, or use the typed handler shape from React.InputHTMLAttributes<HTMLInputElement> directly.

1// shadcn/ui — separate Label, manual htmlFor wiring
2import { Checkbox } from '@/components/ui/checkbox'
3import { Label } from '@/components/ui/label'
4
5<div className="flex items-center gap-2">
6 <Checkbox id="terms" />
7 <Label htmlFor="terms">Accept terms</Label>
8</div>
9
10// Drivn — one tag, label prop
11import { Checkbox } from '@/components/ui/checkbox'
12
13<Checkbox label="Accept terms" />

The DOM tree and accessibility

shadcn renders a <button role="checkbox" aria-checked={…}> from Radix, with the visual checkmark inside the button and a hidden form input synced via the Radix internals. Drivn renders a real <input type="checkbox" className="sr-only"> inside a <label>, with the visual box rendered as a sibling <span>. Both patterns are accessible — Radix' button-with-aria-checked is the WAI-ARIA pattern for custom checkbox widgets, and Drivn's native-input-in-label is the original HTML pattern that screen readers have understood for two decades.

The behavioral difference shows up around forms. Drivn's native input participates in <form> submission automatically — its name and value attributes serialize like any other field, and it triggers HTML5 validation if you set required. shadcn's Radix Checkbox renders a separate hidden <input> to participate in form submission, which works the same in practice but means the rendered DOM has two checkbox-shaped elements (the button and the hidden input).

1// Drivn rendered DOM — verbatim from the Checkbox source
2<label className={cn(styles.base, disabled && 'opacity-50 cursor-default', className)}>
3 <input
4 ref={ref}
5 type="checkbox"
6 className="sr-only"
7 checked={isChecked}
8 disabled={disabled}
9 onChange={(e) => {
10 if (!isControlled)
11 setInternal(e.target.checked)
12 onChange?.(e)
13 }}
14 {...props}
15 />
16 <span className={cn(styles.box, isChecked && styles.checked)}>
17 {isChecked && (
18 <Check className="w-2.5 h-2.5 text-primary-foreground" />
19 )}
20 </span>
21 {label && (
22 <span className={styles.label}>{label}</span>
23 )}
24</label>

Controlled and uncontrolled state

Both libraries support controlled and uncontrolled modes, but the implementation hides in different places. shadcn defers to Radix, which detects checked vs defaultChecked and switches modes accordingly. Drivn does the same detection in component code: const isControlled = checked !== undefined reads the prop, and const [internal, setInternal] = useState(defaultChecked ?? false) holds uncontrolled state. The handler updates internal state only when uncontrolled, then always forwards the event to onChange.

The practical difference is debuggability. When something goes wrong in a Drivn checkbox, you can read the controlled/uncontrolled detection inline in the Checkbox source and step through it with React DevTools without leaving your repo. With shadcn's Radix-backed Checkbox, the controlled state lives inside @radix-ui/react-checkbox so the same investigation requires opening node_modules. Neither is a blocker — both work — but copy-and-own keeps the surface flat.

1// Drivn — controlled
2import { useState } from 'react'
3import { Checkbox } from '@/components/ui/checkbox'
4
5export function TermsForm() {
6 const [accepted, setAccepted] = useState(false)
7
8 return (
9 <Checkbox
10 label="Accept terms"
11 checked={accepted}
12 onChange={(e) => setAccepted(e.target.checked)}
13 />
14 )
15}
16
17// Drivn — uncontrolled
18<Checkbox label="Subscribe to newsletter" defaultChecked />

Indeterminate state and form integration

shadcn supports indeterminate via Radix — pass checked={"indeterminate"} and Radix renders a horizontal-line glyph instead of a check. Drivn does not ship indeterminate out of the box because the visual marker is just <Check /> from lucide-react with no minus variant. If you need indeterminate, swap the icon based on a state prop and add the missing branch — the Checkbox source is around 50 lines, so the modification is one component, not a dependency upgrade.

Form integration is where Drivn's native-input approach pays off. Drop a Drivn Checkbox inside a <form> with name="agree" and action="/api/submit" and the field serializes like any other form input on submit — no FormData wiring, no controlled-state ferrying. shadcn does the same job via Radix' hidden internal input, but the visible element is a <button> so quick formData.get(name) calls work because of the hidden sibling, not the visible widget. See react-hook-form integration for the full form pattern.

1// Drivn — native form participation
2<form action="/api/preferences" method="post">
3 <Checkbox name="newsletter" label="Subscribe to newsletter" />
4 <Checkbox name="terms" label="Accept terms" required />
5 <button type="submit">Save</button>
6</form>
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 checkmark glyph and cn from the local utils — that is it. There is no @radix-ui/react-checkbox, no cva, no clsx wrapper, no class-variance-authority. The component is a native <input type="checkbox"> hidden via sr-only, a styled <span> for the visual box, and the lucide check icon. About 50 lines of TSX total.

Add a state?: 'checked' | 'indeterminate' | 'unchecked' prop to the Checkbox source and swap the icon based on the value — <Check /> for checked, <Minus /> for indeterminate, null for unchecked. The native <input> element accepts the indeterminate state via a ref effect: useEffect(() => { if (ref.current) ref.current.indeterminate = state === 'indeterminate' }, [state]). The component lives in your repo so the modification is local.

Yes. Because the underlying element is a native input, register() from react-hook-form works without any custom wrapper — <Checkbox {...register("newsletter")} label="Subscribe" /> registers the field, ties up validation, and serializes on submit. No <Controller> is needed, unlike shadcn's Radix-backed Checkbox where you wrap in <Controller> because the visible element is a button rather than an input.

Because the native <input type="checkbox"> element inside a <label> is the original WAI-ARIA-compliant checkbox pattern — screen readers, keyboard navigation, and form submission all work without any extra ARIA attributes. Radix' button-with-role="checkbox" pattern is also valid and necessary when you build a checkbox from a <div>, but Drivn uses the native input so the Radix wrapper would add a dependency without changing the accessibility outcome.