Skip to content

Tabs

Switch between views of the same object, as a track or as a line.

Status
Stable
Category
Navigation
Adoption
Not used yet
import { Tabs } from "@oration/canon/components/tabs";
packages/canon/src/components/tabs.tsx

Northwind Freight

Supplier since March 2021, paid by ACH on net 30

  • INV-20931Sep 21$18,240.00
  • INV-20874Sep 14$9,612.50
  • INV-20802Sep 7$22,005.10
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function Hero() {    return (        <div className="w-full max-w-2xl overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex flex-col gap-1 px-5 pt-4 pb-3">                <p className="text-base leading-none font-semibold text-foreground">                    Northwind Freight                </p>                <p className="text-13 text-muted-foreground">                    Supplier since March 2021, paid by ACH on net 30                </p>            </div>            <Tabs defaultValue="invoices" className="gap-0">                <div className="border-b border-border px-5">                    <TabsList variant="line" scrollable className="h-10 gap-3">                        <TabsTrigger value="overview" className="px-0">                            Overview                        </TabsTrigger>                        <TabsTrigger value="activity" className="px-0">                            Activity                        </TabsTrigger>                        <TabsTrigger value="invoices" className="px-0">                            Invoices                            <span className="text-xs font-normal text-muted-foreground tabular-nums">                                38                            </span>                        </TabsTrigger>                        <TabsTrigger value="remittances" className="px-0">                            Remittances                            <span className="text-xs font-normal text-muted-foreground tabular-nums">                                12                            </span>                        </TabsTrigger>                        <TabsTrigger value="contacts" className="px-0">                            Contacts                            <span className="text-xs font-normal text-muted-foreground tabular-nums">                                4                            </span>                        </TabsTrigger>                        <TabsTrigger value="files" className="px-0">                            Files                            <span className="text-xs font-normal text-muted-foreground tabular-nums">                                7                            </span>                        </TabsTrigger>                    </TabsList>                </div>                <TabsContent value="overview" className="px-5 py-4 text-13">                    Ships dry freight for the Orchard Street and Halcyon                    warehouses. Owned by Priya Raman.                </TabsContent>                <TabsContent value="activity" className="px-5 py-4 text-13">                    Remittance for INV-20931 sent by Jordan Lee on Friday, Sep                    25.                </TabsContent>                <TabsContent value="invoices" className="px-5 py-2">                    <ul className="flex flex-col text-13">                        {[                            ["INV-20931", "Sep 21", "$18,240.00"],                            ["INV-20874", "Sep 14", "$9,612.50"],                            ["INV-20802", "Sep 7", "$22,005.10"],                        ].map(([id, date, amount]) => (                            <li                                key={id}                                className="flex h-9 items-center gap-3 border-b border-border last:border-b-0"                            >                                <span className="font-mono text-xs">{id}</span>                                <span className="text-muted-foreground">                                    {date}                                </span>                                <span className="ml-auto font-medium tabular-nums">                                    {amount}                                </span>                            </li>                        ))}                    </ul>                </TabsContent>                <TabsContent value="remittances" className="px-5 py-4 text-13">                    12 remittances this year, all matched to their invoices.                </TabsContent>                <TabsContent value="contacts" className="px-5 py-4 text-13">                    Tomás Ferreira is the billing contact.                </TabsContent>                <TabsContent value="files" className="px-5 py-4 text-13">                    W-9 on file, signed January 12, 2026.                </TabsContent>            </Tabs>        </div>    );}

Usage#

Tabs switch between views of the same object without leaving the page: a supplier's overview, activity and invoices, or a payment run's summary and exceptions. Canon has two looks. The default is a 32px Well Gray track with a raised white pill, for small groups inside a card, dialog or toolbar. The line variant has no track and marks the active tab with a 2px ink underline; record, agent, report and sequence pages use it, scrolling sideways with a faded edge. The common mistake is using tabs to change a setting or filter a list. Tabs pick which panel you are looking at; they never change data.

