A button whose click is a transition. Compared with <button onClick>, a Button runs action in its own transition and shows data-pending until it settles, ignores a second click while it is pending, sends a rejection to the nearest Error Boundary, and takes a Server Function as action or formAction.
"use client";
import { startTransition, useOptimistic, useState } from "react";
import { Button } from "@funstack/ui/button";
import { toast } from "@funstack/ui/toast";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";
// Three Buttons with a 1.2 s fake save. Click Save several times quickly: the action runs once
// (the call counter, optimistic so it counts at once), because a Button ignores activation while
// its own action is pending. The
// second Button rejects into the Error Boundary; the third catches inside and shows a toast.
export function ButtonSave() {
const [calls, setCalls] = useState(0);
const [saved, setSaved] = useState(0);
const [optimisticCalls, addCall] = useOptimistic(calls, (n) => n + 1);
return (
<div className="demo-stack">
<div className="demo-row">
<Button
className="demo-btn"
action={async () => {
addCall(undefined);
setCalls((c) => c + 1);
await fakeSave("the draft", { ms: 1200 });
startTransition(() => setSaved((s) => s + 1));
}}
>
{({ pending }) => (
<>
{pending && <span className="demo-spinner" aria-hidden />}
{pending ? "Saving…" : "Save"}
</>
)}
</Button>
<output className="demo-readout">
Action calls: {optimisticCalls}. Saves: {saved}.
</output>
</div>
<DemoErrorBoundary>
<Button
className="demo-btn"
action={() => fakeSave("the deletion", { ms: 1200, fail: true })}
>
{({ pending }) => (pending ? "Deleting…" : "Delete (rejects)")}
</Button>
</DemoErrorBoundary>
<Button
className="demo-btn"
action={async () => {
try {
await fakeSave("the copy", { ms: 1200, fail: true });
toast({ title: "Copied" });
} catch (error) {
// Caught inside: the library counts this as success, so pending simply ends.
toast({ title: "Copy failed", description: String(error), type: "error" });
}
}}
>
{({ pending }) => (pending ? "Copying…" : "Copy (fails, caught)")}
</Button>
</div>
);
}/* Button: [data-pending] is the in-flight state, [data-disabled] the consumer's `disabled`. A
pending Button is also aria-disabled="true", so style "unavailable" from [data-disabled] (or
aria-disabled without data-pending), not from aria-disabled alone. */
.demo-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.35em 0.9em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
cursor: pointer;
transition: opacity 120ms;
}
.demo-btn:hover {
background: color-mix(in srgb, var(--color-text) 6%, var(--color-bg));
}
.demo-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-on-primary);
}
.demo-btn[data-pending] {
cursor: progress;
opacity: 0.75;
}
/* CSS-only pending: the label hides and a spinner takes its place, no function children. */
.demo-btn-css-spinner {
position: relative;
}
.demo-btn-css-spinner[data-pending] {
color: transparent;
}
.demo-btn-css-spinner[data-pending]::after {
content: "";
position: absolute;
inset: 0;
margin: auto;
inline-size: 0.9em;
block-size: 0.9em;
border: 2px solid var(--color-text);
border-right-color: transparent;
border-radius: 50%;
animation: demo-spin 700ms linear infinite;
}
.demo-btn[data-disabled],
.demo-btn[aria-disabled="true"]:not([data-pending]) {
opacity: 0.5;
cursor: not-allowed;
}
.demo-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.demo-btn {
transition: none;
}
.demo-btn-css-spinner[data-pending]::after {
animation-duration: 2s;
}
}Click Save several times quickly: the action runs once. While it runs the button carries data-pending and aria-disabled="true" and keeps focus. The second button rejects into an Error Boundary; the third catches the error inside its action, which the library counts as success, and reports it with a toast.
import { Button } from "@funstack/ui/button";
<Button action={save}>Save</Button> {/* <button type="button"> */}One element, no parts. It renders <button type="button">; render substitutes any element, such as render={<a href />} for a link that runs an action. The Button adds no role: a link stays a link to assistive technology.
| Prop | Type | Default | Description |
|---|---|---|---|
action | Action<[details: ButtonActionDetails]> | – | Runs inside the Button's own transition on click, Enter or Space. Sync or async; may be a Server Function (details carries only the virtual event). A rejection reaches the nearest Error Boundary above the Button. While it is pending, further activation is ignored and the Button is aria-disabled="true". Not combinable with formAction (development error). |
formAction | string | ((formData: FormData) => void | Promise<void>) | – | React's formAction: run by React instead of the form's action when this Button submits the form. With a function the Button is pending for its own submissions only, ignores further activation meanwhile, and defaults to type="submit". Prefer a stable function (a Server Function, a useActionState dispatch, useCallback) when the form is also submitted from script with requestSubmit(button); an inline function works for clicks and Enter. The match is by function identity (React reports no submitter), so Buttons that share one function (one dispatch, different name / value) all show pending while either submits; give each its own function (dispatch.bind(null, "draft")) to tell them apart. |
type | "button" | "submit" | "reset" | "button", or "submit" with formAction | Owned on a <button> and on a component render element (assumed to render a <button>; it should spread the prop): set it here, not on the element. Omitted for a host element of another tag (<a>). action together with submit / reset warns in development (one click would do two things). |
disabled | boolean | false | Native disabled and data-disabled; never dispatches, and a disabled link does not navigate. With focusableWhenDisabled, inside a Toolbar, or when render is not a <button> (a link, where native disabled means nothing), renders aria-disabled="true" instead. |
focusableWhenDisabled | boolean | false (true inside a Toolbar.Root) | Keep a disabled Button focusable: aria-disabled="true" instead of native disabled, so keyboard and screen-reader users still find it and a Tooltip on it still opens. |
transition | ViewTransitionClass | – | Forwarded to the Button's <ViewTransition default>: animates the commit that ends the action (content keyed on pending), not the click. |
className | string | ((state: ButtonState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ButtonState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ButtonState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ButtonState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
Every other <button> prop passes through, including name, value, form, commandfor, command, aria-busy and every event handler but onClick (pass-through handlers keep firing while pending). onClick and aria-disabled are the Button's and are not props. ButtonState is { pending, disabled }; ButtonActionDetails is { event }.
| Attribute | Meaning |
|---|---|
data-pending | The Button's own action is in flight (until the commit that ends it, including a Suspense wait), or a submission it started with its own function formAction is pending. |
data-disabled | The disabled prop. Not set by pending. |
aria-disabled | "true" while pending, and while disabled with focusableWhenDisabled, inside a Toolbar, or on a non-<button> render element. |
Pending is not disabled: [data-disabled] and state.disabled are your disabled only, so the two states never have to look alike. Owned keys (a render element cannot override them): type (on a <button>), disabled, aria-disabled, onClick, data-pending, data-disabled.
While the Button's own action is in flight, a click, Enter or Space (also a programmatic click() and the click an implicit form submission sends) starts no second run and is preventDefault()ed, so a link does not navigate again, a form does not submit again and a command does not run again. Nothing else is blocked: focus, hover and your own event handlers work as usual, and ancestors still see the click.
aria-disabled="true" meanwhile, so a screen-reader user who presses again, or comes back to it, hears that it is unavailable. It is never natively disabled: Chromium and Firefox move focus to the page when a focused button becomes disabled.data-pending and aria-disabled in that window, but its own work has settled, so a click there starts a new run rather than being dropped. A formAction Button follows React's form status instead and stays guarded until its submission commits.action={() => { void increment(); }}. The run is over a moment after the click, so every click runs the action, even while an unrelated action elsewhere holds the Button's commit (then only the pending look lingers until that commit lands). An ordered sequence whose steps see each other's results is useActionState (pass its dispatch as formAction, or call it from the action)."use client";
import { startTransition, useState } from "react";
import { Button } from "@funstack/ui/button";
import { Form } from "@funstack/ui/form";
import { fakeSave } from "./fake-api.js";
// One form, three submitters. Form.Submit runs the form's own action and is pending for every
// submission of the form (the form channel). Each Button formAction runs its own function instead
// and is pending for its own submissions only; a second click on a pending one is ignored.
export function ButtonFormActions() {
const [log, setLog] = useState<string[]>([]);
const record = (line: string) => startTransition(() => setLog((l) => [line, ...l].slice(0, 4)));
return (
<div className="demo-stack">
<Form.Root
className="demo-stack"
action={async (data: FormData) => {
await fakeSave("the post", { ms: 1500 });
record(`Published “${String(data.get("title"))}”.`);
}}
>
<label className="demo-field">
Title <input name="title" defaultValue="Hello, world" required />
</label>
<div className="demo-row">
<Form.Submit className="demo-btn demo-btn-primary">
{({ pending }) => (pending ? "Working…" : "Publish")}
</Form.Submit>
<Button
className="demo-btn"
formAction={async (data: FormData) => {
await fakeSave("the draft", { ms: 1500 });
record(`Saved a draft of “${String(data.get("title"))}”.`);
}}
>
{({ pending }) => (pending ? "Saving draft…" : "Save draft")}
</Button>
<Button
className="demo-btn"
formAction={async () => {
await fakeSave("the discard", { ms: 2500 });
record("Discarded.");
}}
>
{({ pending }) => (pending ? "Discarding…" : "Discard")}
</Button>
</div>
</Form.Root>
<ul className="demo-readout">
{log.length === 0 ? <li>Nothing yet.</li> : log.map((line, i) => <li key={i}>{line}</li>)}
</ul>
</div>
);
}A Button is type="button" by default, so an action Button never submits the form it sits in. With formAction it is a submit button that runs its own function instead of the form's action: constraint validation, FormData and React's automatic form reset all apply. It is pending for its own submissions only (React reports the submitter's function through useFormStatus().action).
| Form.Submit | Button formAction | Button type="submit" | |
|---|---|---|---|
| Submits | the form's own action | its own function instead | the form's own action |
pending | the form channel: any submitter | its own submissions only | none |
| Re-entry while pending | not blocked (a useActionState dispatch queues it, in order) | ignored | none |
| Use | the one primary submit button | secondary submitters | rarely; Form.Submit is the documented spelling |
Inside a Form.Root, a Button formAction goes React's way: its function runs, the form's action does not, and Form.Root shows data-pending as for any submission (its errors are not fed by it; to route field errors, pass the dispatch of the same useActionState as formAction). Implicit submission (Enter in a text field) clicks the form's first submit button: if that is a pending Button formAction, the click is ignored and nothing is submitted twice.
"use client";
import { startTransition, useState } from "react";
import { Button } from "@funstack/ui/button";
import { Dialog } from "@funstack/ui/dialog";
import { Tooltip } from "@funstack/ui/tooltip";
import { fakeSave } from "./fake-api.js";
// A Trigger that renders a Button: one click runs the Button's action and opens the dialog. The
// action does not await its work (`void`), because React commits the open together with the
// transition the click started: an awaited action would hold the dialog closed until it settles.
// The Tooltip on the Archive Button keeps opening on hover and focus while the Button is pending.
export function ButtonCompose() {
const [prefetches, setPrefetches] = useState(0);
return (
<div className="demo-row">
<Dialog.Root defaultOpen={false}>
<Dialog.Trigger
render={
<Button
className="demo-btn"
action={() => {
void fakeSave("the profile").then(() =>
startTransition(() => setPrefetches((n) => n + 1)),
);
}}
/>
}
>
Edit profile…
</Dialog.Trigger>
<Dialog.Popup className="demo-dialog">
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Profiles prefetched so far: {prefetches}.</Dialog.Description>
<div className="demo-dialog-actions">
<Dialog.Close className="demo-button">Close</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Root>
<Tooltip.Root>
<Tooltip.Trigger
render={
<Button className="demo-btn" action={() => fakeSave("the archive", { ms: 3000 })} />
}
>
Archive
</Tooltip.Trigger>
<Tooltip.Popup className="demo-tip">Moves the thread out of your inbox</Tooltip.Popup>
</Tooltip.Root>
</div>
);
}<Dialog.Trigger render={<Button action={a} />}> runs a and opens the dialog on one click: the Trigger's handler runs first and cancels the click (its command), and the Button runs its action anyway; a cancelled click is not a veto for a Button. The two updates start in one event, so React commits them together: an awaited a holds the dialog closed until it settles (the Trigger already shows data-open and data-pending). For a prefetch, do not await it, as above. The same works with Popover.Trigger and Tooltip.Trigger; a Tooltip keeps opening on hover and focus while the Button is pending, because a pending Button is not natively disabled and removes no listeners.
commandfor / command pass through: without action the browser runs the command, before and after hydration. With both, one click runs the action and the command; while pending, neither runs again. Inside a Toolbar a disabled Button stays focusable and in the arrow-key sequence (aria-disabled, the APG Toolbar rule).
| Key | Native <button> | render={<a href>} |
|---|---|---|
| Enter | Click: runs action (unless pending or disabled) | Click: action and navigation; while pending, neither |
| Space | Click on keyup: runs action | Nothing (a link); add role="button" and a key handler if a link must act on Space |
| Tab | Sequential, or the Toolbar's single Tab stop inside one | Same |
Focus never moves because of pending. The library announces nothing when an action starts or ends; the outcome is yours, and that is where status messages belong: a toast (announced by its Viewport), a field error, content that replaces the button, or ariaNotify from the action. aria-busy is not set by default (screen readers either ignore it or may hide the button's name); pass it yourself if you want it. A label that changes with pending ("Saving…") is your choice, and like any name change on the focused element it is not reliably spoken.
// No "use client": this file is a Server Component. It renders Button (a client module) with
// serialisable props only: `commandfor` / `command` are plain attributes, so the browser opens
// and closes the dialog before hydration and with JavaScript off. A Server Function would also be
// a valid `action` or `formAction` here; this static site has none.
import { Button } from "@funstack/ui/button";
import { ButtonCssPending } from "./ButtonCssPending.js";
export function ButtonInvoker() {
return (
<div className="demo-row">
<Button className="demo-btn" commandfor="button-invoker-preview" command="show-modal">
Preview
</Button>
<dialog id="button-invoker-preview" className="demo-dialog" aria-labelledby="bip-title">
<h2 id="bip-title">Preview</h2>
<p>Opened by the browser's Invoker Command, no JavaScript involved.</p>
<div className="demo-dialog-actions">
<Button className="demo-btn" commandfor="button-invoker-preview" command="close">
Close
</Button>
</div>
</dialog>
{/* Pending styled by [data-pending] CSS only (no function children). */}
<ButtonCssPending />
</div>
);
}ButtonInvoker.tsx has no "use client": it is a Server Component that renders Button (a client module) with serialisable props. The Preview button opens the dialog through the browser's Invoker Commands, before hydration and without JavaScript. Sync shows a pending look made by [data-pending] CSS alone (ButtonCssPending.tsx below; its fake action is the only reason it is a client module on this static site). A Server Component can also pass a Server Function as action (it receives details with event undefined) or as formAction (progressively enhanced: the button submits before hydration), and style pending with [data-pending] CSS. It cannot pass function children, className, style or render, nor a plain client function as action: the spinner-in-children pattern needs a Client Component.
"use client";
import { Button } from "@funstack/ui/button";
import { fakeSave } from "./fake-api.js";
// A Button whose pending look is CSS only: static children, a string className, and a spinner
// drawn by `.demo-btn-css-spinner[data-pending]::after` in button.css. In an app with Server
// Functions a Server Component renders this directly: `<Button action={sync} ...>`. This static
// site has none, so the fake action lives in this small client module.
export function ButtonCssPending() {
return (
<Button className="demo-btn demo-btn-css-spinner" action={() => fakeSave("the sync")}>
Sync
</Button>
);
}// A Server Component
import { Button } from "@funstack/ui/button";
import { archive } from "./actions"; // "use server"
<Button action={archive.bind(null, post.id)} className="row-action">Archive</Button>Before hydration an action Button does nothing on click; a Server Function formAction submits natively, a command Button runs its command and a link navigates. The server HTML carries no aria-disabled and no data-pending.
.btn[data-pending] { cursor: progress; }
.btn[data-pending] .label { visibility: hidden; }
.btn[data-pending]::after { content: ""; /* spinner */ }
.btn[data-disabled], .btn[aria-disabled="true"]:not([data-pending]) { opacity: .5; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Tailwind: data-pending:cursor-progress data-disabled:opacity-50. With transition, keep the reduced-motion block in your stylesheet; the Button adds no view transition types.