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
  • 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 ↗

Progress

A progress bar (WAI-ARIA progressbar) and the library's first output-only component: nothing is pressed or typed, so there is no action, no pending state and no optimistic state. A Progress is the display of someone else's action. It needs no JavaScript at all: Progress.Root has no "use client", so a Server Component renders it on the server.

Show code
ProgressUpload.tsx
"use client";
import { useState, useTransition } from "react";
import { Progress } from "@funstack/ui/progress";
import { fakeUpload } from "./fake-api.js";

const megabytes = { style: "unit", unit: "megabyte", maximumFractionDigits: 1 } as const;

// An upload driven by useTransition. The bar mounts in the same render that flips `pending`, is
// indeterminate until the size is known, then follows each chunk. Every chunk is written with a
// plain setState AFTER an await, which is urgent and renders at once. Wrapping it in
// startTransition would hold it until the upload ends (0% ... 100%), because React holds
// transition updates made while an async action is pending.
export function ProgressUpload() {
  const [size, setSize] = useState<number | null>(null);
  const [sent, setSent] = useState(0);
  const [pending, startUpload] = useTransition();

  const upload = () => {
    // Reset before the action, outside it: a write in its synchronous prefix would be held.
    setSize(null);
    setSent(0);
    startUpload(async () => {
      for await (const progress of fakeUpload("report.pdf")) {
        setSize(progress.total); // after an await: urgent
        setSent(progress.sent);
        // Not: startTransition(() => setSent(progress.sent)), which is held until the end.
      }
    });
  };

  return (
    <div className="demo-stack">
      <button type="button" className="demo-button demo-primary" onClick={upload}>
        {pending ? "Uploading…" : "Upload report.pdf"}
      </button>
      {(pending || size !== null) && (
        <Progress.Root
          className="demo-progress"
          value={size === null ? null : sent / 1e6}
          max={size === null ? undefined : size / 1e6}
          format={megabytes}
        >
          <div className="demo-progress-head">
            <Progress.Label>Uploading report.pdf</Progress.Label>
            <Progress.Value>
              {({ formattedValue, max, complete }) =>
                formattedValue === null
                  ? "Preparing…"
                  : complete
                    ? "Done"
                    : `${formattedValue} of ${max.toFixed(1)} MB`
              }
            </Progress.Value>
          </div>
          <Progress.Track className="demo-progress-track">
            <Progress.Indicator className="demo-progress-fill" />
          </Progress.Track>
        </Progress.Root>
      )}
    </div>
  );
}
progress.css
/* Progress: the library publishes the percentage as --progress-value on the Root (inherited,
   absent while indeterminate) and sizes nothing; this CSS does. Registering the property lets
   the variable itself transition, in every engine. */
@property --progress-value {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

.demo-progress {
  display: grid;
  gap: 0.35rem;
  inline-size: min(24rem, 100%);
  transition: --progress-value 200ms ease-out;
}

.demo-progress-head {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  color: var(--color-text);
  font-size: 0.9rem;
}
.demo-progress-head [aria-hidden] {
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
}

/* Linear */
.demo-progress-track {
  position: relative;
  block-size: 8px;
  border-radius: 4px;
  overflow: hidden;
  background: var(--color-border);
}
.demo-progress-fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: var(--progress-value, 0%);
  border-radius: inherit;
  background: var(--color-primary);
}

/* Complete */
.demo-progress-fill[data-complete] {
  background: #2f9e44;
}

/* Indeterminate: a sliding segment. */
.demo-progress-fill[data-indeterminate] {
  inset-inline-start: auto;
  inline-size: 30%;
  animation: demo-progress-slide 1.2s ease-in-out infinite;
}
@keyframes demo-progress-slide {
  from {
    translate: -100% 0;
  }
  to {
    translate: 400% 0;
  }
}

/* Circular: the same variable, no SVG. While indeterminate there is no --progress-value (the
   registered property falls back to its initial 0%), so the spinner draws a fixed arc. */
.demo-progress-ring {
  inline-size: 32px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(var(--color-primary) var(--progress-value), var(--color-border) 0);
  mask: radial-gradient(farthest-side, transparent calc(100% - 5px), black calc(100% - 5px));
}
.demo-progress-ring[data-indeterminate] {
  background: conic-gradient(var(--color-primary) 25%, var(--color-border) 0);
  animation: demo-progress-spin 1s linear infinite;
}
@keyframes demo-progress-spin {
  to {
    rotate: 1turn;
  }
}

