Tabs
An open-ended, closable tab collection whose panel renders either in the layout or in a popover anchored to its own tab.
Getting started
Overview
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Before you begin
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
Usage guidelines
- Open-ended and closable — tabs are added and removed at runtime, unlike a fixed set of panels.
- Open-ness is the selection — there is no separate
openflag.valueis a tab id ornull, andnullmeans nothing is showing. A page-tab strip never reachesnull; a chat dock does, every time you close the last one. - Order is data —
itemsis an ordered array on the store, not something derived from the DOM. Drag it with whatever library you like throughrender; the result is a state change like any other. - One viewport, not a panel per tab — switching re-renders the same box, which is what lets a floating surface move rather than tear itself down.
- In the layout or anchored — wrapping the viewport in
Portal›Positioner›Popupis the entire difference between the two. - A toolbar, not a tablist — see Why a toolbar and not a tablist. ARIA's tablist cannot describe a closable, open-ended strip.
- Get started — see Quick start to add the package.
Anatomy
<Tabs.Root>
<Tabs.List>
<Tabs.Trigger>
<Tabs.Icon />
<Tabs.Action>
<Tabs.Close />
</Tabs.Action>
</Tabs.Trigger>
</Tabs.List>
{/* In the layout … */}
<Tabs.Viewport />
{/* … or floating over the open tab. */}
<Tabs.Portal>
<Tabs.Positioner>
<Tabs.Popup>
<Tabs.Viewport />
</Tabs.Popup>
</Tabs.Positioner>
</Tabs.Portal>
</Tabs.Root>One viewport per collection, in one of those two places — the three wrapping parts are the only difference between them.
Both the strip and the viewport take a function, so neither needs a loop, a key, or a subscription of its own:
<Tabs.Root defaultItems={["a", "b"]} defaultValue="a" selectOnClose="adjacent">
<Tabs.List>
{(id) => (
<Tabs.Trigger value={id}>
<Tabs.Icon />
{documents[id].title}
<Tabs.Action>
<Tabs.Close aria-label="Close tab" />
</Tabs.Action>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Viewport>{(id) => <Document id={id} />}</Tabs.Viewport>
</Tabs.Root>The viewport can sit anywhere in the tree — inside the popup for a floating surface, or in a card three components away while the strip stays in the window chrome.
Examples
Anchoring the panel to its tab
Wrap the viewport and the panel floats above the open tab instead of sitting in the layout. Nothing else changes — same Root, same List, same ARIA — because the content portals into the viewport wherever it happens to be:
Getting started
A chat dock is something you work behind: non-modal throughout, with no backdrop, no scroll lock, no focus trap, and no dismissal on outside press. Open a chat below, then keep reading — the page stays yours.
The Agent button is a trigger written outside the list. Its value is never in the collection, so it anchors a draft to itself without creating a tab — send something and a tab appears, titled by what you typed.
The panel is a real chat, built from this package's own parts — a Thread of Messages with a docked Composer. The dock owns which conversation is showing; everything inside the panel is the chat primitives' business.
<Tabs.Root defaultItems={chats} selectOnClose="recent">
<Tabs.List>{/* … */}</Tabs.List>
<Tabs.Portal>
<Tabs.Positioner side="top" align="end" sideOffset={8}>
<Tabs.Popup>
<Tabs.Viewport>{(id) => <Chat id={id} />}</Tabs.Viewport>
</Tabs.Popup>
</Tabs.Positioner>
</Tabs.Portal>
</Tabs.Root>Note the Agent button in the demo: a Tabs.Trigger written outside the list,
whose value is never in items. It anchors a panel to itself without creating
a tab — a draft, in other words — and it keeps its own tab stop rather than
joining the roving focus. Selecting something else is all it takes to discard
it, because there was never a tab to clean up.
One positioner serves the whole collection, anchored to whichever tab is open.
A positioner per tab would mean an autoUpdate loop each — a ResizeObserver
and an IntersectionObserver apiece, still running while closed — and nothing to
morph between when the selection moves.
The surface is non-modal throughout: no backdrop, no scroll lock, no focus trap, and no dismissal on outside press. A chat dock is something you work behind.
Positioner owns the movement and Popup owns the appearance, so the element
being animated is never the element being moved. Until the first placement
lands the surface withholds paint rather than showing at 0,0 — otherwise the
first real position would arrive as auto → a length, which CSS cannot
interpolate, and the surface would slide in from the corner.
Choosing where the selection lands
Where the selection goes when the open tab is closed is a behaviour, and the
primitive will not invent one you did not ask for. selectOnClose is the only
difference between the three strips below.
| Value | Description |
|---|---|
unsetdefault | Nowhere: closing what was open shows nothing. |
"adjacent" | Whatever slides into the vacated slot, or the last tab if the tail went — an editor's behaviour. |
"recent" | The tab you were in before this one, falling back to adjacent. |
selectOnClose unsetNothing is selected. Closing what was open shows an empty viewport.
selectOnClose="adjacent"Whatever slides into the vacated slot, or the last tab if the tail went. An editor's behaviour.
selectOnClose="recent"The tab you were in before this one, falling back to adjacent.
Persisting the collection
The collection goes out through onItemsChange and the selection through
onValueChange, and both come back in as defaultItems and defaultValue.
<Tabs.Root
defaultItems={stored?.items ?? []}
defaultValue={stored?.value ?? null}
onItemsChange={(items) => save({ items })}
onValueChange={(value) => save({ value })}
>A restored selection naming a tab that is no longer in items is dropped
rather than trusted, so a stale value cannot open a panel for something that
does not exist. See Shell for
why the value has to arrive as a prop rather than be read at init.
Opening a tab from elsewhere
useTabsStore(store, selector) is the outside-the-tree twin of useTabs,
taking an explicit Tabs.createStore() handle — which is how a "new chat"
button somewhere else in the app opens a tab. There is no global fallback.
open adds a tab and selects it, or moves and selects one already present, so
the caller never has to check first.
Why a toolbar and not a tablist
ARIA's tablist cannot describe this widget, for two independent reasons. A
tablist must own only tabs, so there is nowhere to put a close button; and it
requires exactly one selected tab, so value: null — a dock with everything
closed — is not a state it can express. Both are real, and axe fails the first
outright.
So the strip is a toolbar of buttons, each a disclosure for its panel:
aria-expanded says whether its panel is showing and aria-controls names it.
Close buttons are real buttons rather than pointer-only affordances, arrow-key
roving focus is exactly what a toolbar is expected to do, and nothing open is
an ordinary state.
Static, always-one-selected tabs are a different widget, and tablist is the
right role for those.
Keyboard
| Key | Description |
|---|---|
| Arrow keys | Move focus along the strip, following its orientation. |
| Enter / Space | Open the focused tab. |
| Delete / Backspace | Close the focused tab — only inside a Tabs.List, and the keyboard equivalent of the × beside it. |
| Escape | Close the open panel. The one key bound on the window rather than the strip, since the panel may be portaled away from it — turn it off with dismissOnEscape. |
Focus follows the arrows but does not select, which is what manual activation
means; set activateOnFocus to select as focus moves. A text field inside the
strip keeps the arrow key whenever the caret still has somewhere to travel.
API reference
Every part accepts className, style, and render (see
Styling) and emits a bespoke part attribute
(data-<part>) unless noted. Every part in the collection also carries
data-orientation, data-disabled when disabled, data-empty when nothing is
open, and data-activation-direction.
Tabs.Root
The provider and container. Renders data-tabs.
| Prop | Type | Default |
|---|---|---|
defaultItems | string[] | — |
items | string[] | — |
onItemsChange | (items: string[]) => void | — |
defaultValue | string | null | — |
value | string | null | — |
onValueChange | (value: string | null) => void | — |
selectOnClose | "adjacent" | "recent" | — |
orientation | "horizontal" | "vertical" | "horizontal" |
loop | boolean | true |
activateOnFocus | boolean | false |
disabled | boolean | false |
dismissOnEscape | boolean | true |
store | TabsStore | — |
| Attribute | Values | Description |
|---|---|---|
data-tabs | — | The container. |
data-orientation | "horizontal" | "vertical" | Which arrow keys walk the strip. |
data-empty | — | Present while nothing is open. A page-tab strip never sees this; a dock does. |
data-disabled | — | Present while the whole collection is disabled. |
data-activation-direction | "left" | "right" | "up" | "down" | Which way the selection last moved, for panels that slide rather than fade. Absent when it has not moved. |
Tabs.List
The strip. Renders data-tabs-list with role="toolbar" and owns the roving
focus.
| Prop | Type | Default |
|---|---|---|
children | ReactNode | ((value: string, index: number) => ReactNode) | — |
| Attribute | Values | Description |
|---|---|---|
data-tabs-list | role="toolbar" | The strip, with aria-orientation matching the Root's. |
data-orientation | "horizontal" | "vertical" | The strip's orientation. |
data-empty | — | Present while nothing is open. |
data-disabled | — | Present while the collection is disabled. |
data-activation-direction | "left" | "right" | "up" | "down" | Which way the selection last moved. |
Tabs.Trigger
One tab. Renders data-tabs-trigger, with aria-expanded and an
aria-controls that only claims a viewport really in the document.
One part, two situations, decided by where it sits rather than by a prop:
inside a Tabs.List it joins the collection and the roving focus, and Delete
closes it. Outside one it takes an explicit value, keeps its own tab stop, and
toggles — a panel with no tab behind it, anchored to the button that owns it.
| Prop | Type | Default |
|---|---|---|
value | string | — |
disabled | boolean | — |
| Attribute | Values | Description |
|---|---|---|
data-tabs-trigger | the tab's value | The tab, and its identity — the strip's roving focus finds tabs by this attribute and reads the value back off it. Select it without the value for styling. |
data-selected | — | Present while this tab's panel is showing. |
data-disabled | — | Present while disabled. |
data-orientation | "horizontal" | "vertical" | The strip's orientation. |
Tabs.Icon
Decoration inside a tab. Renders a <span> with data-tabs-icon and
aria-hidden — the trigger already has an accessible name, and an icon that
repeats it only makes the announcement longer. It carries the tab's state, so
the mark can respond to its tab being open without a group selector.
| Prop | Type | Default |
|---|---|---|
value | string | — |
| Attribute | Description |
|---|---|
data-tabs-icon | The icon slot. |
data-selected | Present while the tab's panel is showing. |
data-disabled | Present while the tab is disabled. |
Tabs.Action
The trailing slot inside a tab — where the close button lives. Renders
data-tabs-action, carrying the tab's state.
| Prop | Type | Default |
|---|---|---|
value | string | — |
| Attribute | Description |
|---|---|
data-tabs-action | The slot. |
data-selected | Present while the tab's panel is showing. |
data-disabled | Present while the tab is disabled. |
It wants to be positioned rather than in flow — a tab narrow enough to truncate has nowhere to put a control, so the label needs to run under the button and fade out:
[data-tabs-trigger] {
position: relative;
overflow: hidden;
}
[data-tabs-action] {
position: absolute;
inset-block: 0;
right: 0;
display: flex;
align-items: center;
/* The padding starts the button past the end of the gradient. */
padding-inline: 0.75rem 0.375rem;
background-color: inherit;
mask-image: linear-gradient(to right, transparent, #000 0.5rem);
}background-color: inherit takes the tab's own colour, whatever state it is
in, and the mask fades that background in from the left — so the label slides
under it rather than stopping at a hard edge. Laying it out in flow instead
gives you a button that collides with the label on exactly the tabs where it
matters.
Tabs.Close
The × inside a tab. Renders data-tabs-close as a role="button" with
tabIndex="-1", for the same reason its parent is a div. It keeps out of
the roving order — arrowing along a strip should walk tabs, not alternate
between each tab and its close button — so the keyboard route to closing is
Delete on the tab itself. Every event it handles stops there: nested inside
the trigger, anything that escaped would open the tab on its way out of closing
it.
| Prop | Type | Default |
|---|---|---|
value | string | — |
disabled | boolean | — |
| Attribute | Description |
|---|---|
data-tabs-close | The close button. |
data-selected | Present while the tab's panel is showing. |
data-disabled | Present while disabled. |
Tabs.Viewport
The one box that shows a tab's content. Renders data-tabs-viewport as a
role="group" named by whichever tab is open, through aria-labelledby. The
role is what makes the name stick: a bare div maps to generic, whose name
assistive technology discards.
Nothing off-screen exists, and that is deliberate: a tab you are not looking at has no component, so anything that must keep running while you are elsewhere — a reply still streaming — belongs in a store rather than in the panel's state.
| Prop | Type | Default |
|---|---|---|
children | (value: string) => ReactNode | — |
| Attribute | Values | Description |
|---|---|---|
data-tabs-viewport | — | The content box. Carries an id, and `aria-labelledby` naming whichever tab is open. |
data-empty | — | Present while nothing is open — the children function is not called, so the box is empty. |
data-orientation | "horizontal" | "vertical" | The collection's orientation. |
data-activation-direction | "left" | "right" | "up" | "down" | Which way the selection last moved, so the panel can slide the right way. |
The open tab's id is deliberately not published as an attribute: it is state the viewport needs, but not a styling hook.
Tabs.Portal
Owns the mounting of the floating surface. Renders no element of its own — so
it has no attributes to publish — and keeps its children in the DOM through the
exit animation, which Tabs.Popup reports as finished.
| Prop | Type | Default |
|---|---|---|
container | HTMLElement | null | document.body |
keepMounted | boolean | false |
Tabs.Positioner
Owns the placement. Renders data-tabs-positioner as role="presentation",
anchored to whichever tab is open.
| Prop | Type | Default |
|---|---|---|
side | "top" | "bottom" | "left" | "right" | "top" |
align | "start" | "center" | "end" | "center" |
sideOffset | number | 8 |
collisionPadding | number | 8 |
Placement lands on the positioner, which is also where the measured geometry is
published — position, left and top are written imperatively, so do not
set them from a stylesheet on this part.
| Attribute | Values | Description |
|---|---|---|
data-tabs-positioner | role="presentation" | The positioned wrapper. |
data-open | — | Present while a tab is open. |
data-closed | — | Present while nothing is. |
data-side | "top" | "bottom" | "left" | "right" | The resolved side, which flip may have changed — so a surface that flipped can style itself as where it ended up. |
data-align | "start" | "center" | "end" | The resolved alignment. |
--anchor-width | measured px | The open tab's own width, e.g. so a surface can match it. |
--anchor-height | measured px | The open tab's own height. |
--anchor-available-width | measured px | Free space toward the placement side — cap a max-width instead of overflowing. |
--anchor-available-height | measured px | The same, vertically. |
Tabs.Popup
The surface itself: the part to style and animate. Renders data-tabs-popup,
and it is what reports the exit animation as finished so the portal knows when
to unmount.
| Attribute | Description |
|---|---|
data-tabs-popup | The surface. |
data-open | Present while a tab is open. |
data-closed | Present while nothing is. |
data-starting-style | Present on the first open frame. |
data-ending-style | Present while the exit animation runs. |
useTabs
Read the collection from anywhere inside <Tabs.Root>:
const openTab = useTabs((tabs) => tabs.value);| Prop | Type | Default |
|---|---|---|
value | string | null | — |
items | string[] | — |
recent | string[] | — |
direction | "left" | "right" | "up" | "down" | "none" | — |
open | (value, options?) => void | — |
close | (value: string) => void | — |
select | (value: string | null) => void | — |
selectRelative | (direction, options?) => void | — |
move | (value: string, toIndex: number) => void | — |
setItems | (items: string[]) => void | — |
useTabsStore(store, selector) is the outside-the-tree twin, taking an
explicit Tabs.createStore() handle — which is how a "new chat" button
somewhere else in the app opens a tab. There is no global fallback.