Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

underline

Panel for overview.

pill

Panel for overview.

enclosed

Panel for overview.

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.

Logs
Active
Logs
Inactive
Logs
Hover
Logs
Focus
Billing
Disabled
Logs12
With count
Logs
With icon
Logs
Pill
Logs
Enclosed
Full width
role="tabpanel"
Panel
tabindex 0 / −1
RovingOne tab stop

Anatomy

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.

  1. Tab height36px (md), 32px (sm)

    The same as a button, so a tab row aligns with any controls sitting beside it.

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

  3. Label colourmuted → fg

    Colour changes, weight does not. Changing the weight reflows the whole row on every switch.

  4. Gap4px between tabs

    Tight, because the tabs are one group. The padding inside each tab does the separating.

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

  6. Panel gap20px below the tabs

    Enough that the panel is clearly separate content, close enough that it is clearly the tab’s content.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
tab paddingHorizontal padding
gapBetween tabs
panel gapTabs to panel

Radius

TokenValueUsed for
--radius-smFocus ring and pill corners

Shadow

TokenValueUsed for
--shadow-e1—Active pill lift

Motion

TokenValueUsed for
durationColour transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingLabel gapTypeMin widthTouch targetWhen to use
Small32px0 10px4px12px—44px (padded)Inside a card or a panel header.
Medium36px0 12px4px13px—44px (padded)Page-level sections. The default.
Full width36px———96px per tab—Two to four tabs on mobile, distributed evenly.
Count badge18px—————Accent on the active tab, neutral on the rest.

Do

Project: api-gateway
Keep the subject constant across tabsAll of these are views of one project. The moment a tab shows a different object, the user loses the thread and the pattern stops being tabs.
/projects/api-gateway?tab=logs
Put the tab in the URLA tab is a view worth linking to. Without a URL, a user cannot share "the logs tab" and the browser back button skips the whole page instead of the tab.
Logs
Change colour, not weightA bold active label is wider than a regular one, so every switch reflows the row and neighbouring tabs move under the cursor.
Order by frequency, and never reorderUsers navigate by position after the first few visits. A tab row that reorders itself based on state destroys that muscle memory completely.

Don't

Do not use tabs for a sequenceTabs imply free movement between peers. A checkout with Cart, Shipping and Payment tabs lets the user jump to Payment before entering an address.
Do not scroll a tab rowTabs off-screen are tabs nobody knows exist. Horizontal scroll inside a vertically scrolling page is also a constant gesture conflict on touch.
Do not nest tab rowsTwo levels of tabs makes it impossible to tell which row owns the content. If you need a second level, the first level is really navigation.
Do not hide critical actions in a tabAnything behind a tab is invisible until the user goes looking. A "Danger zone" tab means the delete action is discovered by accident, or not at all.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

2.1.1KeyboardA2.4.3Focus OrderA2.4.7Focus VisibleAA4.1.2Name, Role, ValueA

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

TabEnters 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 / EndFirst and last tab.
Tab (again)Moves into the panel, not to the next tab.
Enter / SpaceActivates 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.
AttributeApplied toNotes
role="tablist" + aria-labelThe containerNames the group. Two tab rows on a page need two distinct labels.
role="tab" + aria-selectedEach tabExactly one tab has aria-selected="true" at a time.
aria-controlsEach tabPoints at its panel id, so the relationship is explicit.
role="tabpanel" + aria-labelledbyEach panelPoints back at its tab, giving the panel a name.
tabindexThe tabs0 on the active tab, −1 on the rest. This is what makes the row one tab stop.
tabindex={0}The panelSo a panel with no focusable content can still be reached and scrolled by keyboard.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
fullWidthbooleanfalseDistributes tabs evenly. Two to four only.
aria-label*string—Names the tablist. Required when there is more than one on a page.

TabPanel

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

Notes

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.