Skip to content
Drivn
5 min read

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

FeatureDrivnshadcn/ui
Install commandnpx drivn add tabsnpx shadcn@latest add tabs
Runtime UI dependenciescn() utility only — zero npm UI packages@radix-ui/react-tabs
API shapeDot notation — Tabs.List, Tabs.Tab, Tabs.PanelNamed exports — TabsList, TabsTrigger, TabsContent
Import statementOne name: import { Tabs }Four names imported flat
Controlled + uncontrolledBoth — defaultValue, value, onChangeBoth — defaultValue, value, onValueChange
Keyboard arrow navigationYes — Radix roving tabindex + arrow keys
WAI-ARIA tab rolesYes — full ARIA tabs pattern
Inactive panelsUnmounted — Panel returns nullUnmounted by default
Styling patternSingle const styles objectInline cn() per export
Client componentYes — 'use client'Yes — 'use client'
LicenseMITMIT

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.

1import { Tabs } from "@/components/ui/tabs"
2
3export 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
2const 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
2const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? '')
3const value = controlled ?? uncontrolled
4const 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
2npx drivn add tabs
3
4# shadcn/ui four named exports, @radix-ui/react-tabs in your tree
5npx shadcn@latest add tabs
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 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.