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
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Underlying primitive | Native input[type=checkbox] | @radix-ui/react-checkbox |
| Rendered element | label > input.sr-only + span | button[role="checkbox"] |
| Built-in label prop | ||
| Indeterminate state | Via checked={"indeterminate"} | |
| Controlled API | checked + onChange | checked + onCheckedChange |
| onChange signature | ChangeEvent<HTMLInputElement> | (checked: boolean) => void |
| Form submission | Native — no extra wiring | Hidden input via Radix |
| Runtime UI deps | lucide-react only | @radix-ui/react-checkbox |
| License | MIT | MIT |
| 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 2 import { Checkbox } from '@/components/ui/checkbox' 3 import { 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 11 import { 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 2 import { useState } from 'react' 3 import { Checkbox } from '@/components/ui/checkbox' 4 5 export 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>
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 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.