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.
"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: 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;
}
}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" /><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.--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).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.
"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: 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;
}
}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.
// 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: 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;
}
}Progress.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | null | The current value, clamped to [min, max]. Absent or null: indeterminate. A non-finite number warns in development and renders as indeterminate. |
min | number | 0 | The start of the range. |
max | number | 100 | The end of the range (the platform's default is 1). max <= min warns in development and renders 0%, never complete. |
format | Intl.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. |
locale | string | the runtime's | BCP 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. |
className | string | ((state: ProgressState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ProgressState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ProgressState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((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.LabelA 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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: ProgressState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ProgressState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ProgressState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ProgressState) => ReactNode) | – | Replace the default <div | span> (element form) or take over rendering (function form). |
Progress.Value| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: ProgressState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ProgressState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ProgressState) => ReactNode) | – | Default: state.formattedValue (nothing while indeterminate), or a function of the state. |
render | ReactElement | ((props, state: ProgressState) => ReactNode) | – | Replace the default <span> (element form) or take over rendering (function form). |
aria-hidden | boolean | true | Hidden by default because the same text is in the Root's aria-valuetext. Pass false when you render something the attribute does not say. |
| Field | Type | Meaning |
|---|---|---|
value | number | null | The value clamped to [min, max]; null while indeterminate. |
min, max | number | The range. |
percentage | number | null | 0 to 100; null while indeterminate. |
formattedValue | string | null | The Intl.NumberFormat text (what Progress.Value shows). |
valueText | string | null | What aria-valuetext carries. |
indeterminate | boolean | value is null. |
complete | boolean | The clamped value reached max. |
Every part receives the same state object.
| Name | On | Meaning |
|---|---|---|
data-indeterminate | every part | value is null. |
data-complete | every part | The clamped value reached max. |
--progress-value | Root (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.
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.
getAriaValueText={(v) => v === null ? "Connecting" : null} for words.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.
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.conic-gradient from the same variable, or SVG through render), a native <progress> mode, data-value attributes, and Field registration.