Skip to content
Navigation

Tabs

Layered panels, one visible at a time — or, with unstackAt, side by side above a breakpoint and no longer tabs at all. The URL does not change, so reach for NavTabs when the tab needs to be linkable.

Manage your account settings and preferences.

Import

import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@mikenotthepope/substrateui"

Composition

Tabs
├── TabsList
│   └── TabsTrigger
└── TabsContent

Unstacking

A review screen wants both panes at once on a laptop and one at a time on a phone. unstackAtis that: the tabs pattern below the breakpoint, and at or above it every pane on screen with the tab strip gone. Narrow this page’s window past lg and back to watch it change.

unstackAt="lg"

Résumé

Notes

Eight years of back-end work, the last three of them on payments.

Wrote the settlement reconciler that closed the month-end gap.

Above the breakpoint it is not tabs, and it does not say it is

This is the part worth arguing about. A tablist whose panels are all on screen describes a control that is not there. There is no selection, so aria-selected has nothing to report; there is nothing to arrow between, so a roving tabIndex is a tab stop that does nothing; and aria-controls points at content already in front of you. Keeping the markup and hiding the strip with a class would leave every one of those claims in the accessibility tree, true of nothing.

So above the breakpoint there is no tablist. Each label becomes a heading, and each pane becomes a region named by that heading — through the same aria-labelledby, pointing at the same element, which was the tab a moment ago. That is the honest reading of two panes side by side: two pieces of content, each with a name, each reachable from a screen reader’s landmark and heading lists instead of from an arrow key.

Which is also the warning on the label. region is a landmark, and landmarks are for content worth jumping to. unstackAt earns its keep on two or three substantial panes — a résumé beside the notes about it. Six chips of settings are tabs at every width, and should stay tabs.

Why a class could not have done this

The request this shipped from said the blocker was Base UI’s TabsPanel setting the hidden attribute, which no breakpoint class can undo. That part is not right: [hidden] { display: none }is a user-agent rule, and any author class outranks it by origin. Base UI’s own Tailwind example depends on that — its panel carries [[hidden]]:hidden to put the hiding back after flex has undone it.

Two things underneath it are the blocker. A closed Base UI panel is also inert, which no class undoes. And one level up from either of them, role is not a property, so no media query can change what an element claims to be. The layout was never the hard part. The claim was.

Between first paint and hydration

The server cannot know the viewport, so the markup it sends is the regions form: headings and named regions, which is the weaker of the two claims and true at either width. The tabs pattern goes on one commit later, once the width is known and only if it is below the breakpoint. Below it, that leaves a moment where the headings name panes the stylesheet has not revealed — the same moment in which a tab would not have responded to a click either, because all of this is a client component.

The layout does not wait for any of that. Which pane shows is a class, so it is right in the first frame and nothing moves when the JavaScript lands. Semantics are JavaScript, layout is CSS, and they are allowed to disagree for exactly one commit.

What the keyboard does, below the breakpoint

The full pattern, unchanged from the tabs above: one tab stop for the whole strip, ArrowLeft and ArrowRight to move within it, Home and End for the ends, Enter or Space to select — or activateOnFocus on TabsList to select as focus lands. Arrow keys follow the reading direction, so under DirectionProvider dir="rtl" ArrowLeft moves to the next tab along. Disabled tabs are skipped.

Every pane stays mounted at every width, so nothing inside one is thrown away when the breakpoint is crossed: a half-typed note is still half-typed after a rotation. Below the breakpoint the inactive panes carry hidden and inert as well as the class that hides them — a pane kept out of the accessibility tree only by a stylesheet is a pane that comes back when the stylesheet does not load.

API Reference

PropTypeDefaultDescription
defaultValuestring—The value of the tab that should be active when initially rendered. Use when you do not need to control the active tab. Required with unstackAt.
valuestring—The controlled value of the currently active tab. Must be used with onValueChange.
onValueChange(value: string) => void—Callback fired when the active tab changes. Receives the new tab value. With unstackAt it fires only below the breakpoint, because above it there is no selection to change.
unstackAt"sm" | "md" | "lg" | "xl" | "2xl"—Breakpoint at or above which the panes stop being tabs and become side-by-side regions. Below it, the tabs pattern. Omit it and the component is the Base UI tablist it has always been.
headingLevel2 | 3 | 4 | 5 | 63The heading level each tab label becomes above unstackAt. Only the page knows its own outline. Ignored without unstackAt.

Parts, under unstackAt

PropTypeDefaultDescription
TabsList activateOnFocusbooleanfalseSelect a tab as soon as an arrow key focuses it, rather than waiting for Enter or Space. Same meaning and same default as without unstackAt.
TabsList loopFocusbooleantrueWhether ArrowRight past the last tab returns to the first.
TabsTrigger disabledbooleanfalseDisables the tab and takes it out of the arrow-key order. Above the breakpoint it has no effect — the pane is shown either way.
className (function form)not available—Base UI lets className be a function of the part's state. An unstacking Tabs renders its own elements, so there is no Base UI state to call it with, and passing a function throws rather than silently dropping the class. Select on data-active or data-unstacked instead.