Skip to content
Drivn
5 min read

Next.js Tabs with the App Router

Add tabbed panels to a Next.js App Router app. Drivn ships a copy-paste, zero-dependency Tabs with controlled state and three variants in one client boundary.

Tabs organize a busy page into switchable panels — a settings screen split into Account, Billing, and Team, or a product page toggling Overview, Specs, and Reviews. The detail worth knowing up front in a Next.js App Router project is that Drivn's Tabs is a Client Component: the tab buttons carry onClick handlers and the active value lives in React state, so the file opens with a 'use client' directive and renders inside a client boundary rather than straight in a Server Component.

After install the component sits in src/components/ui/tabs.tsx as a compound set: a Tabs root plus Tabs.List, Tabs.Tab, and Tabs.Panel attached via Object.assign. The root shares the active value through a React context, so the parts stay in sync with no prop drilling — Tabs.Tab reads the value to know when it is active, and Tabs.Panel reads it to decide whether to render. It works uncontrolled with a defaultValue, or controlled with a value and onChange pair when the URL or a parent owns the state.

This guide installs Drivn in a Next.js 16 project, renders a tab set inside a client boundary, switches between uncontrolled and controlled state, cycles the three built-in variants, then restyles from the file you own. For the full reference see the Tabs docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Tabs.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add tabs. The Tabs component imports nothing past React and the local cn utility — no headless primitive, no icon dependency — so the CLI writes a single tabs.tsx file and touches nothing else in your tree. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it, so commit it for a clean baseline before wiring it into a page. There is no peer dependency to add and nothing to configure past having Tailwind v4 already processing your styles — the component is pure React plus Tailwind classes over a context-driven button group.

1# from the root of your Next.js 16 project
2npx drivn add tabs

Render tabs inside a client boundary

Because tabs.tsx opens with 'use client', the tab set renders inside a client boundary — that is the piece that sets Tabs apart from a static Server Component. In the App Router you can keep the surrounding page a Server Component and drop the Tabs into a client child, or mark the page file itself 'use client' for a quick start. The root takes a defaultValue matching one Tabs.Tab value; Tabs.List renders the row of buttons, each Tabs.Tab carries a unique value, and each Tabs.Panel renders only when its value matches the active tab. The React context wires them together, so you never pass the active state by hand — see the installation guide for project setup. Compose the parts to mirror the sections they switch between.

1'use client'
2
3import { Tabs } from '@/components/ui/tabs'
4
5export function AccountTabs() {
6 return (
7 <Tabs defaultValue="account">
8 <Tabs.List>
9 <Tabs.Tab value="account">Account</Tabs.Tab>
10 <Tabs.Tab value="billing">Billing</Tabs.Tab>
11 <Tabs.Tab value="team">Team</Tabs.Tab>
12 </Tabs.List>
13 <Tabs.Panel value="account">Account settings here.</Tabs.Panel>
14 <Tabs.Panel value="billing">Billing settings here.</Tabs.Panel>
15 <Tabs.Panel value="team">Team settings here.</Tabs.Panel>
16 </Tabs>
17 )
18}

Uncontrolled vs controlled state

The Tabs root supports both state models from the same component. Left uncontrolled, you pass a defaultValue and the root holds the active tab in its own useState — the simplest setup, and all you need for a self-contained tab group. Controlled, you pass a value and an onChange pair and own the active tab in the parent; the root reads controlled ?? uncontrolled, so as soon as value is defined it defers to you. Controlled mode is what you reach for when the active tab should sync with the URL — read a ?tab= search param in the App Router and set it from onChange so a deep link opens the right panel. The onChange callback receives the next value as a plain string, so wiring it to a useState setter or a router push is a one-liner. For copy-paste layouts see the Tabs examples.

1'use client'
2
3import { useState } from 'react'
4import { Tabs } from '@/components/ui/tabs'
5
6export function ControlledTabs() {
7 const [tab, setTab] = useState('overview')
8
9 return (
10 <Tabs value={tab} onChange={setTab}>
11 <Tabs.List>
12 <Tabs.Tab value="overview">Overview</Tabs.Tab>
13 <Tabs.Tab value="usage">Usage</Tabs.Tab>
14 </Tabs.List>
15 <Tabs.Panel value="overview">Overview content.</Tabs.Panel>
16 <Tabs.Panel value="usage">Usage content.</Tabs.Panel>
17 </Tabs>
18 )
19}