@media (prefers-reduced-motion: reduce) {
  .demo-progress {
    transition: none;
  }
  .demo-progress-fill[data-indeterminate] {
    animation: none;
    inset-inline-start: 0;
    inline-size: 100%;
    opacity: 0.5;
  }
  .demo-progress-ring[data-indeterminate] {
    animation: none;
    opacity: 0.5;
  }
}

Report progress with a plain setState, not startTransition

Every other page tells you to wrap updates in startTransition. A value that reports on a pending action is the exception: React holds every transition update made while an async action is pending until that action settles, so a wrapped progress update sits at 0% and jumps to 100% when the upload ends. A setState after an await inside the action is urgent already, so the natural code is the right code:

startUpload(async () => {
  for await (const sent of upload(file)) setSent(sent);                       // renders at once
  // for await (const sent of upload(file)) startTransition(() => setSent(sent)); // held: 0% … 100%
});

A setState in the action's synchronous prefix (before the first await) is a transition update and is held too. Set a meaningful starting value (a resumed upload's offset) before calling the action, or let it land with the first chunk. useOptimistic is not a workaround: the value is not optimistic.

Anatomy

import { Progress } from "@funstack/ui/progress";

<Progress.Root value={uploaded} max={file.size} format={{ style: "unit", unit: "megabyte" }}>
  <Progress.Label>Uploading {file.name}</Progress.Label>  {/* span[id]; the Root references it */}
  <Progress.Value />                                    {/* span[aria-hidden]: "3.2 MB" */}
  <Progress.Track>                                      {/* div: the rail */}
    <Progress.Indicator />                              {/* span: the fill, sized by your CSS */}
  </Progress.Track>
</Progress.Root>

// A spinner: indeterminate (no value), named by aria-label, no parts needed.
<Progress.Root aria-label="Loading messages" className="spinner" />
  • Not the native element. <progress> renders no children, its fill is a vendor pseudo-element that Firefox cannot transition, and its indeterminate look differs per engine. The Root is a div with role="progressbar", which assistive technology treats exactly like the native element.
  • value is optional. Absent or null means indeterminate: no aria-valuenow, no aria-valuetext, data-indeterminate, and no --progress-value. Switching to a number is an ordinary re-render: value={total === null ? null : done}.
  • max defaults to 100 (the platform's is 1). The value is clamped to [min, max] like the platform clamps it.
  • Nothing is positioned by the library. The Root publishes --progress-value (a percentage, inherited); your CSS decides whether the fill is a width, a scale, a conic-gradient or an SVG stroke (see progress.css above).

Busy while a form submits

An indeterminate bar from Form.Root's children(state). The progressbar never sets aria-busy: the region being replaced does, and points at the bar with aria-describedby (the APG's advice). Progress does not read the form status by itself, so a Progress inside a form is not tied to it by accident.

Saved name: Ada Lovelace

Show code
ProgressForm.tsx
"use client";
import { useState } from "react";
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import { Progress } from "@funstack/ui/progress";
import { sleep } from "./fake-api.js";

// An indeterminate bar while the form submits, read from Form.Root's children(state). The
// region being replaced carries aria-busy and is described by the bar (the APG's advice): the
// progressbar never sets aria-busy itself.
export function ProgressForm() {
  const [saved, setSaved] = useState("Ada Lovelace");
  return (
    <Form.Root
      className="demo-form"
      action={async (formData) => {
        await sleep(1500);
        setSaved(String(formData.get("name")));
      }}
    >
      {({ pending }) => (
        <>
          <Field.Root name="name" required className="demo-fieldset">
            <Field.Label className="demo-label">Display name</Field.Label>
            <Field.Control defaultValue={saved} autoComplete="off" className="demo-input" />
          </Field.Root>
          <div className="demo-row">
            <Form.Submit className="demo-button demo-primary">Save</Form.Submit>
            {pending && (
              <Progress.Root id="saving" aria-label="Saving" className="demo-progress demo-grow">
                <Progress.Track className="demo-progress-track">
                  <Progress.Indicator className="demo-progress-fill" />
                </Progress.Track>
              </Progress.Root>
            )}
          </div>
          <p
            className="demo-readout"
            aria-busy={pending}
            aria-describedby={pending ? "saving" : undefined}
          >
            Saved name: <strong>{saved}</strong>
          </p>
        </>
      )}
    </Form.Root>
  );
}
progress.css
/* Progress: the library publishes the percentage as --progress-value on the Root (inherited,
   absent while indeterminate) and sizes nothing; this CSS does. Registering the property lets
   the variable itself transition, in every engine. */
