Drivn vs shadcn/ui — Tabs Compared
Drivn Tabs is a zero-dependency dot-notation component; shadcn/ui Tabs wraps Radix for built-in ARIA. Compare API shape, controlled mode, and accessibility.
Drivn and shadcn/ui take genuinely different paths to a tab interface, and Tabs is one of the clearest examples. shadcn/ui's Tabs wraps @radix-ui/react-tabs — a headless primitive that ships the full WAI-ARIA tabs pattern, roving tabindex, and arrow-key navigation out of the box, exposed through four named exports: Tabs, TabsList, TabsTrigger, and TabsContent. Drivn's Tabs is a single file at packages/drivn/src/registry/components/tabs.ts with no Radix and no runtime UI dependency — just React, a small context, and the cn() class helper.
That trade is the whole story. Drivn gives you a dot-notation API — one import { Tabs } yields Tabs.List, Tabs.Tab, and Tabs.Panel attached to the root via Object.assign — controlled and uncontrolled modes from the same component, and a single const styles object you can read top to bottom. shadcn gives you the accessibility primitives Radix maintains for you, at the cost of a dependency in your tree and four flat exports to import and keep in sync.
This page walks each difference with verbatim source from the Drivn registry, then is honest about the one place shadcn clearly wins out of the box: keyboard and screen-reader behaviour you would otherwise layer on yourself.
Side-by-side comparison
| Feature | Drivn | shadcn/ui |
|---|---|---|
| Install command | npx drivn add tabs | npx shadcn@latest add tabs |
| Runtime UI dependencies | cn() utility only — zero npm UI packages | @radix-ui/react-tabs |
| API shape | Dot notation — Tabs.List, Tabs.Tab, Tabs.Panel | Named exports — TabsList, TabsTrigger, TabsContent |
| Import statement | One name: import { Tabs } | Four names imported flat |
| Controlled + uncontrolled | Both — defaultValue, value, onChange | Both — defaultValue, value, onValueChange |
| Keyboard arrow navigation | Yes — Radix roving tabindex + arrow keys | |
| WAI-ARIA tab roles | Yes — full ARIA tabs pattern | |
| Inactive panels | Unmounted — Panel returns null | Unmounted by default |
| Styling pattern | Single const styles object | Inline cn() per export |
| Client component | Yes — 'use client' | Yes — 'use client' |
| License | MIT | MIT |
One import, dot notation — Tabs.Tab not TabsTrigger
Drivn attaches the three tab parts to the root via Object.assign(TabsRoot, { List, Tab, Panel }), so a single import { Tabs } from "@/components/ui/tabs" gives you Tabs.List, Tabs.Tab, and Tabs.Panel. The tree reads like the interface it builds: a list of tabs followed by the panels they reveal, with no Tabs prefix repeated as a separate export on every part.
shadcn/ui splits the same surface into four named exports — Tabs, TabsList, TabsTrigger, TabsContent — which you import flat and compose. The rendered result is comparable, but Drivn keeps the import to one line and the value wiring stays implicit: you pass value on each Tabs.Tab and matching value on each Tabs.Panel, and context handles the rest. See the full API on the Tabs reference.
1 import { Tabs } from "@/components/ui/tabs" 2 3 export default function Page() { 4 return ( 5 <Tabs defaultValue="overview"> 6 <Tabs.List> 7 <Tabs.Tab value="overview">Overview</Tabs.Tab> 8 <Tabs.Tab value="usage">Usage</Tabs.Tab> 9 </Tabs.List> 10 <Tabs.Panel value="overview">Overview content</Tabs.Panel> 11 <Tabs.Panel value="usage">Usage content</Tabs.Panel> 12 </Tabs> 13 ) 14 }
Zero dependencies — no Radix in your tree
The clearest difference is the dependency line. shadcn/ui's Tabs installs @radix-ui/react-tabs, which lands in your node_modules and your bundle. Drivn's Tabs imports only React and the local cn() helper — the entire component is one file you own, with a const styles object holding every class. The active tab swaps bg-background text-foreground in for the inactive text-muted-foreground hover:text-foreground, and the list is a rounded bg-accent track with a one-pixel inset padding.
Because the styles live in one object rather than scattered through inline cn() calls per export, you restyle the whole component by editing four keys. There is no headless library version to track, no breaking change in a dependency to chase — the trade, covered below, is that you also do not inherit Radix's accessibility behaviour.
1 // packages/drivn/src/registry/components/tabs.ts — verbatim 2 const styles = { 3 list: 'flex w-fit bg-accent border border-border rounded-[10px] p-1', 4 tab: cn( 5 'px-4 py-2 text-sm font-medium rounded-lg', 6 'transition-colors cursor-pointer' 7 ), 8 active: 'bg-background text-foreground', 9 inactive: 'text-muted-foreground hover:text-foreground', 10 }
Controlled and uncontrolled from one component
Drivn's TabsRoot supports both modes from the same props. Leave it uncontrolled with defaultValue and it tracks the active tab internally with React.useState; pass value and onChange and it becomes controlled, so you can drive the active tab from URL state, a parent reducer, or a form. The logic is a single const value = controlled ?? uncontrolled plus a setValue callback that fires onChange and only writes internal state when no value prop was supplied.
shadcn/ui exposes the same capability via Radix's defaultValue, value, and onValueChange — the prop name is the only meaningful difference (onChange in Drivn, onValueChange in shadcn). Both let you mix a default with an optional controlled override, so neither forces you to choose up front.
1 // packages/drivn/src/registry/components/tabs.ts — verbatim 2 const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? '') 3 const value = controlled ?? uncontrolled 4 const setValue = React.useCallback( 5 (v: string) => { 6 onChange?.(v) 7 if (controlled === undefined) setUncontrolled(v) 8 }, 9 [controlled, onChange] 10 )
Where shadcn and Radix still win — accessibility
The honest case for shadcn/ui here is accessibility you get for free. Drivn's Tabs.Tab renders a plain <button> with an onClick that calls setValue — it does not add role="tab", aria-selected, a tabpanel role, or arrow-key navigation between triggers. shadcn's Radix-backed Tabs ships the full WAI-ARIA tabs pattern and roving-tabindex keyboard support out of the box, which matters for keyboard and screen-reader users on a complex app.
For a fresh build where you want zero dependencies, one import, and a Tabs component you can read and own end to end, Drivn is the cleaner start — and because you own the source, layering role and aria-selected attributes onto the <button> is a few lines you control. If you need the WAI-ARIA keyboard behaviour without writing or maintaining it yourself, stay on shadcn. Either way, pair Tabs with Card for panel surfaces and Button for actions inside each panel.
1 # Drivn — one import, zero runtime UI deps 2 npx drivn add tabs 3 4 # shadcn/ui — four named exports, @radix-ui/react-tabs in your tree 5 npx shadcn@latest add tabs
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 Tabs component is a single file at packages/drivn/src/registry/components/tabs.ts that imports React and the local cn() utility — nothing else. shadcn/ui's Tabs, by contrast, wraps @radix-ui/react-tabs, so installing it adds that Radix package to your node_modules and bundle. Drivn keeps the whole component in your codebase with no runtime UI dependency to track.
Drivn uses dot notation — one import of { Tabs } gives you Tabs.List, Tabs.Tab, and Tabs.Panel attached to the root via Object.assign. shadcn/ui exports four separate names — Tabs, TabsList, TabsTrigger, and TabsContent — which you import flat. Both wire the active value through context; Drivn keeps the import to one line and the panel matching to a shared value prop on Tab and Panel.
Yes. Pass value and onChange to make Tabs controlled, or defaultValue to leave it uncontrolled — the source reads const value = controlled ?? uncontrolled, so a supplied value prop takes over while defaultValue seeds internal useState. This lets you drive the active tab from URL state or a parent store. shadcn/ui offers the same via Radix using onValueChange instead of onChange.
Not out of the box. Drivn's Tabs.Tab renders a plain button with an onClick handler and does not add roving tabindex, arrow-key movement, or ARIA tab roles. shadcn/ui's Radix-backed Tabs ships the full WAI-ARIA tabs pattern and keyboard navigation automatically. Because you own the Drivn source, you can add role and aria-selected attributes to the button yourself if you need them.
No. The Drivn Tabs.Panel returns null when its value does not match the active tab, so inactive panels are unmounted rather than hidden with CSS — their content and effects do not run until selected. shadcn/ui also unmounts inactive content by default. Both approaches make tabs a natural place to lazy-load heavy panel content that only mounts when the user opens that tab.
Yes. The Tabs source begins with the 'use client' directive because it holds the active value in React.useState and shares it through a React context. Any page that renders Tabs therefore includes that client component, though the panels you place inside can be as light or heavy as you like. shadcn/ui's Tabs is likewise a client component since Radix manages interactive state.

