Tabs
Switch between views of the same thing: a record's sections, a page's lists. One style everywhere, an ink underline under the current tab.
Playground
Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.
import { Tabs } from '@/ui/tabs';<Tabs />Record tabs
Halcyon Bank's record: 36px tabs on a hairline, subtle text, ink text and a 2px ink underline on the current tab. Counts are badges after the label.
import { Badge } from '@/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs';
export default function TabsDefault() {
return (
<Tabs defaultValue="overview" className="w-full max-w-160">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="applications">
Applications <Badge>14</Badge>
</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="notes">
Notes <Badge>3</Badge>
</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
</TabsList>
<TabsContent value="overview">Halcyon Bank, fintech, London. Owner Priya Shah.</TabsContent>
<TabsContent value="applications">14 Applications across 3 Jobs.</TabsContent>
<TabsContent value="activity">Sara Lund replied to the shortlist email.</TabsContent>
<TabsContent value="notes">3 Notes, the latest from Tom Becker.</TabsContent>
<TabsContent value="files">No files yet.</TabsContent>
</Tabs>
);
}Disabled
A disabled tab fades to 50% and the arrow keys skip it. Prefer hiding a tab that isn't built.
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/tabs';
export default function TabsDisabled() {
return (
<Tabs defaultValue="overview" className="w-full max-w-160">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="invoices" disabled>
Invoices
</TabsTrigger>
</TabsList>
<TabsContent value="overview">Orbital Freight, logistics, Rotterdam.</TabsContent>
<TabsContent value="activity">James Reed booked an interview for Thursday.</TabsContent>
</Tabs>
);
}Page header tabs
The same tab as a link with href, so each view has its own URL and back and forward work. The current one carries aria-current="page".
import { Badge } from '@/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/ui/tabs';
// Each tab is a link with its own URL. In the app, `value` comes from the current path.
export default function TabsHeaderTabs() {
return (
<Tabs defaultValue="candidates" className="w-full max-w-160">
<TabsList>
<TabsTrigger value="overview" href="?job=overview">
Overview
</TabsTrigger>
<TabsTrigger value="candidates" href="?job=candidates">
Candidates <Badge>24</Badge>
</TabsTrigger>
<TabsTrigger value="activity" href="?job=activity">
Activity
</TabsTrigger>
<TabsTrigger value="files" href="?job=files">
Files
</TabsTrigger>
</TabsList>
</Tabs>
);
}Counts
A big count caps at 99+ and screen readers still hear the real number. A tab with nothing in it shows no badge.
import { Badge } from '@/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/ui/tabs';
// A big count caps at 99+ (screen readers hear the real number). A tab with nothing in it shows no badge.
export default function TabsCounts() {
return (
<Tabs defaultValue="applications" className="w-full max-w-160">
<TabsList>
<TabsTrigger value="applications">
Applications <Badge>{1234567}</Badge>
</TabsTrigger>
<TabsTrigger value="notes">
Notes <Badge>{0}</Badge>
</TabsTrigger>
<TabsTrigger value="files">
Files <Badge>{1}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
);
}Phone width
When the tabs don't fit, the row scrolls sideways inside itself with no scrollbar, and the current tab scrolls into view.
import { Badge } from '@/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/ui/tabs';
// At 260px the row scrolls sideways inside itself, and the current tab scrolls into view.
export default function TabsPhoneWidth() {
return (
<Tabs defaultValue="notes" className="w-65">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="applications">
Applications <Badge>14</Badge>
</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="notes">Notes</TabsTrigger>
</TabsList>
</Tabs>
);
}Motion
Picking a tab changes the text and underline colour with the state change pattern. Press Replay to watch it.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/ui/tabs';
const tabs = ['Overview', 'Applications', 'Activity'];
// Replay moves the current tab along the row: the text and the underline change colour in 160ms.
export default function TabsMotion() {
const [current, setCurrent] = useState('Overview');
const replay = () => {
tabs.forEach((tab, i) => setTimeout(() => setCurrent(tab), (i + 1) * 500));
};
return (
<div className="flex w-full max-w-160 flex-col items-start gap-300">
<Tabs value={current} onValueChange={setCurrent} className="w-full">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{tab}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Button size="sm" onClick={replay}>
Replay
</Button>
</div>
);
}Skip link
The first thing Tab reaches on every page. It jumps past the sidebar to the page's content, and stays hidden until it has focus.
Workspace and account menu
The Agency name at the top of the sidebar. It opens one menu: who you are signed in as, the Agencies you can switch to, settings, bug reports and sign out.