@property --progress-value {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

.demo-progress {
  display: grid;
  gap: 0.35rem;
  inline-size: min(24rem, 100%);
  transition: --progress-value 200ms ease-out;
}

.demo-progress-head {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  color: var(--color-text);
  font-size: 0.9rem;
}
.demo-progress-head [aria-hidden] {
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
}

/* Linear */
.demo-progress-track {
  position: relative;
  block-size: 8px;
  border-radius: 4px;
  overflow: hidden;
  background: var(--color-border);
}
.demo-progress-fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: var(--progress-value, 0%);
  border-radius: inherit;
  background: var(--color-primary);
}

/* Complete */
.demo-progress-fill[data-complete] {
  background: #2f9e44;
}

/* Indeterminate: a sliding segment. */
.demo-progress-fill[data-indeterminate] {
  inset-inline-start: auto;
  inline-size: 30%;
  animation: demo-progress-slide 1.2s ease-in-out infinite;
}
@keyframes demo-progress-slide {
  from {
    translate: -100% 0;
  }
  to {
    translate: 400% 0;
  }
}

/* Circular: the same variable, no SVG. While indeterminate there is no --progress-value (the
   registered property falls back to its initial 0%), so the spinner draws a fixed arc. */
.demo-progress-ring {
  inline-size: 32px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(var(--color-primary) var(--progress-value), var(--color-border) 0);
  mask: radial-gradient(farthest-side, transparent calc(100% - 5px), black calc(100% - 5px));
}
.demo-progress-ring[data-indeterminate] {
  background: conic-gradient(var(--color-primary) 25%, var(--color-border) 0);
  animation: demo-progress-spin 1s linear infinite;
}
@keyframes demo-progress-spin {
  to {
    rotate: 1turn;
  }
}

@media (prefers-reduced-motion: reduce) {
  .demo-progress {
    transition: none;
  }
  .demo-progress-fill[data-indeterminate] {
    animation: none;
    inset-inline-start: 0;
    inline-size: 100%;
    opacity: 0.5;
  }
  .demo-progress-ring[data-indeterminate] {
    animation: none;
    opacity: 0.5;
  }
}

From a Server Component

This example has no "use client". Progress.Root has none either, so it ran on the server, once: the getAriaValueText function was passed from a Server Component (it never crosses to the browser), Intl formatted the numbers where the data is, and the arithmetic is not in the client bundle. The parts read the Root through a context, so they are small client modules; which of them reach the browser depends on how your bundler emits client references.

Generate thumbnails34%
Rebuild search index100%
Export invoices0%
Waiting for the next job
Show code
ProgressJobs.tsx
// No "use client": this file is a Server Component, and so is Progress.Root, which has no
// directive. The Root ran on the server: `getAriaValueText` below is a plain function passed from
// a Server Component (allowed, because it never crosses to the client), and Intl formatted the
// numbers where the data is. Only the small context module and the parts reach the browser.
import { Progress } from "@funstack/ui/progress";

// "Server data": a job queue as a database would return it.
const jobs = [
  { id: "thumbs", name: "Generate thumbnails", done: 412, total: 1200 },
  { id: "index", name: "Rebuild search index", done: 87_300, total: 87_300 },
  { id: "export", name: "Export invoices", done: 0, total: 3 },
];

