"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.
| Library-owned | Browser-owned | |
|---|---|---|
| Examples | Toggle pressed, Tabs value, Accordion value in library mode, Switch and Checkbox checked, RadioGroup value, opening a Dialog in library mode | Tooltip open state (top layer), <details open> in Accordion's JS-free mode, a Dialog in JS-free mode, closing any Dialog |
| Who changes it | the library, inside its transition | the browser (hover, light dismiss, Escape, find-in-page, a summary click, an invoker command) |
| Optimistic state | useOptimistic | the DOM itself |
| Action prop | awaited; the UI waits for it | a notification after the fact (Dialog's openChangeAction(false)), or none |
| Styling | data-* attributes | pseudo-classes: :popover-open, :open |
| Animation | transition 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.
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; }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 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:
| Opening | Closing | |
|---|---|---|
| Owner | the library | the browser |
| How | the Trigger's click is cancelled and dispatched in the Root's transition; showModal() runs in the commit that mounts the content | Escape, 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 |
| Pending | the Trigger shows data-pending until the dialog opens | the Trigger shows data-pending while the notification runs |
| A controlled parent that refuses | a veto: the dialog never opens | not 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.
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(), 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.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.