FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Tabs

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…

Show code
TabsSuspending.tsx
"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.css
/* 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;
  }
}

Anatomy

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

Props

Tabs.Root

PropTypeDefaultDescription
valuestring | null–Controlled selected value. null selects nothing; undefined means uncontrolled.
defaultValuestring | nullnullInitial value in uncontrolled mode. A development warning fires when neither value nor defaultValue is passed; pass defaultValue={null} to start empty on purpose.
valueChangeActionChangeAction<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.
loopbooleantrueWhether arrow keys wrap from the last tab to the first.
disabledbooleanfalseDisables every Tab (still focusable, never dispatches).
classNamestring | ((state: TabsRootState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TabsRootState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TabsRootState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
experimental_focusgroupbooleanfalseRenders 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.
classNamestring | ((state: TabsListState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TabsListState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TabsListState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
valuestring–Required. The value this tab selects; unique within the Root.
disabledbooleanfalseFocusable but inert: aria-disabled="true", never dispatches.
transitionViewTransitionClass–Forwarded to the Tab's <ViewTransition default>. Fires only on the commit that settles the action, never on the optimistic selection.
classNamestring | ((state: TabsTabState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TabsTabState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TabsTabState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((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

PropTypeDefaultDescription
valuestring–Required. The tab this panel belongs to.
keepMountedboolean | "until-found"falsefalse: 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.
transitionViewTransitionClass–Forwarded to the Panel's <ViewTransition default>; animates the swap.
classNamestring | ((state: TabsPanelState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TabsPanelState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TabsPanelState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
classNamestring | ((state: TabsIndicatorState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: TabsIndicatorState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: TabsIndicatorState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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.

Data attributes

AttributeOnMeaning
data-pendingRootA selection is in flight (including a suspending panel's wait).
data-pendingTabThis tab is the one the user is waiting on.
data-pendingIndicatorMirrors the Root.
data-selectedTab, IndicatorOptimistic selection (immediate).
data-selectedPanelCommitted selection (relevant with keepMounted).
data-disabledRoot, List, TabDisabled by prop or by the Root.
data-orientationRoot, List, Tab"horizontal" or "vertical".
aria-selectedTabOptimistic selection.
aria-disabledTab"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; }

Keyboard and ARIA

KeyBehaviour
ArrowRight / ArrowLeftHorizontal: next / previous tab (swapped under direction: rtl). Wraps when loop.
ArrowDown / ArrowUpVertical: next / previous tab.
Home / EndFirst / last tab.
Enter / SpaceSelects the focused tab (the only way to select by keyboard in manual mode).
TabLeaves the list: the selected tab is the only tab stop (roving tabindex), and the panel is next.
  • In automatic mode, moving focus between tabs selects. Focus entering the list from outside never selects.
  • Disabled tabs are focusable with arrow keys (aria-disabled), but never selected.
  • With no selection (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.
  • Key presses with Alt, Ctrl or Meta are left to the browser.

Async walkthrough

The profile settings.

Show code
TabsSaved.tsx
"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.css
/* 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;
  }
}
  1. Select Billing: the tab and indicator move immediately, the Root and the Billing tab get data-pending, the Profile panel stays.
  2. The save resolves: the Billing panel replaces Profile in one commit, sliding by the fsui-tabs-next type.
  3. The save rejects: the selection rolls back to the committed tab and the error reaches the Error Boundary.
  4. Selecting another tab while one is pending replaces the intent; clicking the committed tab again is a real dispatch that cancels the pending switch.

Automatic activation and slow actions

With activationMode="automatic", every arrow key press dispatches. Prefer "manual" when the action awaits a server round trip, or do not return the promise ((next) => { void save(next); }).

Recipes

Controlled by a router

// `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 as links

<Tabs.Tab value="docs" render={<a href="/docs" />}>Docs</Tabs.Tab>

Preserved panels and find-in-page

{/* 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.