export function ProgressJobs() {
  return (
    <div className="demo-stack">
      {jobs.map((job) => (
        <Progress.Root
          key={job.id}
          className="demo-progress"
          value={job.done}
          max={job.total}
          locale="en-US"
          format={{ style: "percent", maximumFractionDigits: 0 }}
          getAriaValueText={(value, { formattedValue, complete }) =>
            complete ? "Finished" : `${formattedValue}, ${value} of ${job.total} items`
          }
        >
          <div className="demo-progress-head">
            <Progress.Label>{job.name}</Progress.Label>
            <Progress.Value />
          </div>
          <Progress.Track className="demo-progress-track">
            <Progress.Indicator className="demo-progress-fill" />
          </Progress.Track>
        </Progress.Root>
      ))}
      <div className="demo-row">
        {/* A spinner: indeterminate, named by aria-label, no parts. */}
        <Progress.Root aria-label="Waiting for the next job" className="demo-progress-ring" />
        <span className="demo-readout" aria-hidden="true">
          Waiting for the next job
        </span>
      </div>
    </div>
  );
}
progress.css
/* Progress: the library publishes the percentage as --progress-value on the Root (inherited,
   absent while indeterminate) and sizes nothing; this CSS does. Registering the property lets
   the variable itself transition, in every engine. */
@property --progress-value {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}

.demo-progress {
  display: grid;
  gap: 0.35rem;
  inline-size: min(24rem, 100%);
  transition: --progress-value 200ms ease-out;
}

.demo-progress-head {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  color: var(--color-text);
  font-size: 0.9rem;
}
.demo-progress-head [aria-hidden] {
  color: var(--color-text-secondary);
  font-variant-numeric: tabular-nums;
}

/* Linear */
.demo-progress-track {
  position: relative;
  block-size: 8px;
  border-radius: 4px;
  overflow: hidden;
  background: var(--color-border);
}
.demo-progress-fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: var(--progress-value, 0%);
  border-radius: inherit;
  background: var(--color-primary);
}

/* Complete */
.demo-progress-fill[data-complete] {
  background: #2f9e44;
}

/* Indeterminate: a sliding segment. */
.demo-progress-fill[data-indeterminate] {
  inset-inline-start: auto;
  inline-size: 30%;
  animation: demo-progress-slide 1.2s ease-in-out infinite;
}
@keyframes demo-progress-slide {
  from {
    translate: -100% 0;
  }
  to {
    translate: 400% 0;
  }
}

/* Circular: the same variable, no SVG. While indeterminate there is no --progress-value (the
   registered property falls back to its initial 0%), so the spinner draws a fixed arc. */
.demo-progress-ring {
  inline-size: 32px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(var(--color-primary) var(--progress-value), var(--color-border) 0);
  mask: radial-gradient(farthest-side, transparent calc(100% - 5px), black calc(100% - 5px));
}
.demo-progress-ring[data-indeterminate] {
  background: conic-gradient(var(--color-primary) 25%, var(--color-border) 0);
  animation: demo-progress-spin 1s linear infinite;
}
@keyframes demo-progress-spin {
  to {
    rotate: 1turn;
  }
}

@media (prefers-reduced-motion: reduce) {
  .demo-progress {
    transition: none;
  }
  .demo-progress-fill[data-indeterminate] {
    animation: none;
    inset-inline-start: 0;
    inline-size: 100%;
    opacity: 0.5;
  }
  .demo-progress-ring[data-indeterminate] {
    animation: none;
    opacity: 0.5;
  }
}

Props

Progress.Root