When to use

  • To split one record into views that don't need to be seen together: Overview, Activity, Invoices, Files.
  • For the sticky tab bar under a record, agent, report or sequence header, as the line variant with scrollable.
  • For two to four sibling panels inside a card, sheet or dialog, as the default track.
  • When each panel is substantial enough to deserve its own heading, and switching doesn't lose work.

When not to use

  • To choose a mode or filter that changes what a single view shows, such as Day, Week and Month. Use Segmented control
  • For the local navigation of a settings or master and detail page with many items and metadata. Use Side tabs
  • To move between steps of a flow that must be done in order. Use Stepper
  • For sections a reader may want open at the same time. Use Accordion
  • For switching between whole pages or routes. Use links in the sidebar or a breadcrumb switcher so the URL and history stay honest. Use Navigation and wayfinding

The Scroll Edge Rule

Clipped content shows its edge. A horizontal tab row that overflows scrolls instead of wrapping, fades the clipped side over 2rem, and keeps the selected tab in view. scrollable does all three.

The Quiet Indigo Rule

The active tab is drawn in ink: a white pill on the track or a 2px Graphite Ink underline. Indigo is only its focus ring, never its fill, text or underline.

Anatomy#

212 invoices to 48 suppliers, $1.84M in total.
  1. List. The tablist. In the default variant it is the track: 32px tall, Well Gray, 10px corners and a 3px inset. The line variant has no fill and a 4px gap between tabs.
  2. Tab. A button with the Control type (14px, medium) at 60% ink, 8px corners and flex-1, so tabs share the track evenly. Icons are 16px.
  3. Active tab. Full ink. On the track it is a raised White Plane pill with a small shadow; in the line variant a 2px ink underline fades in beneath it.
  4. Count. Optional. A 12px Slate Meta figure in tabular numerals after the label, written inline in the trigger.
  5. Panel. The tabpanel for the selected tab. Hidden panels unmount unless keepMounted is set.

Examples#

Track

The default variant: a 32px Well Gray track with a raised white pill. Use it for two to four panels inside a card, sheet or dialog.

