Tabs
Peer views of one object. Not a wizard, not a filter, and not navigation between unrelated destinations — those are the three things tabs are constantly misused for.
Also called Tab List, Tab Panel, Tabbed Interface — in this system all of them are Tabs.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Overview
Panel content for overview. Tab moves focus straight in here — it does not step through the remaining tabs.
Three variants
Underline for page-level sections, pill for switching a view inside a card, enclosed when the panel needs a visible container of its own.
The three misuses
Each of these is a real pattern seen in production, and each has a component that does the job better.
As a wizard
Step 1 · Step 2 · Step 3
Tabs imply free movement. A wizard has prerequisites and a direction.
As a filter
All · Active · Archived
The subject changes between tabs. That is a filter, and filters should be chips.
As navigation
Dashboard · Billing · Team
Separate destinations need URLs, titles and browser history.
Too many tabs
Past about seven, tabs stop being scannable. Scrolling hides destinations; a sidebar or a select shows them all.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
The active underline breaks the container border, connecting the tab to its panel.
Underline variant. The 2px indicator sits on the 1px container border, so the active tab and the panel below read as one surface.
- Tab height36px (md), 32px (sm)
The same as a button, so a tab row aligns with any controls sitting beside it.
- Indicator2px, on the border line
The tab is pulled down 1px so the indicator overlaps the container border. That overlap is what visually joins the tab to its panel.
- Label colourmuted → fg
Colour changes, weight does not. Changing the weight reflows the whole row on every switch.
- Gap4px between tabs
Tight, because the tabs are one group. The padding inside each tab does the separating.
- Count badge18px, neutral or accent
Accent on the active tab, neutral elsewhere. A count on every tab in bright accent is a row of competing signals.
- Panel gap20px below the tabs
Enough that the panel is clearly separate content, close enough that it is clearly the tab’s content.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-accent | — | Active underline |
| --ds-fg | — | Active label |
| --ds-fg-muted | — | Inactive label |
| --ds-border-subtle | — | The container line the indicator sits on |
| --ds-surface-raised | — | Active pill background |
| --ds-layer-hover | — | Hover wash on pill and enclosed variants |
Spacing
| Token | Value | Used for |
|---|---|---|
| tab padding | Horizontal padding | |
| gap | Between tabs | |
| panel gap | Tabs to panel |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-sm | Focus ring and pill corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e1 | — | Active pill lift |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Colour transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Small | 32px | 0 10px | 4px | 12px | — | 44px (padded) | Inside a card or a panel header. |
| Medium | 36px | 0 12px | 4px | 13px | — | 44px (padded) | Page-level sections. The default. |
| Full width | 36px | — | — | — | 96px per tab | — | Two to four tabs on mobile, distributed evenly. |
| Count badge | 18px | — | — | — | — | — | Accent on the active tab, neutral on the rest. |
/projects/api-gateway?tab=logsNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The inactive label must reach 4.5:1 — it is real text, not decoration, and it is the most commonly failed part of a tab row.
- The 2px indicator must reach 3:1 against the container. It is the only visual carrier of which tab is active.
- Never rely on the indicator alone. The active label also changes colour, so the state survives greyscale.
Keyboard
| Tab | Enters the tablist at the active tab. One stop for the whole row. |
| ← / → | Moves between tabs and activates them. Skips disabled tabs and wraps at the ends. |
| Home / End | First and last tab. |
| Tab (again) | Moves into the panel, not to the next tab. |
| Enter / Space | Activates in manual-activation mode. Not needed with automatic activation. |
Screen readers
- Announced as "Logs, tab, 2 of 5, selected". The position comes from the tablist, which is why a row of plain buttons is not equivalent.
- Do not render inactive panels. Keeping them in the DOM means their content is reachable by screen reader and by Ctrl+F when it should not be.
- A tab with a count badge should include it in the accessible name: "Logs, 12 items" rather than "Logs 12".
Focus & touch
- Automatic activation — arrowing selects — is correct when panels are cheap to render. If switching triggers a network request, use manual activation so arrowing moves focus and Enter commits.
- Tabs are padded to a 44px target on coarse pointers. Full-width tabs are the better mobile pattern for two to four options; more than that belongs in a select or a drawer.
| Attribute | Applied to | Notes |
|---|---|---|
| role="tablist" + aria-label | The container | Names the group. Two tab rows on a page need two distinct labels. |
| role="tab" + aria-selected | Each tab | Exactly one tab has aria-selected="true" at a time. |
| aria-controls | Each tab | Points at its panel id, so the relationship is explicit. |
| role="tabpanel" + aria-labelledby | Each panel | Points back at its tab, giving the panel a name. |
| tabindex | The tabs | 0 on the active tab, −1 on the rest. This is what makes the row one tab stop. |
| tabindex={0} | The panel | So a panel with no focusable content can still be reached and scrolled by keyboard. |
Example usage
1import { Tabs, TabPanel } from '@/ui/Navigation'23const TABS = [4 { value: 'overview', label: 'Overview' },5 { value: 'logs', label: 'Logs', count: 12 },6 { value: 'security', label: 'Security' },7]89// Keep the tab in the URL — it is a view worth linking to10const [params, setParams] = useSearchParams()11const tab = params.get('tab') ?? 'overview'1213<Tabs14 tabs={TABS}15 value={tab}16 onChange={(v) => setParams({ tab: v }, { replace: true })}17 aria-label="Project sections"18/>1920{TABS.map((t) => (21 <TabPanel key={t.value} value={t.value} active={t.value === tab}>22 <PanelFor id={t.value} />23 </TabPanel>24))}2526// Only render the active panel. Keeping the others mounted makes their27// content reachable by screen reader and by Ctrl+F.28// Lazy-load anything heavy behind a tab:29const Metrics = lazy(() => import('./Metrics'))Framework-free HTML
<div role="tablist" aria-label="Project sections">
<button role="tab" id="tab-overview"
aria-selected="true" aria-controls="panel-overview" tabindex="0">
Overview
</button>
<button role="tab" id="tab-logs"
aria-selected="false" aria-controls="panel-logs" tabindex="-1">
Logs
<span class="ds-badge" aria-label="12 items">12</span>
</button>
</div>
<div role="tabpanel" id="panel-overview"
aria-labelledby="tab-overview" tabindex="0">
…
</div>
<!-- The inactive panel is NOT rendered, not just hidden -->CSS
.ds-tablist {
display: flex;
gap: 4px;
border-block-end: 1px solid var(--ds-border-subtle);
}
.ds-tab {
block-size: 36px;
padding-inline: 12px;
color: var(--ds-fg-muted);
font-weight: 500; /* the SAME in every state */
border-block-end: 2px solid transparent;
margin-block-end: -1px; /* overlap the container border */
transition: color 140ms var(--ease-standard),
border-color 140ms var(--ease-standard);
}
.ds-tab:hover { color: var(--ds-fg-secondary); }
/* Colour and the indicator change. Weight never does — a bold active
label is wider and reflows the whole row on every switch. */
.ds-tab[aria-selected='true'] {
color: var(--ds-fg);
border-block-end-color: var(--ds-accent);
}
.ds-tab:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: -2px; /* inset, so it is not clipped */
border-radius: var(--radius-sm);
}
.ds-tab:disabled { opacity: 0.4; pointer-events: none; }
/* The panel is a focus stop even with no focusable content inside */
.ds-tabpanel:focus-visible { outline: none; }
.ds-tabpanel { padding-block-start: 20px; }
@media (pointer: coarse) {
.ds-tab { min-block-size: 44px; }
}Component API
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs* | TabSpec[] | — | { value, label, icon?, count?, disabled? }. Two to seven. |
| value* | string | — | Controlled active tab. |
| onChange* | (v: string) => void | — | Fired on click and on arrow-key movement. |
| variant | 'underline' | 'pill' | 'enclosed' | 'underline' | Underline for page sections, pill inside cards, enclosed when the panel needs a container. |
| size | 'sm' | 'md' | 'md' | 32px or 36px. |
| fullWidth | boolean | false | Distributes tabs evenly. Two to four only. |
| aria-label* | string | — | Names the tablist. Required when there is more than one on a page. |
TabPanel
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Must match its tab’s value — it wires aria-labelledby and the id. |
| active* | boolean | — | Renders nothing when false. Do not hide with CSS. |
Professional tips
- Two to seven tabs. Below two it is not a choice; above seven the row stops being scannable and the labels start truncating.
- Lazy-load heavy panels but prefetch the adjacent ones on hover. Switching should feel instant even when the panel is a chart.
- A count badge should only appear when the count is actionable. "Logs 4,281" is noise; "Security 2" is a call to action.
- If a tab is empty for a given object, keep it visible and show an empty state. Removing tabs conditionally makes the row a different shape on every record.
Performance
- Render only the active panel. Mounting all of them multiplies the initial render cost and puts hidden content into Ctrl+F and the accessibility tree.
- Preserve scroll position per tab. Returning to a tab and finding it scrolled to the top is a small, repeated frustration.
- For panels with expensive charts, keep the data cached but unmount the DOM. Re-fetching on every switch is the more common and more visible mistake.
- Do not animate the panel transition. A crossfade delays content the user has explicitly asked for.
Common mistakes
- Using buttons instead of a real tablist, which costs one tab stop per tab and loses the "2 of 5" announcement.
- Changing the label weight on the active tab, which reflows the entire row on every switch.
- Keeping inactive panels in the DOM, so hidden content is found by Ctrl+F and read by screen readers.
- No URL for the active tab, so a tab cannot be shared and the back button skips the whole page.
- Nesting tab rows, making it impossible to tell which row owns the content below.
Real-world recommendations
- When a tab row grows past seven, the page is usually doing too much. Splitting it into two pages almost always reads better than adding a scroll.
- Log which tabs are opened. A tab used by under 2% of sessions is a candidate for removal or for a link somewhere less prominent.
- On mobile, three or fewer full-width tabs work well. More than that and a select or a drawer is genuinely easier to use than a scrolling row.
- Keep the first tab the one people want most often. The default view is the one the majority of users will ever see.