PropTypeDefaultDescription
valuenumber | nullnullThe current value, clamped to [min, max]. Absent or null: indeterminate. A non-finite number warns in development and renders as indeterminate.
minnumber0The start of the range.
maxnumber100The end of the range (the platform's default is 1). max <= min warns in development and renders 0%, never complete.
formatIntl.NumberFormatOptions{ style: "percent" }Formats aria-valuetext and Progress.Value. A percent style formats the fraction of the range; any other style formats the value ({ style: "unit", unit: "megabyte" } → "3.2 MB"). Serialisable: a Server Component may pass it.
localestringthe runtime'sBCP 47 tag for Intl.NumberFormat. Pass it when a client component is server-rendered, so the server's and the browser's text agree at hydration. Under React Server Components the Root runs once and there is nothing to mismatch.
getAriaValueText(value: number | null, state: ProgressState) => string | null | undefined–The aria-valuetext to render instead of formattedValue. Called with the clamped value (null while indeterminate) and the state as it would be without this prop; null or undefined renders no attribute. From a Server Component it works when that Server Component renders the Root.
classNamestring | ((state: ProgressState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ProgressState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ProgressState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: ProgressState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

Owned: role, aria-valuemin, aria-valuemax, aria-valuenow, aria-valuetext, data-indeterminate, data-complete, and aria-labelledby (your tokens first, then the Label's id). Your style wins over --progress-value per property. Everything else passes through to the div.

Progress.Track, Progress.Indicator, Progress.Label

A div, a span and a span, with the state's data attributes. None has a role or an inline size. The Label's id is owned: the Root always references it.

PropTypeDefaultDescription
classNamestring | ((state: ProgressState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ProgressState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ProgressState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: ProgressState) => ReactNode)–Replace the default <div | span> (element form) or take over rendering (function form).

Progress.Value

PropTypeDefaultDescription
classNamestring | ((state: ProgressState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ProgressState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ProgressState) => ReactNode)–Default: state.formattedValue (nothing while indeterminate), or a function of the state.
renderReactElement | ((props, state: ProgressState) => ReactNode)–Replace the default <span> (element form) or take over rendering (function form).
aria-hiddenbooleantrueHidden by default because the same text is in the Root's aria-valuetext. Pass false when you render something the attribute does not say.

State

FieldTypeMeaning
valuenumber | nullThe value clamped to [min, max]; null while indeterminate.
min, maxnumberThe range.
percentagenumber | null0 to 100; null while indeterminate.
formattedValuestring | nullThe Intl.NumberFormat text (what Progress.Value shows).
valueTextstring | nullWhat aria-valuetext carries.
indeterminatebooleanvalue is null.
completebooleanThe clamped value reached max.

Every part receives the same state object.

Data attributes and custom properties

NameOnMeaning
data-indeterminateevery partvalue is null.
data-completeevery partThe clamped value reached max.
--progress-valueRoot (inherited)The percentage, 0% to 100%; absent while indeterminate, so a registered @property falls back to its initial-value.

"In progress" is :not([data-indeterminate]):not([data-complete]). Register --progress-value with @property (inherits: true) and transition it on the Root: the variable itself animates in every engine, which the native fill cannot do in Firefox. The recipes in progress.css include a prefers-reduced-motion block with a static indeterminate rendering.

Labelling

A progressbar needs an accessible name. Render a Progress.Label, or pass aria-label / aria-labelledby to the Root. The Root always references the Label's id; with no Label rendered the reference dangles, and the accessible-name computation skips it and uses your aria-label. (That is what lets the Root be a Server Component: it cannot know whether a Label is rendered. For the same reason it cannot warn about a missing name.) The Label is a span: the Root is not a labelable element, so a <label for> would associate nothing.

Inside a Field, a Progress is not a form control and does not register with the Field: it sits next to the Field's control with its own name. See Meter for the password-strength pattern.

What assistive technology hears

  • The upload above: "Uploading report.pdf, progress bar, 3.2 megabytes".
  • Indeterminate: the name, the role and "busy" or "indeterminate" (the screen reader's own word for a progress bar with no value). No English default text is added; pass getAriaValueText={(v) => v === null ? "Connecting" : null} for words.
  • Updates are not announced: a progressbar is not a live region. Report completion with a toast or a live-region message from the action's last line.

Server Components

Progress.Root has no "use client": it uses only useId, arithmetic and Intl, and renders a context provider created in a client module. Progress.Track, Indicator, Label and Value read that context and are client modules. A Server Component may render all of them, and may pass getAriaValueText, children(state) and render functions to the Root, but not to the parts (they are client components: format a part's content in the Root's children(state) instead). Rendered from a client component, the Root is an ordinary component; under SSR of that client tree pass locale.

There is no behaviour to hydrate: the server HTML carries the role, the values, the text, the data attributes and the custom property, and your CSS draws the bar and its animation from the first paint. A Progress also works as a Suspense fallback.

Not in this version

  • A transition prop: values change in your lane, usually urgently, and the fill animates with CSS. Wrap the swap of a finished bar in your own <ViewTransition> if you want one.
  • Circular parts (use conic-gradient from the same variable, or SVG through render), a native <progress> mode, data-value attributes, and Field registration.