Every component that owns state keeps three values. Knowing which part shows which one is the key to styling FUNSTACK UI.
| Value | Source | Shown by |
|---|---|---|
| committed | the controlled prop, or internal state written after the action succeeds | content regions: Tabs.Panel, Accordion.Panel, the <details open> attribute |
| optimistic | useOptimistic over the committed value | triggers: Toggle, Tabs.Tab, Tabs.Indicator, Accordion.Trigger and Item (aria-pressed, aria-selected, data-selected, data-open) |
| pending | the owner's useTransition | data-pending on the owner and on the trigger the user acted on |
This rule follows from how React works, not from taste. An optimistic update renders synchronously, and a synchronous render that suspends shows the nearest Suspense fallback. If a panel followed the optimistic value, selecting a tab whose panel loads data would flash a fallback. Because panels follow the committed value, the old panel stays on screen while the new one loads, the tab is already selected, and both the tab and the root carry data-pending. When the data is ready, the panel swaps.
Try the tabs below: switching waits for an 800 ms save. The indicator and the tab colour move at once (optimistic); the panel changes when the save resolves (committed). Every third save fails and everything rolls back.
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;
}
}Two consequences for triggers:
children function) must not suspend, or must have its own Suspense boundary.transition on the data attribute.data-pending landsToggle, Tabs.Root, Accordion.Root) while its transition is pending.Tabs.Indicator also mirrors the Root's pending state..tabs[data-pending] .panel { opacity: 0.6; transition: opacity 150ms 150ms; }
.tab[data-pending]::after { content: ""; /* spinner */ }The same names exist in the function props: state.pending, state.pressed, state.selected, state.open.
<Tabs.Tab value="billing">
{({ pending }) => <>Billing{pending && <Spinner />}</>}
</Tabs.Tab>If the action rejects, or a controlled parent does not adopt the new value, the optimistic value falls back to the committed one when the transition ends. Nothing has to be undone by hand: uncontrolled state is only written after success.