Shadcn Tabs
Free shadcn/ui tabs component for React — Radix-powered, with the default segmented look plus underline and pill restyles, vertical orientation, icons and badges, full-width, disabled, controlled, and scrollable. The examples official shadcn never shipped.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
Props
defaultValue
= —
string
The tab selected by default (uncontrolled, on Tabs).
value
= —
string
Controlled active tab (on Tabs). Pair with onValueChange.
onValueChange
= —
(value: string) => void
Fires when the active tab changes (on Tabs).
orientation
= "horizontal"
"horizontal" | "vertical"
Tab orientation for arrow-key navigation (on Tabs).
A Radix tabs primitive — four parts (Tabs, TabsList, TabsTrigger, TabsContent). The default look is a segmented card (bg-muted list, active trigger gets bg-background + shadow). Match each TabsTrigger value to a TabsContent value. Restyle to underline or pills, or go vertical, by overriding the TabsList / TabsTrigger classNames (see the examples).
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
Examples
Tabs
The canonical account/password tabs — the default segmented-card look with two full-width triggers.
Packages
Props
No props documented yet.
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsDemo01() {
return (
<Tabs defaultValue="account" className="w-[360px]">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="Ada Lovelace" />
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@ada" />
</div>
<Button className="w-fit">Save changes</Button>
</TabsContent>
<TabsContent value="password" className="mt-4 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="current">Current password</Label>
<Input id="current" type="password" />
</div>
<div className="grid gap-2">
<Label htmlFor="new">New password</Label>
<Input id="new" type="password" />
</div>
<Button className="w-fit">Change password</Button>
</TabsContent>
</Tabs>
)
}
Underline style
A line restyle — a transparent list with a bottom border and a `border-b-2` primary underline on the active tab.
Packages
Props
No props documented yet.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const tabs = [
{ value: "overview", body: "Your account overview at a glance." },
{ value: "analytics", body: "Traffic and engagement analytics." },
{ value: "reports", body: "Download and schedule reports." },
{ value: "notifications", body: "Manage your notification preferences." },
]
export default function TabsUnderline01() {
return (
<Tabs defaultValue="overview" className="w-[440px]">
<TabsList className="h-auto w-full justify-start gap-4 rounded-none border-b bg-transparent p-0">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="text-muted-foreground data-[state=active]:border-primary data-[state=active]:text-foreground h-9 flex-none rounded-none border-0 border-b-2 border-transparent bg-transparent px-1 capitalize shadow-none data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{tab.value}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="text-muted-foreground mt-4 text-sm"
>
{tab.body}
</TabsContent>
))}
</Tabs>
)
}
Pills
A `rounded-full` pill restyle with a solid primary active state.
Packages
Props
No props documented yet.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const tabs = [
{ value: "all", label: "All", body: "Showing all items." },
{ value: "active", label: "Active", body: "Showing active items." },
{ value: "archived", label: "Archived", body: "Showing archived items." },
]
export default function TabsPills01() {
return (
<Tabs defaultValue="all" className="w-[420px]">
<TabsList className="rounded-full">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full data-[state=active]:shadow-none"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="text-muted-foreground mt-4 text-sm"
>
{tab.body}
</TabsContent>
))}
</Tabs>
)
}
Vertical
Vertical tabs — `flex-row` Tabs with a `flex-col` TabsList and left-aligned triggers (`orientation="vertical"`).
Packages
Props
No props documented yet.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const tabs = [
{ value: "general", label: "General", body: "General workspace settings." },
{ value: "members", label: "Members", body: "Invite and manage members." },
{ value: "billing", label: "Billing", body: "Plans and invoices." },
{ value: "advanced", label: "Advanced", body: "Danger zone and exports." },
]
export default function TabsVertical01() {
return (
<Tabs
defaultValue="general"
orientation="vertical"
className="w-[460px] flex-row gap-4"
>
<TabsList className="h-auto w-40 flex-col">
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="w-full justify-start">
{tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="flex-1">
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="text-muted-foreground text-sm"
>
{tab.body}
</TabsContent>
))}
</div>
</Tabs>
)
}
Icons & badges
Triggers with a leading icon and a count [Badge](/components/badge) — the inbox/sent/alerts pattern.
Packages
Props
No props documented yet.
import { BellIcon, InboxIcon, SendIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const tabs = [
{ value: "inbox", label: "Inbox", icon: InboxIcon, count: 12, body: "You have 12 new messages." },
{ value: "sent", label: "Sent", icon: SendIcon, count: 0, body: "Your sent messages." },
{ value: "alerts", label: "Alerts", icon: BellIcon, count: 3, body: "You have 3 unread alerts." },
]
export default function TabsIcons01() {
return (
<Tabs defaultValue="inbox" className="w-[440px]">
<TabsList className="w-full">
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon />
{tab.label}
{tab.count > 0 && (
<Badge
variant="secondary"
className="h-5 min-w-5 px-1 tabular-nums"
>
{tab.count}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="text-muted-foreground mt-4 text-sm"
>
{tab.body}
</TabsContent>
))}
</Tabs>
)
}
Full width
Equal-width triggers that fill the container with `grid w-full grid-cols-N`.
Packages
Props
No props documented yet.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const tabs = [
{ value: "preview", label: "Preview", body: "The rendered preview." },
{ value: "code", label: "Code", body: "The source code." },
{ value: "settings", label: "Settings", body: "Configuration options." },
]
export default function TabsFullWidth01() {
return (
<Tabs defaultValue="preview" className="w-[460px]">
<TabsList className="grid w-full grid-cols-3">
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="text-muted-foreground mt-4 rounded-md border p-4 text-sm"
>
{tab.body}
</TabsContent>
))}
</Tabs>
)
}
Disabled & controlled
A disabled tab plus **controlled** state via `value` / `onValueChange`.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsDisabled01() {
const [tab, setTab] = React.useState("details")
return (
<div className="grid gap-3">
<p className="text-muted-foreground text-sm">
Active tab: <span className="text-foreground font-medium">{tab}</span>
</p>
<Tabs value={tab} onValueChange={setTab} className="w-[420px]">
<TabsList className="w-full">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="shipping">Shipping</TabsTrigger>
<TabsTrigger value="payment" disabled>
Payment
</TabsTrigger>
</TabsList>
<TabsContent value="details" className="text-muted-foreground mt-4 text-sm">
Your order details.
</TabsContent>
<TabsContent value="shipping" className="text-muted-foreground mt-4 text-sm">
Your shipping address.
</TabsContent>
<TabsContent value="payment" className="text-muted-foreground mt-4 text-sm">
Payment unlocks once shipping is set.
</TabsContent>
</Tabs>
</div>
)
}
Scrollable
Many tabs in a horizontally scrollable list — `overflow-x-auto` around a `w-max` TabsList.
Packages
Props
No props documented yet.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
export default function TabsScrollable01() {
return (
<Tabs defaultValue="January" className="w-[440px]">
<div className="overflow-x-auto">
<TabsList className="w-max">
{months.map((month) => (
<TabsTrigger key={month} value={month}>
{month}
</TabsTrigger>
))}
</TabsList>
</div>
{months.map((month) => (
<TabsContent
key={month}
value={month}
className="text-muted-foreground mt-4 text-sm"
>
Showing data for {month}.
</TabsContent>
))}
</Tabs>
)
}
The Shadcn Tabs switch between panels in place — the canonical shadcn/ui tabs built on Radix. Official ships one demo in the default segmented style; the examples below cover what people actually search for — underline tabs, pill tabs, vertical tabs, plus icons, badges, full-width, controlled, and scrollable.
Built on Radix
Tabs is a thin wrapper over Radix Tabs with four parts — Tabs, TabsList, TabsTrigger, TabsContent. Radix owns the WAI-ARIA tabs pattern: roving focus, arrow-key navigation, and the aria-controls wiring. The one rule: each TabsTrigger value must match a TabsContent value.
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">…</TabsContent>
<TabsContent value="password">…</TabsContent>
</Tabs>
Restyling: underline & pills
The default look is a segmented card — a bg-muted list where the active trigger gets bg-background + a shadow. The two most-requested alternatives are className overrides, not new components:
- Underline — make the list transparent with a bottom border (
bg-transparent rounded-none border-b p-0) and give the trigger aborder-b-2 border-transparent data-[state=active]:border-primary, clearing the card look withdata-[state=active]:bg-transparent data-[state=active]:shadow-none. - Pills — add
rounded-fullto the list and triggers and a solid active fill (data-[state=active]:bg-primary data-[state=active]:text-primary-foreground).
The Underline and Pills examples are copy-paste ready.
Vertical, full-width & scrollable
- Vertical — set
orientation="vertical"(for arrow keys), makeTabsflex-row, and theTabsListflex-colwithw-full justify-starttriggers (the Vertical example). - Full width — give
TabsListgrid w-full grid-cols-Nso every trigger is equal width (the Full width example). - Scrollable — wrap a
w-maxTabsListin anoverflow-x-autodiv when there are too many tabs to fit (the Scrollable example).
Icons, badges, disabled & controlled
Drop an icon and a count Badge straight into a trigger (the Icons & badges example). Disable a trigger with the disabled prop. Use defaultValue for an uncontrolled start, or value + onValueChange to control the active tab from state — useful for wizards or syncing to the URL (the Disabled & controlled example).
Tabs vs Accordion
Use Tabs when the sections are peers shown one at a time in a fixed area (settings, preview/code). Use an Accordion when sections stack and several may be open, or on narrow screens where horizontal triggers don't fit. Tabs switch in place; accordions expand downward.
Accessibility & theming
Radix gives the tabs full keyboard support — arrow keys move between triggers, Home/End jump to the ends, and the panel is announced via aria-controls. The tabs read your design tokens — --muted (the list), --background (the active trigger), --primary (underline/pill accents) — so they follow your theme, including dark mode, with no overrides.