Tabs

Tab navigation with underline, background, and ghost variants, and an indicator that follows the shape system

Manage your account settings.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
const LABELS: Record<string, string> = {
account: "Account",
password: "Password",
settings: "Settings",
};
export default function TabsDemo() {
return (
<Tabs defaultValue="account" size="lg" className="w-fit max-w-full">
<TabsList>
{PANELS.map((panel) => (
<TabsTrigger key={panel.value} value={panel.value}>
{LABELS[panel.value]}
</TabsTrigger>
))}
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>
);
}

Installation

npx shadcn@latest add https://www.deltacomponents.dev/r/tabs.json

Usage

1import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2
3<Tabs defaultValue="account">
4 <TabsList>
5 <TabsTrigger value="account">Account</TabsTrigger>
6 <TabsTrigger value="password">Password</TabsTrigger>
7 </TabsList>
8 <TabsContent value="account">Manage your account settings.</TabsContent>
9 <TabsContent value="password">Change your password here.</TabsContent>
10</Tabs>

Basic

Manage your account settings.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
const LABELS: Record<string, string> = {
account: "Account",
password: "Password",
settings: "Settings",
};
export default function TabsDemo() {
return (
<Tabs defaultValue="account" size="lg" className="w-fit max-w-full">
<TabsList>
{PANELS.map((panel) => (
<TabsTrigger key={panel.value} value={panel.value}>
{LABELS[panel.value]}
</TabsTrigger>
))}
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>
);
}

Underline

Manage your account settings.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
const LABELS: Record<string, string> = {
account: "Account",
password: "Password",
settings: "Settings",
};
export default function TabsUnderline() {
return (
<Tabs defaultValue="account" variant="underline" size="lg" className="w-fit max-w-full">
<TabsList>
{PANELS.map((panel) => (
<TabsTrigger key={panel.value} value={panel.value}>
{LABELS[panel.value]}
</TabsTrigger>
))}
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>
);
}

Ghost

Manage your account settings.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
const LABELS: Record<string, string> = {
account: "Account",
password: "Password",
settings: "Settings",
};
export default function TabsGhost() {
return (
<Tabs defaultValue="account" variant="ghost" size="lg" className="w-fit max-w-full">
<TabsList>
{PANELS.map((panel) => (
<TabsTrigger key={panel.value} value={panel.value}>
{LABELS[panel.value]}
</TabsTrigger>
))}
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>
);
}

Sizes

Manage your account settings.

import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
const LABELS: Record<string, string> = {
account: "Account",
password: "Password",
settings: "Settings",
};
export default function TabsSizes() {
const [size, setSize] = useState<"sm" | "default" | "lg">("lg");
return (
<div className="flex w-fit max-w-full flex-col items-start gap-5">
<Tabs defaultValue="account" size={size}>
<TabsList>
{PANELS.map((panel) => (
<TabsTrigger key={panel.value} value={panel.value}>
{LABELS[panel.value]}
</TabsTrigger>
))}
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>

With Icons

Manage your account settings.

import { useIcon } from "@/lib/icon-context";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
export default function TabsWithIcons() {
const UserIcon = useIcon("user");
const LockIcon = useIcon("lock");
const SettingsIcon = useIcon("settings");
return (
<Tabs defaultValue="account" size="lg" className="w-fit max-w-full">
<TabsList>
<TabsTrigger value="account" icon={<UserIcon />}>
Account
</TabsTrigger>
<TabsTrigger value="password" icon={<LockIcon />}>
Password
</TabsTrigger>
<TabsTrigger value="settings" icon={<SettingsIcon />}>
Settings
</TabsTrigger>
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>
);

Concentric

Manage your account settings.

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const PANELS = [
{ value: "account", copy: "Manage your account settings." },
{ value: "password", copy: "Change your password here." },
{ value: "settings", copy: "Configure your preferences." },
] as const;
const LABELS: Record<string, string> = {
account: "Account",
password: "Password",
settings: "Settings",
};
export default function TabsConcentric() {
return (
<Tabs defaultValue="account" concentric size="lg" className="w-fit max-w-full">
<TabsList>
{PANELS.map((panel) => (
<TabsTrigger key={panel.value} value={panel.value}>
{LABELS[panel.value]}
</TabsTrigger>
))}
</TabsList>
<div className="relative min-h-[24px]">
{PANELS.map((panel) => (
<TabsContent
key={panel.value}
value={panel.value}
className="absolute inset-x-0 top-0"
>
<p className="text-caption text-muted-foreground">{panel.copy}</p>
</TabsContent>
))}
</div>
</Tabs>
);
}

Override Styling

The list carries the only height and the only type size in the strip. Triggers stretch to the first and inherit the second, and the indicator insets to the list's own padding, so className="h-11 text-sm" on TabsList moves the whole control the way a size step does — size is a preset for those same values, not a separate axis.

$12 per user, billed monthly.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function TabsOverrideStyling() {
return (
// `data-radius` scopes the shape system to this subtree. The list takes
// the container radius and the triggers the element radius, so the corners
// stay nested without either being written out here.
<div data-radius="pill" className="w-full max-w-[380px]">
<Tabs defaultValue="monthly" concentric size="lg">
{/* The list's height and type size are the only ones in the strip —
the triggers stretch to the first and inherit the second. */}
<TabsList className="h-11 w-full p-1 text-sm">
<TabsTrigger value="monthly" className="flex-1">
Monthly
</TabsTrigger>
<TabsTrigger value="yearly" className="flex-1">
Yearly
</TabsTrigger>
</TabsList>
<TabsContent value="monthly" className="text-muted-foreground text-sm">
$12 per user, billed monthly.
</TabsContent>
<TabsContent value="yearly" className="text-muted-foreground text-sm">
$9 per user, billed annually. Two months free.
</TabsContent>
</Tabs>
</div>
);
}

Radius rides the shape system rather than a utility: a data-radius scope gives the list the container radius and the triggers the element radius, which differ by exactly the list's 4px padding, so concentric corners stay nested without any arithmetic at the call site.

Parts you hold no reference to are reachable by data-slot:

API Reference

Tabs

TabsList

TabsTrigger

TabsContent

A panel mounts the first time its tab becomes active, then stays mounted — hidden, not unmounted, when another tab is selected — so content backed by an API doesn't refetch and flash empty on every revisit. forceMount mounts every panel up front instead of waiting for a first visit.

TabsFromArray

The same tabs, driven by an array of TabItem objects instead of JSX — for a list that comes from a config or an API rather than being written out by hand.