The WAI-ARIA Tabs pattern. Tabs and the indicator show the optimistic selection; panels show the committed one, so a panel that suspends keeps the current panel on screen while the selected tab shows data-pending.
Loading orders…
"use client";
import { Suspense, use, useState } from "react";
import { Tabs } from "@funstack/ui/tabs";
import { sleep } from "./fake-api.js";
// Cache the promises so `use()` sees the same one on every render.
const cache = new Map<string, Promise<string[]>>();
function loadOrders(status: string): Promise<string[]> {
let promise = cache.get(status);
if (promise === undefined) {
promise = sleep(1200).then(() => [1, 2, 3].map((n) => `${status} order #${n}0${n}`));
cache.set(status, promise);
}
return promise;
}
function Orders({ status }: { status: string }) {
const orders = use(loadOrders(status));
return (
<ul>
{orders.map((order) => (
<li key={order}>{order}</li>
))}
</ul>
);
}
const statuses = ["Open", "Shipped", "Returned"];
const tabs = ["Summary", ...statuses];
// Panels suspend while their data loads. The Suspense boundary sits above Tabs.Root, yet its
// fallback never replaces a loaded panel: selection is a transition, so the old panel stays
// (dimmed through .demo-tabs[data-pending]) and the selected tab shows a spinner.
export function TabsSuspending() {
const [generation, setGeneration] = useState(0);
return (
<>
<Suspense key={generation} fallback={<p>Loading orders…</p>}>
<Tabs.Root className="demo-tabs" defaultValue="Summary">
<Tabs.List className="demo-tabs-list" aria-label="Orders">
{tabs.map((status) => (
<Tabs.Tab key={status} value={status} className="demo-tab">
{({ pending }) => (
<>
{status}
{pending && <span className="demo-spinner" aria-hidden />}
</>
)}
</Tabs.Tab>
))}
<Tabs.Indicator className="demo-tabs-indicator" />
</Tabs.List>
{/* The first panel needs no data, so the server-rendered HTML is complete. */}
<Tabs.Panel value="Summary" className="demo-tab-panel">
<p>Pick a status. Each list takes 1.2 s to load the first time.</p>
</Tabs.Panel>
{statuses.map((status) => (
<Tabs.Panel key={status} value={status} className="demo-tab-panel">
<Orders status={status} />
</Tabs.Panel>
))}
</Tabs.Root>
</Suspense>
<button
type="button"
className="demo-button"
onClick={() => {
cache.clear();
setGeneration((g) => g + 1);
}}
>
Clear the cache and start over
</button>
</>
);
}/* Tabs: optimistic selection on tabs and the indicator, committed selection on panels. */
.demo-tabs-list {
/* The Indicator is absolutely positioned against the Tabs (its anchors) inside the list. */
position: relative;
display: flex;
gap: 4px;
border-bottom: 1px solid var(--color-border);
}
.demo-tab {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.5em 0.9em;
border: none;
background: none;
color: var(--color-text-secondary);
font: inherit;
cursor: pointer;
transition: color 120ms;
}
.demo-tab[data-selected] {
color: var(--color-primary);
}
.demo-tab[data-pending] {
cursor: progress;
}
.demo-tab[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
.demo-tab:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
/* CSS Anchor Positioning places the indicator under the optimistic tab; a CSS transition
slides it. */
.demo-tabs-indicator {
position: absolute;
bottom: -1px;
left: anchor(left);
width: anchor-size(width);
height: 3px;
border-radius: 2px;
background: var(--color-primary);
transition:
left 200ms ease-out,
width 200ms ease-out;
}
.demo-tab-panel {
padding: 12px 4px 0;
}
.demo-tab-panel :is(p, ul) {
margin: 0 0 12px;
}
/* The old panel stays while the next one loads; dim it after a short delay. */
.demo-tabs[data-pending] .demo-tab-panel {
opacity: 0.5;
transition: opacity 150ms 150ms;
}
/* ViewTransition classes used by the `transition` prop on Tabs.Panel. */
::view-transition-old(.demo-slide-next) {
animation: 180ms ease-in both demo-out-left;
}
::view-transition-new(.demo-slide-next) {
animation: 180ms ease-out both demo-in-right;
}
::view-transition-old(.demo-slide-prev) {
animation: 180ms ease-in both demo-out-right;
}
::view-transition-new(.demo-slide-prev) {
animation: 180ms ease-out both demo-in-left;
}
@keyframes demo-out-left {
to {
opacity: 0;
translate: -2rem 0;
}
}
@keyframes demo-in-right {
from {
opacity: 0;
translate: 2rem 0;
}
}
@keyframes demo-out-right {
to {
opacity: 0;
translate: 2rem 0;
}
}
@keyframes demo-in-left {
from {
opacity: 0;
translate: -2rem 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
.demo-tabs-indicator,
.demo-tab {
transition: none;
}
}import { Tabs } from "@funstack/ui/tabs";
<Tabs.Root defaultValue="general"> {/* div: state, transition, ids */}
<Tabs.List aria-label="Settings"> {/* div[role=tablist]: arrow keys */}
<Tabs.Tab value="general">General</Tabs.Tab> {/* button[role=tab] */}
<Tabs.Tab value="billing">Billing</Tabs.Tab>
<Tabs.Indicator /> {/* span: optional marker, after the Tabs */}
</Tabs.List>
<Tabs.Panel value="general">...</Tabs.Panel> {/* div[role=tabpanel] */}
<Tabs.Panel value="billing">...</Tabs.Panel>
</Tabs.Root>Ids and aria-controls / aria-labelledby are wired from value; Tabs and Panels do not need to be registered or kept in the same order. Give the List an accessible name (aria-label or aria-labelledby).
Tabs.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | – | Controlled selected value. null selects nothing; undefined means uncontrolled. |
defaultValue | string | null | null | Initial value in uncontrolled mode. A development warning fires when neither value nor defaultValue is passed; pass defaultValue={null} to start empty on purpose. |
valueChangeAction | ChangeAction<string, TabsValueChangeDetails> | – | Runs inside the Root's transition whenever a tab is selected. Sync or async; rejections reach the nearest Error Boundary and roll the selection back. details.reason is "activate" (click, Enter/Space, or focus in automatic mode) or "platform" (find-in-page revealed a keepMounted="until-found" panel; event is the beforematch event). |
orientation | "horizontal" | "vertical" | "horizontal" | Layout and arrow-key axis. |
activationMode | "automatic" | "manual" | "automatic" | automatic: selection follows focus. manual: arrow keys only move focus; Enter, Space or click selects. |
loop | boolean | true | Whether arrow keys wrap from the last tab to the first. |
disabled | boolean | false | Disables every Tab (still focusable, never dispatches). |
className | string | ((state: TabsRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TabsRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TabsRootState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: TabsRootState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
TabsRootState: { value, pending, orientation, disabled }, where value is the optimistic selection.
Tabs.List| Prop | Type | Default | Description |
|---|---|---|---|
experimental_focusgroup | boolean | false | Renders focusgroup="tablist … nomemory" and focusgroupstart on the selected tab; where the browser implements it (Chromium), the browser handles arrow keys, Home and End, and unselected tabs drop tabindex="-1" after hydration (Chromium leaves negative-tabindex elements out of the focusgroup). The JS path stays active elsewhere. |
className | string | ((state: TabsListState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TabsListState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TabsListState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: TabsListState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
TabsListState: { orientation, disabled }. The List owns onKeyDown and onFocus.
Tabs.Tab| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Required. The value this tab selects; unique within the Root. |
disabled | boolean | false | Focusable but inert: aria-disabled="true", never dispatches. |
transition | ViewTransitionClass | – | Forwarded to the Tab's <ViewTransition default>. Fires only on the commit that settles the action, never on the optimistic selection. |
className | string | ((state: TabsTabState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TabsTabState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TabsTabState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((props, state: TabsTabState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
TabsTabState: { value, selected, pending, disabled, orientation }. The Tab owns onClick, onKeyDown, onFocus and id. It sets anchor-name in its style for the Indicator (your style wins).
Tabs.Panel| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Required. The tab this panel belongs to. |
keepMounted | boolean | "until-found" | false | false: hidden panels are not rendered. true: rendered inside <Activity mode="hidden"> (DOM and state kept, effects stopped, not in server HTML). "until-found": rendered with hidden="until-found" (in server HTML, effects live); find-in-page or a text fragment selects the tab. |
transition | ViewTransitionClass | – | Forwarded to the Panel's <ViewTransition default>; animates the swap. |
className | string | ((state: TabsPanelState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TabsPanelState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TabsPanelState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: TabsPanelState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
TabsPanelState: { value, selected, orientation }, where selected is committed. Panels get tabIndex={0} by default (APG); pass tabIndex={-1} when the panel starts with focusable content.
Tabs.Indicator| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: TabsIndicatorState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: TabsIndicatorState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: TabsIndicatorState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: TabsIndicatorState) => ReactNode) | – | Replace the default <span> (element form) or take over rendering (function form). |
TabsIndicatorState: { value, pending, orientation }. A decorative aria-hidden span that sets position-anchor to the optimistic tab. Give it position: absolute and anchor() insets in CSS, and a CSS transition on them to slide. It must come after the Tabs inside the List.
| Attribute | On | Meaning |
|---|---|---|
data-pending | Root | A selection is in flight (including a suspending panel's wait). |
data-pending | Tab | This tab is the one the user is waiting on. |
data-pending | Indicator | Mirrors the Root. |
data-selected | Tab, Indicator | Optimistic selection (immediate). |
data-selected | Panel | Committed selection (relevant with keepMounted). |
data-disabled | Root, List, Tab | Disabled by prop or by the Root. |
data-orientation | Root, List, Tab | "horizontal" or "vertical". |
aria-selected | Tab | Optimistic selection. |
aria-disabled | Tab | "true" when disabled (still focusable). |
.tab[data-selected] { color: var(--accent); }
.tab[aria-disabled="true"] { opacity: 0.4; }
.tabs[data-pending] .panel { opacity: 0.6; transition: opacity 150ms 150ms; }
.indicator { position: absolute; left: anchor(left); width: anchor-size(width); transition: left 200ms, width 200ms; }| Key | Behaviour |
|---|---|
| ArrowRight / ArrowLeft | Horizontal: next / previous tab (swapped under direction: rtl). Wraps when loop. |
| ArrowDown / ArrowUp | Vertical: next / previous tab. |
| Home / End | First / last tab. |
| Enter / Space | Selects the focused tab (the only way to select by keyboard in manual mode). |
| Tab | Leaves the list: the selected tab is the only tab stop (roving tabindex), and the panel is next. |
aria-disabled), but never selected.null, or a value that matches no tab), the List itself is the tab stop and hands focus to the first enabled tab.aria-selected follows the optimistic selection. aria-orientation is always set on the List.The profile settings.
"use client";
import { Tabs } from "@funstack/ui/tabs";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";
let saves = 0;
// Manual activation with an awaited save: arrow keys only move focus, Enter / Space / click
// select. The panel switches when the save resolves; every third save rejects, the selection
// rolls back and the error reaches the Error Boundary. Panels slide by DOM direction through
// the library's fsui-tabs-next / fsui-tabs-prev transition types.
export function TabsSaved() {
return (
<DemoErrorBoundary>
<Tabs.Root
className="demo-tabs"
defaultValue="profile"
activationMode="manual"
valueChangeAction={async (next) => {
await fakeSave(`tab "${next}"`, { fail: ++saves % 3 === 0 });
}}
>
<Tabs.List className="demo-tabs-list" aria-label="Account settings">
<Tabs.Tab value="profile" className="demo-tab">
Profile
</Tabs.Tab>
<Tabs.Tab value="billing" className="demo-tab">
Billing
</Tabs.Tab>
<Tabs.Tab value="danger" className="demo-tab" disabled>
Danger zone
</Tabs.Tab>
<Tabs.Tab value="team" className="demo-tab">
Team
</Tabs.Tab>
<Tabs.Indicator className="demo-tabs-indicator" />
</Tabs.List>
{["profile", "billing", "danger", "team"].map((value) => (
<Tabs.Panel
key={value}
value={value}
className="demo-tab-panel"
transition={{
"fsui-tabs-next": "demo-slide-next",
"fsui-tabs-prev": "demo-slide-prev",
default: "none",
}}
>
<p>The {value} settings.</p>
</Tabs.Panel>
))}
</Tabs.Root>
</DemoErrorBoundary>
);
}/* Tabs: optimistic selection on tabs and the indicator, committed selection on panels. */
.demo-tabs-list {
/* The Indicator is absolutely positioned against the Tabs (its anchors) inside the list. */
position: relative;
display: flex;
gap: 4px;
border-bottom: 1px solid var(--color-border);
}
.demo-tab {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.5em 0.9em;
border: none;
background: none;
color: var(--color-text-secondary);
font: inherit;
cursor: pointer;
transition: color 120ms;
}
.demo-tab[data-selected] {
color: var(--color-primary);
}
.demo-tab[data-pending] {
cursor: progress;
}
.demo-tab[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
.demo-tab:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
/* CSS Anchor Positioning places the indicator under the optimistic tab; a CSS transition
slides it. */
.demo-tabs-indicator {
position: absolute;
bottom: -1px;
left: anchor(left);
width: anchor-size(width);
height: 3px;
border-radius: 2px;
background: var(--color-primary);
transition:
left 200ms ease-out,
width 200ms ease-out;
}
.demo-tab-panel {
padding: 12px 4px 0;
}
.demo-tab-panel :is(p, ul) {
margin: 0 0 12px;
}
/* The old panel stays while the next one loads; dim it after a short delay. */
.demo-tabs[data-pending] .demo-tab-panel {
opacity: 0.5;
transition: opacity 150ms 150ms;
}
/* ViewTransition classes used by the `transition` prop on Tabs.Panel. */
::view-transition-old(.demo-slide-next) {
animation: 180ms ease-in both demo-out-left;
}
::view-transition-new(.demo-slide-next) {
animation: 180ms ease-out both demo-in-right;
}
::view-transition-old(.demo-slide-prev) {
animation: 180ms ease-in both demo-out-right;
}
::view-transition-new(.demo-slide-prev) {
animation: 180ms ease-out both demo-in-left;
}
@keyframes demo-out-left {
to {
opacity: 0;
translate: -2rem 0;
}
}
@keyframes demo-in-right {
from {
opacity: 0;
translate: 2rem 0;
}
}
@keyframes demo-out-right {
to {
opacity: 0;
translate: 2rem 0;
}
}
@keyframes demo-in-left {
from {
opacity: 0;
translate: -2rem 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
.demo-tabs-indicator,
.demo-tab {
transition: none;
}
}data-pending, the Profile panel stays.fsui-tabs-next type.// `section` comes from the URL; navigating is the state update.
<Tabs.Root value={section} valueChangeAction={(next) => navigate(`/settings/${next}`)}>
...
</Tabs.Root>A controlled value that matches no tab is allowed (a sub-route without a tab): nothing is selected and a development warning explains it.
<Tabs.Tab value="docs" render={<a href="/docs" />}>Docs</Tabs.Tab>{/* The draft survives switching tabs. */}
<Tabs.Panel value="editor" keepMounted>
<DraftEditor />
</Tabs.Panel>
{/* In the server HTML and found by Ctrl+F; finding text selects the tab. */}
<Tabs.Panel value="changelog" keepMounted="until-found">
<Changelog />
</Tabs.Panel>Pick a status. Each list takes 1.2 s to load the first time.