Drivn vs shadcn/ui — Switch Compared
Drivn ships a ~30 line zero-runtime Switch built on a native button; shadcn/ui wraps @radix-ui/react-switch. Here is the practical difference.
A Switch is the simplest possible toggle in a component library — a pill that flips between on and off — yet the implementation choice between Drivn and shadcn/ui ends up touching bundle size, accessibility wiring, and how the component behaves under controlled state. Drivn ships a single file of roughly thirty lines at packages/drivn/src/registry/components/switch.ts — a native <button type="button" role="switch"> with an aria-checked attribute, a track div that swaps between bg-primary and bg-border, and a thumb that translates by 22 pixels. There is no Radix wrapper, no class-variance-authority, no headless layer between your component and the DOM.
shadcn/ui wraps @radix-ui/react-switch, which gives you <Switch.Root> and <Switch.Thumb> as composed primitives, full controlled/uncontrolled state handling out of the box, focus rings tuned for keyboard users, and the WAI-ARIA Switch pattern implemented down to the keystroke. The cost is a runtime dependency on @radix-ui/react-switch plus its peer requirements, and an extra abstraction layer between you and the rendered button. That is the trade.
This page walks the implementation difference, the controlled-state shape, the accessibility story, and the situations where the Radix-backed shadcn approach is the better fit even if Drivn ships fewer dependencies.
Side-by-side comparison
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Install command | npx drivn add switch | npx shadcn@latest add switch |
| Underlying implementation | Native button with role="switch" | @radix-ui/react-switch primitives |
| Runtime UI dependencies | cn() utility only — zero npm UI packages | @radix-ui/react-switch + peers |
| Component lines | ~30 lines, single file | ~30 lines wrapping a ~250 line Radix package |
| Public API shape | <Switch checked onChange /> | <Switch checked onCheckedChange /> |
| Controlled state | checked + onChange in caller | checked + onCheckedChange in caller |
| Uncontrolled state | Not built-in — caller owns useState | defaultChecked supported via Radix |
| ARIA pattern | role="switch" + aria-checked on button | Full WAI-ARIA Switch pattern via Radix |
| Keyboard behaviour | Browser default — Space/Enter on button | Space/Enter standardised by Radix |
| Form integration | Pair with hidden input in caller | Built-in via Radix Form integration |
| Thumb animation | CSS translate-x 200ms | CSS translate-x 200ms via data-state |
| Disabled state | Standard button disabled attribute | Radix data-disabled + standard attribute |
| Client component | Yes — 'use client' | Yes — 'use client' |
| License | MIT | MIT |
Controlled state shape — checked + onChange
Both libraries expect controlled state from the caller. Drivn names the callback onChange and types it as (checked: boolean) => void — note the Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> on the props type so the native button onChange does not collide. shadcn/ui (via Radix) names the callback onCheckedChange, which is the Radix convention to disambiguate from native form onChange events. Same idea, different name.
Drivn does not ship an uncontrolled mode — there is no defaultChecked prop, no internal useState. If you want uncontrolled state you wrap the Switch in your own component and own the state there. Radix-backed shadcn/ui supports both controlled (checked + onCheckedChange) and uncontrolled (defaultChecked) out of the box because Radix maintains the internal state machine for you. For most forms this difference is invisible — you reach for react-hook-form or Zod anyway and control the value at the form level. For one-off toggles, uncontrolled mode is a small ergonomic win.
1 "use client" 2 3 import * as React from "react" 4 import { Switch } from "@/components/ui/switch" 5 6 export default function NotificationToggle() { 7 const [enabled, setEnabled] = React.useState(false) 8 9 return ( 10 <Switch 11 checked={enabled} 12 onChange={setEnabled} 13 /> 14 ) 15 }
Accessibility — role="switch" + aria-checked
The Drivn Switch renders a native <button> with role="switch" and aria-checked={checked}. That alone covers the WAI-ARIA Switch pattern minimums — screen readers announce "switch, on" or "switch, off", the toggle is reachable via Tab, and Space and Enter flip the state because that is what native buttons do. The element type is a button, so the focus ring, hit target, and keyboard activation come from the browser without any extra wiring.
shadcn/ui inherits the full Radix Switch implementation, which adds explicit Space and Enter handlers, a normalised focus ring via data-state attributes that you can target in Tailwind, and the same role="switch" and aria-checked plumbing. The behavioural surface is wider, the keyboard contract is documented per keystroke, and the WAI-ARIA compliance is exhaustive. For mass-market consumer apps where accessibility is audited every quarter, that exhaustiveness is worth the dependency. For internal tools and product surfaces where role + aria-checked covers your audit, the Drivn approach is fine.
1 // Drivn Switch — verbatim button + ARIA wiring 2 <button 3 type="button" 4 role="switch" 5 aria-checked={checked} 6 onClick={() => onChange?.(!checked)} 7 className={cn(styles.base, checked ? 'bg-primary' : 'bg-border', className)} 8 {...props} 9 > 10 <span className={cn(styles.thumb, checked ? 'translate-x-[25px]' : 'translate-x-[3px]')} /> 11 </button>
When the shadcn + Radix path still wins
Three situations push you toward the Radix-backed shadcn Switch even though Drivn ships fewer dependencies. The first is a project already wired through @radix-ui/react-form or another Radix form layer — the Switch primitive integrates with that form context cleanly, broadcasts its state via data-state, and participates in the form submission lifecycle without extra glue. Pairing the Drivn Switch with the same form layer means adding a hidden <input type="checkbox"> next to the button and syncing it manually.
The second is a design system mandate of WAI-ARIA exhaustiveness — every component audited against the full pattern, every keystroke documented, every screen reader output specified. Radix is engineered for that bar; the Drivn Switch is engineered to cover the practical 95% with a third of the surface area. The third is an existing shadcn project — switching one component to Drivn means running two registries side by side, two CLIs in the build, and two @/components/ui conventions. Stay on shadcn unless you are migrating the whole surface. For a fresh start where bundle size, registry simplicity, and dot-notation parity with the rest of the Drivn library matter more than Radix-grade accessibility, the Drivn Switch is the cleaner pick.
1 # Drivn — one CLI, one registry 2 npx drivn add switch 3 4 # shadcn/ui — wraps a Radix package 5 npx shadcn@latest add switch 6 # also installs @radix-ui/react-switch
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 Drivn Switch is a single file at packages/drivn/src/registry/components/switch.ts that imports React and the local cn() utility from @/utils/cn — nothing else. There is no @radix-ui/react-switch dependency, no class-variance-authority, no headless toggle library. The component is a native HTML button with role="switch" and aria-checked, plus a thumb span that translates via CSS. The whole file is roughly thirty lines and ships as TypeScript source via the Drivn CLI.
Drivn calls it onChange and types it as (checked: boolean) => void. To prevent a collision with the native button onChange event, the SwitchProps interface uses Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> before adding the custom signature. shadcn/ui follows the Radix convention and uses onCheckedChange to disambiguate from form onChange events. When migrating between the two, every onChange in your Drivn code becomes onCheckedChange in shadcn and vice versa.
No. The Drivn Switch is controlled-only — the caller owns the boolean state via useState (or a form library) and passes it down through the checked prop. There is no defaultChecked, no internal state machine, no useUncontrolledState hook. If you need uncontrolled behaviour, wrap the Switch in a small component that owns the state and exposes its own API. The shadcn/ui Switch inherits Radix uncontrolled mode via defaultChecked out of the box.
The thumb is an absolutely positioned span sized w-5 h-5 with bg-white, rounded-full, and shadow-md. Its base class adds transition-transform duration-200. When the checked prop is true, the cn() call appends translate-x-[25px], moving the thumb 25 pixels to the right. When false, it appends translate-x-[3px], settling the thumb on the left edge of the 48-pixel-wide track. The track itself animates between bg-primary and bg-border via transition-colors duration-200. No framer-motion, no animation library — pure Tailwind utilities.
For most production surfaces, yes. The component renders a native button with role="switch" and aria-checked, which is the WAI-ARIA Switch pattern baseline. Screen readers announce the on/off state correctly, Tab reaches the control, and Space and Enter activate it because those are native button keystrokes. The Drivn Switch does not match the exhaustive per-keystroke contract that Radix documents — for a design system requiring that level of audit, the shadcn/ui Switch wrapping @radix-ui/react-switch is the safer pick.
The Switch renders a button, not a form input, so a form submit does not include its value automatically. Three patterns: pair it with react-hook-form using Controller to bridge the checked + onChange API into the form state, drop a hidden <input type="checkbox" name="..." value={checked.toString()} /> next to the Switch and sync the two values, or call the form library setValue from the onChange callback directly. For most Drivn projects the react-hook-form Controller pattern is the cleanest because it keeps the Switch component itself unmodified.

