Skip to content
Drivn
6 min read

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

FeatureDrivnshadcn/ui
Install commandnpx drivn add switchnpx shadcn@latest add switch
Underlying implementationNative button with role="switch"@radix-ui/react-switch primitives
Runtime UI dependenciescn() 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 statechecked + onChange in callerchecked + onCheckedChange in caller
Uncontrolled stateNot built-in — caller owns useStatedefaultChecked supported via Radix
ARIA patternrole="switch" + aria-checked on buttonFull WAI-ARIA Switch pattern via Radix
Keyboard behaviourBrowser default — Space/Enter on buttonSpace/Enter standardised by Radix
Form integrationPair with hidden input in callerBuilt-in via Radix Form integration
Thumb animationCSS translate-x 200msCSS translate-x 200ms via data-state
Disabled stateStandard button disabled attributeRadix data-disabled + standard attribute
Client componentYes — 'use client'Yes — 'use client'
LicenseMITMIT

Thirty lines of button vs a Radix wrapper

The Drivn Switch file is short enough to read in one breath. It declares a styles object with two entries — base for the pill track and thumb for the white circle — then renders a <button type="button" role="switch" aria-checked={checked}> whose track colour swaps between bg-primary and bg-border and whose thumb translates between translate-x-[3px] and translate-x-[25px] based on checked. The toggle logic is one line: onClick={() => onChange?.(!checked)}. That is the whole component.

The shadcn/ui Switch is a thin wrapper around @radix-ui/react-switch. The wrapper file is roughly the same length as the Drivn file, but it imports SwitchPrimitive.Root and SwitchPrimitive.Thumb from a separate package that ships its own state machine, its own data attributes (data-state="checked", data-disabled), its own focus management, and its own form integration hooks. You get more behaviour for free, at the cost of a runtime dependency you cannot remove without rewriting the component.

1// packages/drivn/src/registry/components/switch.ts — verbatim
2const styles = {
3 base: cn(
4 'relative w-12 h-[26px] rounded-full',
5 'transition-colors duration-200 overflow-hidden'
6 ),
7 thumb: cn(
8 'absolute left-0 top-[3px] w-5 h-5',
9 'bg-white rounded-full shadow-md',
10 'transition-transform duration-200'
11 ),
12}

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
3import * as React from "react"
4import { Switch } from "@/components/ui/switch"
5
6export 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
2npx drivn add switch
3
4# shadcn/ui wraps a Radix package
5npx shadcn@latest add switch
6# also installs @radix-ui/react-switch
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 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.