Friday, Oct 2 at 2:00 PM CT. 212 invoices to 48 suppliers.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function Track() {    return (        <Tabs defaultValue="summary" className="w-full max-w-md gap-3">            <TabsList>                <TabsTrigger value="summary">Summary</TabsTrigger>                <TabsTrigger value="invoices">Invoices</TabsTrigger>                <TabsTrigger value="exceptions">Exceptions</TabsTrigger>            </TabsList>            <TabsContent                value="summary"                className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"            >                Friday, Oct 2 at 2:00 PM CT. 212 invoices to 48 suppliers.            </TabsContent>            <TabsContent                value="invoices"                className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"            >                $1,842,310.40 across 212 invoices, the largest to Halcyon.            </TabsContent>            <TabsContent                value="exceptions"                className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"            >                3 invoices are on hold until their W-9s are on file.            </TabsContent>        </Tabs>    );}

Line

No track; the active tab gets a 2px ink underline. Put a hairline under the list so the underline sits on it, and use flex-none so tabs hug their labels.

Aisha Bello asked why INV-20874 was short paid by $412.50.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function Line() {    return (        <Tabs defaultValue="conversation" className="w-full max-w-lg gap-4">            <TabsList                variant="line"                className="w-full justify-start border-b border-border pb-1"            >                <TabsTrigger value="conversation" className="flex-none px-2">                    Conversation                </TabsTrigger>                <TabsTrigger value="activity" className="flex-none px-2">                    Activity                </TabsTrigger>                <TabsTrigger value="details" className="flex-none px-2">                    Details                </TabsTrigger>            </TabsList>            <TabsContent value="conversation" className="text-13">                Aisha Bello asked why INV-20874 was short paid by $412.50.            </TabsContent>            <TabsContent value="activity" className="text-13">                Wen Zhou changed the priority to High on Monday, Sep 28.            </TabsContent>            <TabsContent value="details" className="text-13">                Opened by email from billing@northwindfreight.com.            </TabsContent>        </Tabs>    );}

Icons and counts

Mark a leading icon with data-icon="inline-start" so the padding tightens. Counts follow the label as a 12px Slate Meta figure in tabular numerals.

38 invoices from Northwind Freight this year.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { ActivityIcon, FileTextIcon, ReceiptIcon } from "lucide-react";export function WithIcons() {    return (        <Tabs defaultValue="invoices" className="gap-3">            <TabsList>                <TabsTrigger value="invoices">                    <ReceiptIcon data-icon="inline-start" aria-hidden="true" />                    Invoices                </TabsTrigger>                <TabsTrigger value="activity">                    <ActivityIcon data-icon="inline-start" aria-hidden="true" />                    Activity                </TabsTrigger>                <TabsTrigger value="files">                    <FileTextIcon data-icon="inline-start" aria-hidden="true" />                    Files                    <span className="text-xs font-normal text-muted-foreground tabular-nums">                        7                    </span>                </TabsTrigger>            </TabsList>            <TabsContent                value="invoices"                className="text-13 text-muted-foreground"            >                38 invoices from Northwind Freight this year.            </TabsContent>            <TabsContent                value="activity"                className="text-13 text-muted-foreground"            >                Last remittance sent Friday, Sep 25.            </TabsContent>            <TabsContent                value="files"                className="text-13 text-muted-foreground"            >                W-9, master services agreement and five delivery receipts.            </TabsContent>        </Tabs>    );}

Scrollable with overflow fade

scrollable lets a long row scroll instead of wrapping. The clipped side fades over 2rem and the selected tab, here Payment runs, is scrolled into view on mount.

Payment runs for Halcyon.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function Scrollable() {    const tabs = [        "Overview",        "Activity",        "Invoices",        "Remittances",        "Payment runs",        "Contacts",        "Files",        "Notes",    ];    return (        <div className="w-full max-w-xs rounded-xl bg-card shadow-border">            <Tabs defaultValue="Payment runs" className="gap-0">                <div className="border-b border-border px-4">                    <TabsList variant="line" scrollable className="h-10 gap-3">                        {tabs.map((tab) => (                            <TabsTrigger key={tab} value={tab} className="px-0">                                {tab}                            </TabsTrigger>                        ))}                    </TabsList>                </div>                {tabs.map((tab) => (                    <TabsContent                        key={tab}                        value={tab}                        className="px-4 py-3 text-13 text-muted-foreground"                    >                        {tab} for Halcyon.                    </TabsContent>                ))}            </Tabs>        </div>    );}

Vertical

orientation="vertical" puts the list beside its panel, with Up and Down arrow keys. The line variant moves its underline to a bar on the right edge. For settings navigation with many items, use Side tabs.

Settles in one business day. No fee.
Settles in one business day. No fee.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function Vertical() {    return (        <div className="grid w-full max-w-2xl gap-8 sm:grid-cols-2">            <Tabs defaultValue="ach" orientation="vertical" className="gap-4">                <TabsList className="w-32">                    <TabsTrigger value="ach">ACH</TabsTrigger>                    <TabsTrigger value="wire">Wire</TabsTrigger>                    <TabsTrigger value="check">Check</TabsTrigger>                </TabsList>                <TabsContent value="ach" className="text-13">                    Settles in one business day. No fee.                </TabsContent>                <TabsContent value="wire" className="text-13">                    Same day before 3:00 PM CT. $15 per payment.                </TabsContent>                <TabsContent value="check" className="text-13">                    Mailed from Orchard Street in five to seven days.                </TabsContent>            </Tabs>            <Tabs defaultValue="ach" orientation="vertical" className="gap-4">                <TabsList variant="line" className="w-32">                    <TabsTrigger value="ach">ACH</TabsTrigger>                    <TabsTrigger value="wire">Wire</TabsTrigger>                    <TabsTrigger value="check">Check</TabsTrigger>                </TabsList>                <TabsContent value="ach" className="text-13">                    Settles in one business day. No fee.                </TabsContent>                <TabsContent value="wire" className="text-13">                    Same day before 3:00 PM CT. $15 per payment.                </TabsContent>                <TabsContent value="check" className="text-13">                    Mailed from Orchard Street in five to seven days.                </TabsContent>            </Tabs>        </div>    );}

Controlled

Pass value and onValueChange when something outside the tabs needs to select one, such as a notice that jumps to the exceptions.

3 invoices are on hold
212 invoices to 48 suppliers on Friday, Oct 2.
import { Button } from "@oration/canon/components/button";import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { TriangleAlertIcon } from "lucide-react";import * as React from "react";export function Controlled() {    const [tab, setTab] = React.useState("summary");    return (        <div className="flex w-full max-w-md flex-col gap-3">            <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2">                <span className="flex min-w-0 items-center gap-2 text-13">                    <TriangleAlertIcon                        aria-hidden="true"                        className="size-4 shrink-0 text-warning"                    />                    3 invoices are on hold                </span>                <Button                    type="button"                    variant="outline"                    size="sm"                    onClick={() => setTab("exceptions")}                >                    Review                </Button>            </div>            <Tabs                value={tab}                onValueChange={(value) => setTab(String(value))}                className="gap-3"            >                <TabsList>                    <TabsTrigger value="summary">Summary</TabsTrigger>                    <TabsTrigger value="invoices">Invoices</TabsTrigger>                    <TabsTrigger value="exceptions">Exceptions</TabsTrigger>                </TabsList>                <TabsContent value="summary" className="text-13">                    212 invoices to 48 suppliers on Friday, Oct 2.                </TabsContent>                <TabsContent value="invoices" className="text-13">                    $1,842,310.40 in total.                </TabsContent>                <TabsContent value="exceptions" className="text-13">                    Orchard Street, Halcyon and Northwind Freight are missing                    W-9s.                </TabsContent>            </Tabs>        </div>    );}

Disabled tab

A disabled tab dims to 50% and can't be selected. Say in the open panel why it is unavailable and when it will be.

Results appear after the run is sent on Friday, Oct 2.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function Disabled() {    return (        <Tabs defaultValue="draft" className="w-full max-w-md gap-3">            <TabsList>                <TabsTrigger value="draft">Draft</TabsTrigger>                <TabsTrigger value="approvals">Approvals</TabsTrigger>                <TabsTrigger value="results" disabled>                    Results                </TabsTrigger>            </TabsList>            <TabsContent                value="draft"                className="text-13 text-muted-foreground"            >                Results appear after the run is sent on Friday, Oct 2.            </TabsContent>            <TabsContent                value="approvals"                className="text-13 text-muted-foreground"            >                Maya Okafor approves runs over $1M.            </TabsContent>        </Tabs>    );}

States#

Track
Line
import { Tabs, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() {    const variants = ["default", "line"] as const;    const forced = {        Rest: "",        Hover: "text-foreground dark:text-foreground",        Focus: "border-ring ring-[3px] ring-ring/50 outline-1 outline-ring",        Selected: "",        Disabled: "",    };    return (        <div className="flex w-full flex-col gap-5 overflow-x-auto" inert>            {variants.map((variant) => (                <div key={variant} className="flex items-center gap-4">                    <span className="w-12 shrink-0 text-13 text-muted-foreground">                        {variant === "default" ? "Track" : "Line"}                    </span>                    <Tabs value="Selected">                        <TabsList variant={variant}>                            {Object.entries(forced).map(                                ([state, className]) => (                                    <TabsTrigger                                        key={state}                                        value={state}                                        disabled={state === "Disabled"}                                        className={cn(                                            "flex-none px-2.5",                                            className,                                        )}                                    >                                        {state}                                    </TabsTrigger>                                ),                            )}                        </TabsList>                    </Tabs>                </div>            ))}        </div>    );}
States
StateTreatment
RestLabel at 60% ink (Slate Meta in dark) on the track or plane.
HoverThe label goes to full ink over 150ms. The track and underline don't change.
Focus visibleAn indigo border, a 3px ring at 50% and a 1px indigo outline. In a scrollable list the ring is inset so the scroller doesn't clip it.
SelectedFull ink. The track variant raises a White Plane pill with a small shadow (a 1px input border and 30% input fill in dark). The line variant fades in a 2px ink underline.
Disabled50% opacity and no pointer events. It stays in arrow-key order but can't be selected. Say why it's unavailable in the panel or beside the list.
OverflowingWith scrollable, a 2rem fade on whichever edge is clipped: the end only at the start, both in the middle, the start only at the end.

Behavior#

  • Built on Base UI Tabs. Uncontrolled with defaultValue, or controlled with value and onValueChange. Base UI's own defaultValue is 0, so tabs with string values must pass one or nothing starts selected.
  • Click, Enter or Space selects a tab. Arrow keys only move focus unless activateOnFocus is set on the list, and they wrap at the ends (loopFocus).
  • Hidden panels unmount by default, so a draft typed into one is lost when you switch. Lift that state up or pass keepMounted to the TabsContent.
  • scrollable makes the list scroll sideways with a hidden scrollbar, fades the clipped edge over 2rem, and scrolls the selected tab into view with a 24px margin: instantly on mount, smoothly on change, and instantly again under reduced motion.
  • orientation="vertical" on Tabs puts the list beside the panel. Tabs fill the list width and left-align, the line underline becomes a 2px bar on the right edge, and Up and Down replace Left and Right.
  • Label color and the active fill transition over 150ms; the line underline fades rather than slides.
  • For a record tab bar that sticks under the header, wrap the list in a sticky top-0 z-10 border-b border-border bg-background div, as record pages do, and keep the panels outside it.

Do and don't#

Do. Switch the range or mode of a single view with a segmented control, and keep tabs for separate panels.
Don't. Use tabs for Day, Week and Month. No panel changes; the same chart re-filters, which is a segmented control's job.
Do. Let a long row of record tabs scroll with scrollable so the clipped edge fades and the selected tab stays in view.
Don't. Let tabs wrap onto a second line. Two rows of tabs read as two groups, and the active underline loses its baseline.
Do. Keep labels to one or two nouns, and put a count after the label in Slate Meta.
Don't. Write sentences, verbs or counts in brackets. Tab labels are names of places, not actions.

Content#

  • Nouns, sentence case, one or two words: Overview, Buying committee, Remittances.
  • Counts follow the label as a plain 12px figure: Invoices 38, not Invoices (38).
  • Keep the same tab names and order across every record of a type, so muscle memory carries over from one supplier to the next.
  • Don't repeat the object in each label: on a supplier record it's Invoices, not Supplier invoices.

Accessibility#

  • Base UI renders role="tablist", role="tab" with aria-selected and aria-controls, and role="tabpanel" with aria-labelledby, so each panel is named by its tab.
  • Roving focus: only the selected tab is in the tab order; arrow keys move between the others.
  • Icon-only tabs are not allowed. Every tab needs visible text, and icons are aria-hidden.
  • A count read on its own is ambiguous. Keep it inside the trigger so it is announced with the label, as in Invoices 38.
  • The open panel is focusable (tabIndex 0), so keyboard users can reach content that has no controls of its own.
  • Motion is color and opacity only, and the scroll-into-view jump is instant under reduced motion.
Keyboard interactions
KeysAction
TabMoves focus to the selected tab, then on into the open panel.
←→Moves focus to the previous or next tab, wrapping at the ends. Up and Down in a vertical list.
HomeMoves focus to the first tab.
EndMoves focus to the last tab.
EnterSelects the focused tab and shows its panel.
SpaceSelects the focused tab and shows its panel.

Design tokens#

Design tokens
TokenUsed for
--mutedThe default track fill
--backgroundThe raised active pill on the track (light)
--foregroundTab labels at 60% and 100%; the 2px line underline
--muted-foregroundThe list color and resting labels in dark
--inputActive pill border and 30% fill (dark)
--ringFocus border, outline and 3px ring at 50%
shadow-smLift under the active pill
--radius-lg10px track corners
--radius-md8px tab corners
[data-overflow-fade]The 2rem mask on clipped edges of a scrollable list

API reference#

Tabs

The root. Holds the selected value and orientation.

Other props spread onto Base UI Tabs.Root (<div>).

Props of Tabs
PropTypeDefaultDescription
defaultValueTabs.Tab.Value0The initially selected tab when uncontrolled. Pass it whenever tab values are strings.
valueTabs.Tab.ValueNo defaultThe selected tab when controlled.
onValueChange(value: Tabs.Tab.Value, eventDetails) => voidNo defaultCalled when a tab is selected.
orientation"horizontal" | "vertical""horizontal"Stacks the list above the panel, or puts it beside the panel with Up and Down arrow keys.
classNamestringNo defaultMerged after the defaults (flex gap-2, and flex-col when horizontal). Record pages pass gap-0.

TabsList

The tablist, drawn as a track or as a bare line. Also exported: tabsListVariants.

Other props spread onto Base UI Tabs.List (<div>).

Props of TabsList
PropTypeDefaultDescription
variant"default" | "line""default"default is the Well Gray track with a raised pill. line has no track and a 2px ink underline.
scrollablebooleanfalseScrolls sideways instead of overflowing, fades the clipped edge over 2rem and keeps the selected tab in view.
activateOnFocusbooleanfalseSelects a tab as soon as arrow keys focus it, instead of on Enter or Space.
loopFocusbooleantrueWraps arrow-key focus from the last tab to the first.
classNamestringNo defaultMerged last. Record tab bars pass a height (h-9 or h-10) and a gap.

TabsTrigger

One tab.

Other props spread onto Base UI Tabs.Tab (<button>).

Props of TabsTrigger
PropTypeDefaultDescription
valueRequiredTabs.Tab.ValueNo defaultMatches the value of its TabsContent.
disabledbooleanfalseDims the tab to 50% and prevents selecting it.
classNamestringNo defaultMerged last. Line tabs on record pages pass px-0 so labels sit on the page gutter.

TabsContent

The panel for one tab.

Other props spread onto Base UI Tabs.Panel (<div>).

Props of TabsContent
PropTypeDefaultDescription
valueRequiredTabs.Tab.ValueNo defaultThe tab this panel belongs to.
keepMountedbooleanfalseKeeps the panel in the DOM while hidden, so local state survives a switch.
classNamestringNo defaultMerged after flex-1 text-sm outline-none.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

TabsContent sets outline-none, but Base UI makes the open panel focusable, so tabbing from the list into a panel draws no focus indicator. Until that changes, add a focus-visible ring to panels that have no controls of their own.

scrollable removes the track's 3px inset and stretches tabs to full height, so it only suits the line variant. The tool action editor needs a scrolling default track and hand-rolls overflow-x-auto on it instead, without the edge fade.

There is no count slot. Every record tab bar repeats a hand-styled text-xs text-muted-foreground tabular-nums span inside its triggers, with small drifts in weight between pages.

The Agents reports index renders a Tabs root with a list and no panels, and switches the content outside it. Tabs that control no tabpanel are a view switch, which is the Segmented control's job.