ComponentsNavigation

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.

States
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.

Halcyon Bank, fintech, London. Owner Priya Shah.
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.

Orbital Freight, logistics, Rotterdam.
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>
  );
}

On this page