Tabs
Switch between views of the same object, as a track or as a line.
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
scrollable does all three.The Quiet Indigo Rule
Anatomy#
- 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. - 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. - 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.
- Count. Optional. A 12px Slate Meta figure in tabular numerals after the label, written inline in the trigger.
- Panel. The
tabpanelfor the selected tab. Hidden panels unmount unlesskeepMountedis 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.
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.
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.
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.
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.
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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Label at 60% ink (Slate Meta in dark) on the track or plane. |
| Hover | The label goes to full ink over 150ms. The track and underline don't change. |
| Focus visible | An 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. |
| Selected | Full 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. |
| Disabled | 50% 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. |
| Overflowing | With 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 withvalueandonValueChange. Base UI's owndefaultValueis0, 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
activateOnFocusis 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
keepMountedto theTabsContent. scrollablemakes 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"onTabsputs 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-backgrounddiv, as record pages do, and keep the panels outside it.
Do and don't#
scrollable so the clipped edge fades and the selected tab stays in view.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"witharia-selectedandaria-controls, androle="tabpanel"witharia-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 (
tabIndex0), 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.
| Keys | Action |
|---|---|
| Tab | Moves 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. |
| Home | Moves focus to the first tab. |
| End | Moves focus to the last tab. |
| Enter | Selects the focused tab and shows its panel. |
| Space | Selects the focused tab and shows its panel. |
Design tokens#
| Token | Used for |
|---|---|
--muted | The default track fill |
--background | The raised active pill on the track (light) |
--foreground | Tab labels at 60% and 100%; the 2px line underline |
--muted-foreground | The list color and resting labels in dark |
--input | Active pill border and 30% fill (dark) |
--ring | Focus border, outline and 3px ring at 50% |
shadow-sm | Lift under the active pill |
--radius-lg | 10px track corners |
--radius-md | 8px 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | Tabs.Tab.Value | 0 | The initially selected tab when uncontrolled. Pass it whenever tab values are strings. |
value | Tabs.Tab.Value | No default | The selected tab when controlled. |
onValueChange | (value: Tabs.Tab.Value, eventDetails) => void | No default | Called 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. |
className | string | No default | Merged 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "line" | "default" | default is the Well Gray track with a raised pill. line has no track and a 2px ink underline. |
scrollable | boolean | false | Scrolls sideways instead of overflowing, fades the clipped edge over 2rem and keeps the selected tab in view. |
activateOnFocus | boolean | false | Selects a tab as soon as arrow keys focus it, instead of on Enter or Space. |
loopFocus | boolean | true | Wraps arrow-key focus from the last tab to the first. |
className | string | No default | Merged 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | Tabs.Tab.Value | No default | Matches the value of its TabsContent. |
disabled | boolean | false | Dims the tab to 50% and prevents selecting it. |
className | string | No default | Merged 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>).
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | Tabs.Tab.Value | No default | The tab this panel belongs to. |
keepMounted | boolean | false | Keeps the panel in the DOM while hidden, so local state survives a switch. |
className | string | No default | Merged 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.