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

  • Button
  • Toggle
  • ToggleGroup
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Toolbar
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • NumberField
  • Field & Form
  • Progress
  • Meter

Resources

  • Roadmap & changelog
  • GitHub ↗

Button

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.

Action calls: 0. Saves: 0.
Show code
ButtonSave.tsx
"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.css
/* 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.

Anatomy

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.

Props

PropTypeDefaultDescription
actionAction<[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).
formActionstring | ((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 formActionOwned 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).
disabledbooleanfalseNative 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.
focusableWhenDisabledbooleanfalse (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.
transitionViewTransitionClass–Forwarded to the Button's <ViewTransition default>: animates the commit that ends the action (content keyed on pending), not the click.
classNamestring | ((state: ButtonState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ButtonState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ButtonState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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 }.

Data attributes

AttributeMeaning
data-pendingThe 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-disabledThe 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.

Pending and re-entry

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.

  • The Button is 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.
  • The guard is the Button's own run, not its look. React may hold the commit that ends the action while another async action elsewhere is pending (or while the result suspends): the button keeps 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.
  • Want every click to count (a "+1" button)? Do not await the work: 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).

Forms

  • Nothing yet.
Show code
ButtonFormActions.tsx
"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.SubmitButton formActionButton type="submit"
Submitsthe form's own actionits own function insteadthe form's own action
pendingthe form channel: any submitterits own submissions onlynone
Re-entry while pendingnot blocked (a useActionState dispatch queues it, in order)ignorednone
Usethe one primary submit buttonsecondary submittersrarely; 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.

Which button?

  • Submits the form with its own action: Form.Submit.
  • Runs an action, then closes an overlay: Dialog.Close, Popover.Close.
  • An item of a menu: Menu.Item. A Button inside a Menu.Popup is not a menu item.
  • Holds a pressed state: Toggle.
  • A command in a Toolbar: Button itself.
  • Anything else whose click is a transition: Button.

Composition

Moves the thread out of your inbox
Show code
ButtonCompose.tsx
"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).

Keyboard and ARIA

KeyNative <button>render={<a href>}
EnterClick: runs action (unless pending or disabled)Click: action and navigation; while pending, neither
SpaceClick on keyup: runs actionNothing (a link); add role="button" and a key handler if a link must act on Space
TabSequential, or the Toolbar's single Tab stop inside oneSame

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.

Server Components

Preview

Opened by the browser's Invoker Command, no JavaScript involved.

Show code
ButtonInvoker.tsx
// 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&apos;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.

ButtonCssPending.tsx
"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.

Styling

.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.