Switch variants: default, underline, compact

The variant prop on the Tabs root swaps the whole tab-strip look without any change to your markup. default renders a pill group — Tabs.List becomes a bg-accent border border-border rounded-[10px] p-1 container and the active tab lifts to bg-background text-foreground. underline drops the container for a flat row where the active tab is text-foreground underline underline-offset-8, the classic docs-style navigation. compact shrinks everything to text-xs font-semibold pills for dense toolbars. Each variant carries its own list, tab, active, and inactive classes in the variantStyles object, and the root passes the choice down through context so Tabs.List and every Tabs.Tab read the same set. The values below are copied verbatim from the registry source.

1// packages/drivn/src/registry/components/tabs.ts — verbatim
2const variantStyles = {
3 default: {
4 list: 'flex w-fit bg-accent border border-border rounded-[10px] p-1',
5 tab: cn(
6 'px-4 py-2 text-sm font-medium rounded-lg',
7 'transition-colors cursor-pointer'
8 ),
9 active: 'bg-background text-foreground',
10 inactive: 'text-muted-foreground hover:text-foreground',
11 },
12 underline: {
13 list: 'flex w-fit gap-4',
14 tab: cn(
15 'text-[16px] font-medium',
16 'transition-colors cursor-pointer'
17 ),
18 active: 'text-foreground underline underline-offset-8',
19 inactive: 'text-muted-foreground hover:text-foreground',
20 },
21 compact: {
22 list: 'flex w-fit gap-0.5',
23 tab: cn(
24 'px-2.5 py-1 text-xs font-semibold rounded',
25 'transition-colors cursor-pointer'
26 ),
27 active: 'bg-muted text-foreground',
28 inactive: 'text-muted-foreground hover:text-foreground hover:bg-muted',
29 },
30}

Restyle from the variantStyles object

Everything visible on the Tabs is a Tailwind class in the variantStyles object inside the file you own, so restyling is a local edit. Each variant is a small map of list, tab, active, and inactive keys — change the default active state from bg-background to a brand token for a stronger selected pill, or bump the underline offset from underline-offset-8 to tighten the gap under the label. Because the classes read your theme tokens, the tabs match dark and light mode with no override — see Theming for the token list. Every Tabs.Tab also gets a built-in focus-visible:ring-[3px] focus-visible:ring-ring/50 focus ring from its base button, so keyboard users see a clear focus state without extra work. Edit the object once and every Tabs in your app follows.

1// packages/drivn/src/registry/components/tabs.ts — verbatim
2default: {
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},
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

Yes. The tab buttons carry onClick handlers and the active value lives in React state, so the source opens with a 'use client' directive and must render inside a client boundary. In the App Router you can keep the surrounding page a Server Component and drop the Tabs into a client child, or mark the page file itself 'use client'. In a Vite or Create React App project the directive is simply ignored and the component works the same.

Through a React context on the Tabs root. The root provides the active value, a setter, and the chosen variant; Tabs.Tab reads the value to know when it is active and Tabs.Panel reads it to decide whether to render, so there is no prop drilling. Using any part outside a Tabs root throws an error, which keeps the compound API honest at runtime.

Yes. Use controlled mode: pass a value and onChange pair instead of a defaultValue. Read a ?tab= search param in the App Router, feed it as value, and set the param from onChange so a deep link opens the right panel. The root reads controlled ?? uncontrolled, so the moment value is defined it defers to your state rather than its internal useState.

Three, set through the variant prop on the Tabs root: default renders a pill group with a bg-accent container and a raised active pill, underline drops the container for a flat row with an underlined active tab, and compact shrinks the tabs to text-xs font-semibold pills for dense toolbars. The variant lives on the root and passes down through context, so every Tabs.List and Tabs.Tab reads the same style set.