React Tabs Component Examples
Copy-paste React Tabs examples: a basic uncontrolled panel, controlled tabs with useState, triggers with icons, and lazy panels that mount only when active.
Tabs let you pack several views into one footprint — a settings screen, a code-and-preview toggle, a product detail panel — showing one at a time behind a row of triggers. The Tabs component in Drivn is three dot-notation sub-components attached to one root via Object.assign: Tabs.List for the trigger row, Tabs.Tab for each clickable label, and Tabs.Panel for the content that swaps in. One import { Tabs } brings all three, and the component is dependency-free — just React, a small context, and cn() from @/utils/cn.
Tabs is a client component ('use client') because it holds the active value in React.useState and shares it through context, so every example below runs in the browser. Each snippet is TypeScript, because Drivn ships TypeScript-only .tsx source installed by the Drivn CLI under @/components/ui/tabs.
The examples cover the basic uncontrolled three-tab panel, a controlled version driven by your own React.useState, triggers with leading icons, and lazy panels that mount only when active — a useful pattern since Tabs.Panel returns null for the inactive value rather than hiding it with CSS. Pair the result with a Card when you want the panels to sit on a bordered surface.
A basic three-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 wire click handlers yourself. The list renders as a rounded bg-accent track, and the active tab swaps in bg-background text-foreground while the rest stay muted.
Because Tabs is uncontrolled here, it tracks the active tab internally from defaultValue="overview". This is the right default for most UI — a settings panel, a docs section toggle, a product tab strip — where the component owns its own state and you only care about what is shown. Drop it into any client page and it works with no extra state management; see the full prop list 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.Tab value="api">API</Tabs.Tab> 10 </Tabs.List> 11 <Tabs.Panel value="overview"> 12 Overview content here. 13 </Tabs.Panel> 14 <Tabs.Panel value="usage"> 15 Usage content here. 16 </Tabs.Panel> 17 <Tabs.Panel value="api"> 18 API content here. 19 </Tabs.Panel> 20 </Tabs> 21 ) 22 }
Controlled tabs with React.useState
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.
This is the pattern to reach for when a Button elsewhere on the page needs to jump the user to a specific tab, or when the active tab must survive a navigation by living in the URL. The wrapper is marked 'use client' for the useState, and setTab is passed straight to onChange because both share the (value: string) => void signature.
1 "use client" 2 3 import * as React from "react" 4 import { Tabs } from "@/components/ui/tabs" 5 6 export default function ControlledTabs() { 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 }
Tabs with icons in the triggers
Each Tabs.Tab accepts children and a className, and it spreads any remaining <button> attributes onto the rendered element — so adding a leading icon is just dropping a lucide-react icon component beside the label. The tab's base styles set padding and font but no flex layout, so pass className="flex items-center gap-2" on the Tab to line the icon up with the text; cn() merges it with the component's own classes.
This keeps the icon-as-component convention consistent with the rest of Drivn — you render <User className="size-4" /> as a child rather than threading an icon prop. The active and inactive color tokens still apply, so the icon inherits the same text-foreground / text-muted-foreground treatment as the label when the tab's state changes.
1 import { Tabs } from "@/components/ui/tabs" 2 import { User, CreditCard } from "lucide-react" 3 4 export default function IconTabs() { 5 return ( 6 <Tabs defaultValue="profile"> 7 <Tabs.List> 8 <Tabs.Tab value="profile" className="flex items-center gap-2"> 9 <User className="size-4" /> 10 Profile 11 </Tabs.Tab> 12 <Tabs.Tab value="payment" className="flex items-center gap-2"> 13 <CreditCard className="size-4" /> 14 Payment 15 </Tabs.Tab> 16 </Tabs.List> 17 <Tabs.Panel value="profile">Profile panel</Tabs.Panel> 18 <Tabs.Panel value="payment">Payment panel</Tabs.Panel> 19 </Tabs> 20 ) 21 }
Lazy panels that mount only when active
A useful property of the Drivn Tabs is that Tabs.Panel returns null when its value does not match the active tab — inactive panels are unmounted, not hidden with CSS. That means anything inside an inactive panel never renders, so its useEffect hooks, data fetches, and expensive children do not run until the user actually opens that tab. You get lazy mounting for free, with no Suspense boundary or conditional of your own.
This is the pattern for a tab that holds a heavy chart, a map, or a list that fetches on mount. Put the costly component inside the panel — optionally wrapped in a Card — and it stays dormant until selected; switch away and it unmounts again. For a tab that should keep its state across switches, lift that state into the parent instead, since the panel itself is torn down each time it goes inactive.
1 import { Tabs } from "@/components/ui/tabs" 2 import { HeavyChart } from "@/components/heavy-chart" 3 4 export default function LazyTabs() { 5 return ( 6 <Tabs defaultValue="summary"> 7 <Tabs.List> 8 <Tabs.Tab value="summary">Summary</Tabs.Tab> 9 <Tabs.Tab value="analytics">Analytics</Tabs.Tab> 10 </Tabs.List> 11 <Tabs.Panel value="summary">Quick summary</Tabs.Panel> 12 <Tabs.Panel value="analytics"> 13 <HeavyChart /> 14 </Tabs.Panel> 15 </Tabs> 16 ) 17 }
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
Import { Tabs } from "@/components/ui/tabs", give the root a defaultValue, then nest a Tabs.List of Tabs.Tab triggers and one Tabs.Panel per tab. Each Tabs.Tab and Tabs.Panel share a value prop, and the panel whose value matches the active tab renders. The active value lives in context, so you do not wire any click handlers yourself.
Both. Pass defaultValue alone and the component 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.
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. This makes tabs a natural place to lazy-mount expensive content like charts or maps.
Yes. Tabs.Tab accepts children and a className and spreads remaining button attributes, so you render a lucide-react icon as a child next to the label. The base tab styles set no flex layout, so add className="flex items-center gap-2" on the Tab to align the icon with the text — cn() merges it with the component's own classes.
It is a client component. The Tabs source starts with the 'use client' directive because it holds the active value in React.useState and shares it through a React context. Any page rendering Tabs includes that client component, but the panels inside can be as light or heavy as you like — and inactive panels stay unmounted until selected.

