Skip to content
Drivn
6 min read

How to Add Tabs to a React App

Step-by-step guide to add tabs to any React app with the Drivn CLI — a copy-paste, dot-notation Tabs component with controlled, uncontrolled, and styled modes.

Reaching for tabs means your React app has several views that share one footprint — a settings screen, a code-and-preview toggle, a product detail panel — and you want to show one at a time behind a row of triggers. You could hand-roll the active-index state, the click handlers, and the show/hide logic every time, or you could paste in a component that already wires them together. The Tabs in Drivn is that component: three dot-notation parts — Tabs.List, Tabs.Tab, and Tabs.Panel — attached to one root via Object.assign, so a single import { Tabs } brings all three.

Drivn installs by copy-paste: the CLI writes a tabs.tsx file into your repository. The component shares the active value through a small React context, so you never wire click handlers yourself — each Tabs.Tab and Tabs.Panel carries a matching value, and the panel whose value is active renders while the rest return null. Because it holds state in React.useState and reads it through context, the source opens with a 'use client' directive. Its only import past React is the local cn utility — there is no headless dependency and no Radix in your tree.

This guide adds Tabs to any React app — install the CLI, render your first panel, control the active tab from your own state, and switch the tab style with the built-in variant prop. For the App Router walkthrough see the Next.js Tabs guide, and for more layouts see the Tabs examples.

Prerequisites

Before installing Tabs, confirm your React project has the two things Drivn assumes: Tailwind CSS v4 installed and processing your styles, and a @/ path alias pointing at your source directory — the component ships as a .tsx file, so TypeScript is part of the stack. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, both are already wired; for a custom setup the installation page lists the minimal config. Tabs has no third-party import past React and the local cn utility — there is no animation or headless library to add. The one detail worth knowing before you render it is that Tabs is a Client Component: it holds the active value in React.useState and shares it through a React context, so its source opens with a 'use client' directive. You can still import Tabs into a Server Component page directly, but any wrapper that reads or sets the active tab with your own state must itself be a client component.

Step 1 — Install Drivn via the CLI

Run the CLI from your project root to add the Tabs source file. The command prompts once for your install directory (defaulting to src/components/ui/) and writes a single tabs.tsx — no other files, because the component's only dependency past React is the cn utility. No global config file is created; the result is TypeScript source you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install. Because the file is yours after install, future Drivn releases will not overwrite your edits — the whole component, from the context provider to the three sub-components and the styling object, is one file you can read top to bottom.

1# add the Tabs source file
2npx drivn add tabs

Step 2 — Render your first tab panel

The starting point is a Tabs root with a defaultValue, a Tabs.List of Tabs.Tab triggers, and one Tabs.Panel per tab. Each Tabs.Tab carries a value, and the panel with the matching value is the one that renders — the active value lives in context, so you never write a click handler yourself. Because Tabs is uncontrolled here, it tracks the active tab internally from defaultValue, which is the right default for most UI: a settings panel, a docs section toggle, a product tab strip. Tabs itself is a Client Component, so you can import it straight into a Server Component page like the one below and only the Tabs part hydrates. The default variant renders the list as a rounded bg-accent track, with the active tab swapping in bg-background text-foreground.

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="settings">Settings</Tabs.Tab>
9 </Tabs.List>
10 <Tabs.Panel value="overview">Overview content here.</Tabs.Panel>
11 <Tabs.Panel value="settings">Settings content here.</Tabs.Panel>
12 </Tabs>
13 )
14}

Step 3 — Control the active tab

When you need to read or set the active tab from outside — syncing it to a URL query param, a parent reducer, or a multi-step form — switch to controlled mode by passing value and onChange. The Drivn source reads const value = controlled ?? uncontrolled, so the moment you pass a value prop it takes over and your onChange becomes the single source of truth. Hold the active tab in React.useState in the parent and feed it back in; onChange fires with the next tab's string value, so wiring it to the setter is a one-liner. Because this wrapper owns state, it must start with 'use client'. Reach for this when a Button elsewhere on the page jumps the user to a specific tab, or when the active tab must survive navigation by living in the URL.

