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 ↗

Browser-owned state

"Transition by default" is about state the library owns. Some state belongs to the browser, which changes it before React hears about it. FUNSTACK UI prefers that state whenever the platform offers it, because it is the only way a component can work without JavaScript.

Two kinds of state

Library-ownedBrowser-owned
ExamplesToggle pressed, Tabs value, Accordion value in library mode, Switch and Checkbox checked, RadioGroup value, opening a Dialog in library modeTooltip open state (top layer), <details open> in Accordion's JS-free mode, a Dialog in JS-free mode, closing any Dialog
Who changes itthe library, inside its transitionthe browser (hover, light dismiss, Escape, find-in-page, a summary click, an invoker command)
Optimistic stateuseOptimisticthe DOM itself
Action propawaited; the UI waits for ita notification after the fact (Dialog's openChangeAction(false)), or none
Stylingdata-* attributespseudo-classes: :popover-open, :open
Animationtransition prop (<ViewTransition>)CSS: @starting-style, transition-behavior: allow-discrete

The library never mirrors browser-owned state into React state. It reads the DOM when it needs to, and it does not duplicate a pseudo-class with a data attribute: there is no data-open on a tooltip, because :popover-open already says it.

Tooltip

A tooltip's visibility is not application state: nobody persists it or awaits it. The popup is a popover in the top layer, so Tooltip has no open prop and no action. Style and animate it in CSS:

.tip { opacity: 0; transition: opacity 120ms, display 120ms allow-discrete, overlay 120ms allow-discrete; }
.tip:popover-open {
  opacity: 1;
  @starting-style { opacity: 0; }
}
/* The trigger of an open tooltip: the popup is its next sibling. */
.trigger:has(+ .tip:popover-open) { outline: 1px solid; }

Accordion: two modes, one anatomy

An Accordion without value, defaultValue and valueChangeAction runs in JS-free mode: the browser owns every <details open>, nothing runs on toggle, and exclusive renders the native name group. Add any of the three and it switches to library mode: the summary click is intercepted, open state is React state changed in a transition, and triggers get optimistic data-open and data-pending.

// JS-free: the browser owns open state.
<Accordion.Root exclusive>...</Accordion.Root>

// Library mode: transitions, actions, optimistic state.
<Accordion.Root exclusive defaultValue={null} valueChangeAction={save}>...</Accordion.Root>

The browser can still open a closed item in library mode (find-in-page, a text fragment, a click before hydration). The Accordion turns that into a regular dispatch with details.reason === "platform", and once the action settles it re-applies the committed state to the DOM, so a controlled parent that refuses the change closes the item again. Tabs does the same for panels with keepMounted="until-found".

The mode is decided on the first render; switching between them later is not supported (development warning).

Dialog: library-owned open, browser-owned close

Dialog has the same two modes as Accordion. Without open, defaultOpen and openChangeAction the browser owns everything through Invoker Commands (command="show-modal", command="request-close", <form method="dialog">). With any of them, ownership is split by direction:

OpeningClosing
Ownerthe librarythe browser
Howthe Trigger's click is cancelled and dispatched in the Root's transition; showModal() runs in the commit that mounts the contentEscape, Android back, a click outside, a Close button or a form submit close the dialog natively and at once
openChangeAction(true, details), awaited: the dialog opens when it resolves and the content has rendered(false, details), a notification: reason is "dismiss" or "activate", returnValue says which button
Pendingthe Trigger shows data-pending until the dialog opensthe Trigger shows data-pending while the notification runs
A controlled parent that refusesa veto: the dialog never opensnot a veto: the dialog is re-opened once the action settles

Closing stays with the browser on purpose: close requests can be cancelled only once per user activation, a method="dialog" submit cannot be cancelled at all, and a user who pressed Escape expects the dialog to go away now. See Dialog.

Native inputs: the DOM is the optimistic state

Switch, Checkbox and RadioGroup render real <input> elements whose checked is library-owned. The browser flips the input before any JavaScript runs, and after every event react-dom restores a controlled input to its current props. Only the synchronous optimistic state has rendered by then, so these components render checked={optimistic}. That is a correctness requirement: an input driven by committed state would snap back right after the click and stay wrong for as long as the action runs. As a consequence :checked and data-checked agree, and assistive technology hears the new state at once.

The browser can still change a native input without a click the library sees. Each case is a regular dispatch with details.reason === "platform", and afterwards the DOM follows the decision:

  • Form reset: a reset button, form.reset(), or React's automatic reset after a successful <form action>. Uncontrolled, the target is the latest defaultChecked / defaultValue; controlled, the browser's mount-time value is offered to the parent, which adopts it or not.
  • Mount reconciliation: a click before hydration, or Firefox restoring form state on reload. React keeps the DOM value when hydrating and fires nothing, so the component compares on mount and dispatches once (once per group for RadioGroup).

A fourth kind of state is consumer-owned and mirrored: Checkbox's indeterminate is derived by the parent, has no default and no action, and the library only writes it to the DOM property after every commit. It is suspended while an action is pending, because the browser clears it on the click.