Tabs

A few views of one thing, one at a time, chosen from a row of tabs whose marker slides to the one chosen.

'use client';

import { Tabs } from '@/components/panelui/tabs';

const PANELS = [
  {
    value: 'overview',
    label: 'Overview',
    text: 'The autumn catalogue: 64 pages, a print run of 4,000, due at the printer on 9 November.',
  },
  {
    value: 'activity',
    label: 'Activity',
    count: 3,
    text: 'Three changes today: the cover moved to review, twelve illustrations arrived, and the print date was set.',
  },
  {
    value: 'files',
    label: 'Files',
    text: 'The brief, the fourth cover and the print schedule. Nothing has been added since Monday.',
  },
];

export default function TabsOverview() {
  return (
    <Tabs defaultValue="overview" className="w-full max-w-md">
      <Tabs.List aria-label="Catalogue">
        {PANELS.map((panel) => (
          <Tabs.Trigger key={panel.value} value={panel.value}>
            {panel.label}
            {panel.count ? <Tabs.Badge>{panel.count}</Tabs.Badge> : null}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      {PANELS.map((panel) => (
        <Tabs.Content key={panel.value} value={panel.value} className="text-sm leading-6 text-muted-foreground">
          {panel.text}
        </Tabs.Content>
      ))}
    </Tabs>
  );
}

Use it to switch between a few views of the same thing: the overview, activity and files of one project, the sections of a settings page, a range on a chart. Only the chosen view is shown, and the others are a press away.

The views have to be peers. When one step leads to the next, use a sequence of steps instead. When the views are separate pages a reader should be able to link to, use links.

Tabs works best with two to six tabs. More than fit the row scroll sideways, with the end that has more faded, so a long row stays one line.

Installation

npx shadcn@latest add https://panelui.dev/r/web/tabs.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/tabs instead of @/components/panelui/tabs. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install @base-ui/react tailwind-variants

Copy the source into components/panelui/tabs.tsx:

Usage

import { Tabs } from "@/components/panelui/tabs"
<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="activity">
      Activity <Tabs.Badge>3</Tabs.Badge>
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">…</Tabs.Content>
  <Tabs.Content value="activity">…</Tabs.Content>
</Tabs>

Composition

<Tabs>
  <Tabs.List>
    <Tabs.Trigger>
      <Tabs.Badge />
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content />
</Tabs>
  • Tabs.List — The row of tabs, and the marker under the chosen one. Give it an aria-label when nothing on the page names the tabs. It scrolls sideways when the tabs do not fit.
  • Tabs.Trigger — One tab. Its value names the Tabs.Content it shows. An icon and a Tabs.Badge can sit beside the label. disabled keeps it from being chosen.
  • Tabs.Badge — A count beside a tab's label: unread, open, waiting. It follows the tab's colour when the tab is chosen.
  • Tabs.Content — What one tab shows. Only the chosen one is in the page; keepMounted keeps the others there, hidden, for content that is expensive to build again.

Examples

Variants

segmented raises the chosen tab out of a track, for switching what a panel shows. underline is lighter, for the sections of a page; its line stretches as it moves. pills fills the chosen tab with the primary colour, for a filter that should be seen at a glance.

'use client';

import { Tabs, type TabsVariant } from '@/components/panelui/tabs';

const VARIANTS: TabsVariant[] = ['segmented', 'underline', 'pills'];
const RANGES = ['Day', 'Week', 'Month', 'Year'];

export default function TabsVariants() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      {VARIANTS.map((variant) => (
        <Tabs key={variant} variant={variant} defaultValue="Week">
          <Tabs.List aria-label={`Range, ${variant}`}>
            {RANGES.map((range) => (
              <Tabs.Trigger key={range} value={range}>
                {range}
              </Tabs.Trigger>
            ))}
          </Tabs.List>
        </Tabs>
      ))}
    </div>
  );
}

Icons and counts

An icon before the label and a Tabs.Badge after it. The count says how much is waiting behind a tab without opening it.

'use client';

import { ArchiveIcon, AtSignIcon, InboxIcon, SendIcon } from 'lucide-react';
import { Tabs } from '@/components/panelui/tabs';

export default function TabsIcons() {
  return (
    <Tabs variant="underline" defaultValue="inbox" className="w-full max-w-lg">
      <Tabs.List aria-label="Mail">
        <Tabs.Trigger value="inbox">
          <InboxIcon /> Inbox <Tabs.Badge>14</Tabs.Badge>
        </Tabs.Trigger>
        <Tabs.Trigger value="mentions">
          <AtSignIcon /> Mentions <Tabs.Badge>2</Tabs.Badge>
        </Tabs.Trigger>
        <Tabs.Trigger value="sent">
          <SendIcon /> Sent
        </Tabs.Trigger>
        <Tabs.Trigger value="archive">
          <ArchiveIcon /> Archive
        </Tabs.Trigger>
      </Tabs.List>
      <Tabs.Content value="inbox" className="text-sm text-muted-foreground">
        Fourteen unread, the oldest from Monday.
      </Tabs.Content>
      <Tabs.Content value="mentions" className="text-sm text-muted-foreground">
        Two threads mention you.
      </Tabs.Content>
      <Tabs.Content value="sent" className="text-sm text-muted-foreground">
        Last sent yesterday at 18:12.
      </Tabs.Content>
      <Tabs.Content value="archive" className="text-sm text-muted-foreground">
        1,208 archived conversations.
      </Tabs.Content>
    </Tabs>
  );
}

Vertical

orientation="vertical" puts the tabs in a column beside the content, for a settings page with more sections than fit across. The up and down arrow keys move between them.

'use client';

import { BellIcon, CreditCardIcon, ShieldIcon, UserRoundIcon } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { Tabs } from '@/components/panelui/tabs';

export default function TabsVertical() {
  return (
    <Tabs variant="underline" orientation="vertical" defaultValue="profile" className="w-full max-w-xl gap-6">
      <Tabs.List aria-label="Settings">
        <Tabs.Trigger value="profile">
          <UserRoundIcon /> Profile
        </Tabs.Trigger>
        <Tabs.Trigger value="alerts">
          <BellIcon /> Notifications
        </Tabs.Trigger>
        <Tabs.Trigger value="security">
          <ShieldIcon /> Security
        </Tabs.Trigger>
        <Tabs.Trigger value="billing">
          <CreditCardIcon /> Billing
        </Tabs.Trigger>
      </Tabs.List>
      <Tabs.Content value="profile" className="flex flex-col gap-2">
        <Label htmlFor="tabs-name">Display name</Label>
        <Input id="tabs-name" defaultValue="Ines Duarte" />
      </Tabs.Content>
      <Tabs.Content value="alerts" className="flex flex-col gap-4">
        <Label>
          <Label.Text>Comments on my work</Label.Text>
          <Label.Action>
            <Switch defaultChecked />
          </Label.Action>
        </Label>
        <Label>
          <Label.Text>Weekly summary</Label.Text>
          <Label.Action>
            <Switch />
          </Label.Action>
        </Label>
      </Tabs.Content>
      <Tabs.Content value="security" className="flex flex-col gap-2">
        <Label htmlFor="tabs-password">
          <Label.Text>Password</Label.Text>
          <Label.Hint>Changed 3 months ago</Label.Hint>
        </Label>
        <Input id="tabs-password" type="password" defaultValue="hunter2hunter2" />
      </Tabs.Content>
      <Tabs.Content value="billing" className="text-sm text-muted-foreground">
        Team plan, renews 1 December.
      </Tabs.Content>
    </Tabs>
  );
}

More tabs than fit

The row scrolls sideways, with a wheel, a trackpad or a swipe, and fades at the end that has more.

'use client';

import { Tabs } from '@/components/panelui/tabs';

const STATIONS = [
  'Lisbon',
  'Porto',
  'Coimbra',
  'Aveiro',
  'Braga',
  'Faro',
  'Évora',
  'Setúbal',
  'Viseu',
  'Guimarães',
  'Leiria',
  'Funchal',
];

export default function TabsOverflow() {
  return (
    <Tabs variant="pills" defaultValue="Lisbon" className="w-full max-w-sm">
      <Tabs.List aria-label="Station">
        {STATIONS.map((station) => (
          <Tabs.Trigger key={station} value={station}>
            {station}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      {STATIONS.map((station, index) => (
        <Tabs.Content key={station} value={station} className="text-sm text-muted-foreground">
          {station}: next departure in {(index * 7) % 23 + 2} minutes.
        </Tabs.Content>
      ))}
    </Tabs>
  );
}

Held by you

Pass value and onValueChange to hold the chosen tab yourself, here so the Back and Next buttons can move between the same views as the tabs.

'use client';

import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { Input } from '@/components/panelui/input';
import { Label } from '@/components/panelui/label';
import { Tabs } from '@/components/panelui/tabs';

const STEPS = ['Basket', 'Delivery', 'Payment'];

export default function TabsControlled() {
  const [step, setStep] = useState('Basket');
  const index = STEPS.indexOf(step);

  return (
    <Tabs value={step} onValueChange={(next) => setStep(next as string)} className="w-full max-w-sm">
      <Tabs.List aria-label="Checkout">
        {STEPS.map((name, at) => (
          <Tabs.Trigger key={name} value={name}>
            {at + 1}. {name}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      <Tabs.Content value="Basket" className="text-sm text-muted-foreground">
        Three items, £42.80.
      </Tabs.Content>
      <Tabs.Content value="Delivery" className="flex flex-col gap-2">
        <Label htmlFor="tabs-postcode" required>
          Postcode
        </Label>
        <Input id="tabs-postcode" placeholder="E8 3RL" required />
      </Tabs.Content>
      <Tabs.Content value="Payment" className="text-sm text-muted-foreground">
        Paid with the card ending 4417.
      </Tabs.Content>
      <div className="flex justify-between">
        <Button variant="outline" size="sm" disabled={index === 0} onClick={() => setStep(STEPS[index - 1]!)}>
          Back
        </Button>
        <Button size="sm" disabled={index === STEPS.length - 1} onClick={() => setStep(STEPS[index + 1]!)}>
          Next
        </Button>
      </div>
    </Tabs>
  );
}

API Reference

Tabs

PropTypeDefaultDescription
variantTabsVariantsegmentedHow the tabs are drawn. Default segmented.
classNamestring—

Tabs.List

PropTypeDefaultDescription
classNamestring—

Tabs.Trigger

PropTypeDefaultDescription
classNamestring—

Tabs.Content

PropTypeDefaultDescription
classNamestring—

Every part also takes the props of the element it renders, and a className.

Notes

Keyboard

The arrow keys move focus between tabs, Home and End go to the first and last, and Enter or Space chooses the one focused. Pass activateOnFocus to Tabs.List to choose a tab as soon as it is reached.

Reduced motion

With reduced motion turned on, the marker and the new view appear in place without sliding.

Built on

The tabs are the Base UI tabs, which the package depends on directly.

Server Components

Tabs is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Tabs.List off the import, so each part is also exported by name: TabsList is Tabs.List, and so on for the others.

import { Tabs, TabsList, TabsTrigger, TabsBadge, TabsContent } from "@/components/panelui/tabs"

On this page