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 open flag. value is a tab id or null, and null means nothing is showing. A page-tab strip never reaches null; a chat dock does, every time you close the last one.
  • Order is dataitems is an ordered array on the store, not something derived from the DOM. Drag it with whatever library you like through render; 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 PortalPositionerPopup is 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.

ValueDescription
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 unset
Drafts

Nothing is selected. Closing what was open shows an empty viewport.

selectOnClose="adjacent"
Drafts

Whatever slides into the vacated slot, or the last tab if the tail went. An editor's behaviour.

selectOnClose="recent"
Drafts

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.

Inbox

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

KeyDescription
Arrow keysMove focus along the strip, following its orientation.
Enter / SpaceOpen the focused tab.
Delete / BackspaceClose the focused tab — only inside a Tabs.List, and the keyboard equivalent of the × beside it.
EscapeClose 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.

PropTypeDefault
defaultItemsstring[]
itemsstring[]
onItemsChange(items: string[]) => void
defaultValuestring | null
valuestring | null
onValueChange(value: string | null) => void
selectOnClose"adjacent" | "recent"
orientation"horizontal" | "vertical"
"horizontal"
loopboolean
true
activateOnFocusboolean
false
disabledboolean
false
dismissOnEscapeboolean
true
storeTabsStore
AttributeValuesDescription
data-tabsThe container.
data-orientation"horizontal" | "vertical"Which arrow keys walk the strip.
data-emptyPresent while nothing is open. A page-tab strip never sees this; a dock does.
data-disabledPresent 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.

PropTypeDefault
childrenReactNode | ((value: string, index: number) => ReactNode)
AttributeValuesDescription
data-tabs-listrole="toolbar"The strip, with aria-orientation matching the Root's.
data-orientation"horizontal" | "vertical"The strip's orientation.
data-emptyPresent while nothing is open.
data-disabledPresent 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.

PropTypeDefault
valuestring
disabledboolean
AttributeValuesDescription
data-tabs-triggerthe tab's valueThe 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-selectedPresent while this tab's panel is showing.
data-disabledPresent 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.

PropTypeDefault
valuestring
AttributeDescription
data-tabs-iconThe icon slot.
data-selectedPresent while the tab's panel is showing.
data-disabledPresent 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.

PropTypeDefault
valuestring
AttributeDescription
data-tabs-actionThe slot.
data-selectedPresent while the tab's panel is showing.
data-disabledPresent 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.

PropTypeDefault
valuestring
disabledboolean
AttributeDescription
data-tabs-closeThe close button.
data-selectedPresent while the tab's panel is showing.
data-disabledPresent 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.

PropTypeDefault
children(value: string) => ReactNode
AttributeValuesDescription
data-tabs-viewportThe content box. Carries an id, and `aria-labelledby` naming whichever tab is open.
data-emptyPresent 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.

PropTypeDefault
containerHTMLElement | null
document.body
keepMountedboolean
false

Tabs.Positioner

Owns the placement. Renders data-tabs-positioner as role="presentation", anchored to whichever tab is open.

PropTypeDefault
side"top" | "bottom" | "left" | "right"
"top"
align"start" | "center" | "end"
"center"
sideOffsetnumber
8
collisionPaddingnumber
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.

AttributeValuesDescription
data-tabs-positionerrole="presentation"The positioned wrapper.
data-openPresent while a tab is open.
data-closedPresent 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-widthmeasured pxThe open tab's own width, e.g. so a surface can match it.
--anchor-heightmeasured pxThe open tab's own height.
--anchor-available-widthmeasured pxFree space toward the placement side — cap a max-width instead of overflowing.
--anchor-available-heightmeasured pxThe 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.

AttributeDescription
data-tabs-popupThe surface.
data-openPresent while a tab is open.
data-closedPresent while nothing is.
data-starting-stylePresent on the first open frame.
data-ending-stylePresent while the exit animation runs.

useTabs

Read the collection from anywhere inside <Tabs.Root>:

const openTab = useTabs((tabs) => tabs.value);
PropTypeDefault
valuestring | null
itemsstring[]
recentstring[]
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.