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 ↗

Pending & optimistic state

Every component that owns state keeps three values. Knowing which part shows which one is the key to styling FUNSTACK UI.

Three values

ValueSourceShown by
committedthe controlled prop, or internal state written after the action succeedscontent regions: Tabs.Panel, Accordion.Panel, the <details open> attribute
optimisticuseOptimistic over the committed valuetriggers: Toggle, Tabs.Tab, Tabs.Indicator, Accordion.Trigger and Item (aria-pressed, aria-selected, data-selected, data-open)
pendingthe owner's useTransitiondata-pending on the owner and on the trigger the user acted on

Triggers are optimistic, content is committed

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.

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;
  }
}

Two consequences for triggers:

  • Content rendered from a trigger's state (its children function) must not suspend, or must have its own Suspense boundary.
  • The optimistic flip is a synchronous commit and never starts a view transition. Animate trigger visuals with a CSS transition on the data attribute.

Where data-pending lands

  • The owner (Toggle, Tabs.Root, Accordion.Root) while its transition is pending.
  • The trigger the user is waiting on: the Toggle itself; the Tab that is the optimistic selection; the Accordion Item and Trigger whose open state is changing. Tabs.Indicator also mirrors the Root's pending state.
  • Never on panels. A panel is either the committed one or not. To dim the old panel while the next one loads, select through the root:
.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>

Rollback

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.

Assistive technology hears optimistic state too, mostly

aria-pressed and aria-selected follow the optimistic value, so screen reader users get the same immediate feedback. The exception is Accordion: HTML forbids aria-expanded on a <summary>, so the expanded state assistive technology hears is the committed one, and data-open is visual only.