1'use client'
2
3import * as React from 'react'
4import { Tabs } from '@/components/ui/tabs'
5
6export function SettingsTabs() {
7 const [tab, setTab] = React.useState('account')
8
9 return (
10 <Tabs value={tab} onChange={setTab}>
11 <Tabs.List>
12 <Tabs.Tab value="account">Account</Tabs.Tab>
13 <Tabs.Tab value="billing">Billing</Tabs.Tab>
14 </Tabs.List>
15 <Tabs.Panel value="account">Account settings</Tabs.Panel>
16 <Tabs.Panel value="billing">Billing settings</Tabs.Panel>
17 </Tabs>
18 )
19}

Step 4 — Switch the tab style with the variant prop

Tabs ships three built-in looks through a single variant prop on the root: default, underline, and compact. The default variant is the rounded bg-accent pill track from Step 2; underline drops the track for plain text triggers with an underline underline-offset-8 on the active tab — the toolbar look for a docs page; and compact renders small text-xs triggers with a bg-muted active state for dense toolbars. Each variant is a key in the variantStyles object inside the file you own, so the switch is a prop, not a re-implementation, and you can add a fourth variant by adding a key. Pair any of them with a Card to give the panels a bordered surface, and recolor the active and hover tokens in Theming. The example below renders the underline style for a preview-and-code toggle.

1import { Tabs } from '@/components/ui/tabs'
2
3export default function DocsTabs() {
4 return (
5 <Tabs defaultValue="preview" variant="underline">
6 <Tabs.List>
7 <Tabs.Tab value="preview">Preview</Tabs.Tab>
8 <Tabs.Tab value="code">Code</Tabs.Tab>
9 </Tabs.List>
10 <Tabs.Panel value="preview">Rendered preview</Tabs.Panel>
11 <Tabs.Panel value="code">Source code</Tabs.Panel>
12 </Tabs>
13 )
14}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

Follow Drivn updates
New components, improvements, and guides every release.
Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

The Tabs source already opens with 'use client' because it holds the active value in React.useState and shares it through context. You can import Tabs straight into a Server Component page and render an uncontrolled panel with no directive of your own. You only add 'use client' to your own wrapper when that wrapper reads or sets the active tab with useState — the controlled pattern. In a Vite or Create React App project the directive is simply ignored and the component works the same.

Both, from the same component. Pass defaultValue alone and Tabs tracks the active tab internally with React.useState. Pass value and onChange and it becomes controlled — the source reads const value = controlled ?? uncontrolled, so a supplied value takes over. Controlled mode is how you sync the active tab to a URL query param, a parent store, or a button elsewhere on the page. onChange hands you the next tab's string value directly.

Pass a variant prop on the Tabs root. Three looks ship built in: default is a rounded bg-accent pill track, underline is plain text with an underlined active tab, and compact is small text-xs triggers with a bg-muted active state. Each is a key in the variantStyles object inside the tabs.tsx file you own, so you can also edit a variant's classes or add a fourth key. The className prop on Tabs.List and Tabs.Tab merges through cn() for per-instance tweaks.

No. Tabs.Panel returns null when its value does not match the active tab, so inactive panels are fully unmounted rather than hidden with CSS. Anything inside an inactive panel — effects, data fetches, heavy children — does not run until that tab is selected, which makes tabs a natural place to lazy-mount expensive content like a chart or a map. If a panel needs to keep its state across switches, lift that state into the parent, since the panel itself is torn down each time it goes inactive.

Yes. The component depends only on React and the local cn utility — no Next.js API and no router — so it works anywhere React and Tailwind reach the DOM. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add tabs and use the same dot-notation API, owning the active value in useState for controlled mode. The 'use client' directive at the top of the source is simply ignored outside the App Router.