Progress
Show how far along a task is. An accent fill grows across a recessed track — measured against a value when you know one, sweeping when you don't — and tints to the task's outcome.
Basic
A determinate bar takes a value between 0 and max (default 100). The indicator fills the track to that fraction and eases as the value changes. labels="top" lays the optional Progress.Label + Progress.Value out in a header row above the track — no wrapper needed.
"use client";
import { Progress } from "@stridge/noctis";
export default function ProgressBasic() {
return (
// `labels="top"` lays the caption row out above the track — no wrapper div — and the Value render
// child reads the percentage straight off the value.
<Progress.Root value={66} aria-label="Sync progress" labels="top" className="max-w-sm">
<Progress.Label>Syncing</Progress.Label>
<Progress.Value>{(_formatted, value) => `${value}%`}</Progress.Value>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
);
}
Labels
labels="top" (the default) stacks the caption row above the track; labels="side" flanks the track on one line. The root owns the layout, so the same parts compose either way, and a bar with no caption collapses to just the track.
"use client";
import { Progress } from "@stridge/noctis";
// `top` (the default) stacks the caption row above the track; `side` flanks the track on one line. The
// root owns the layout, so the same Label + Value + Track compose either way — no wrapper.
const PLACEMENTS: Progress.Labels[] = ["top", "side"];
export default function ProgressLabels() {
return (
<div className="flex w-full max-w-sm flex-col gap-8">
{PLACEMENTS.map((labels) => (
<Progress.Root key={labels} value={62} labels={labels} aria-label={`Uploading (${labels})`}>
<Progress.Label>Uploading</Progress.Label>
<Progress.Value>{(_formatted, value) => `${value}%`}</Progress.Value>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
))}
</div>
);
}
Sizes
Two track thicknesses — md (the default) and sm, a thinner rail for dense rows and inline status. Both follow the radius knob, so a RadiusScope retunes them; for an arbitrary height, pass thickness (a number is px, a string is any CSS length).
"use client";
import { Progress } from "@stridge/noctis";
const SIZES: Progress.Size[] = ["sm", "md"];
export default function ProgressSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{SIZES.map((size) => (
<Progress.Root key={size} value={size === "sm" ? 35 : 70} size={size} aria-label={`Progress ${size}`}>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
))}
</div>
);
}
Tone
A progress bar is a task, so its colour signals the task's outcome. neutral (the default) keeps the active accent; danger marks a failed task, warning an at-risk one, success a finished one. Tone is an explicit choice — never inferred — mirroring Meter's status vocabulary.
"use client";
import { Progress } from "@stridge/noctis";
// A meter is a reading; a progress bar is a task — so its colour signals the task's outcome. neutral is
// the active default (accent); success/warning/danger mark a finished, at-risk, or failed task.
const TASKS = [
{ tone: "neutral", label: "Uploading", value: 60 },
{ tone: "warning", label: "Running low on space", value: 88 },
{ tone: "danger", label: "Upload failed", value: 40 },
{ tone: "success", label: "Synced", value: 100 },
] as const;
export default function ProgressTone() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{TASKS.map((task) => (
<Progress.Root key={task.label} value={task.value} tone={task.tone} labels="top" aria-label={task.label}>
<Progress.Label>{task.label}</Progress.Label>
<Progress.Value>{(_formatted, value) => `${value}%`}</Progress.Value>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
))}
</div>
);
}
Completion
When the value reaches max, every part carries data-complete and the fill plays a one-shot settle (dropped under reduced motion). "Done" isn't automatically "good", so the hue stays accent by default — signal a persistent completed state by pairing tone="success" with a "Complete" value, as here.
"use client";
import { Progress } from "@stridge/noctis";
import { useEffect, useState } from "react";
// A live bar ticking to 100%. At completion it adopts tone="success" and the value reads "Complete" —
// the persistent done signal — while the fill plays its one-shot settle (dropped under reduced motion).
export default function ProgressCompletion() {
const [value, setValue] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setValue((v) => (v >= 100 ? 0 : Math.min(100, v + 5)));
}, 350);
return () => clearInterval(id);
}, []);
const done = value >= 100;
return (
<Progress.Root value={value} tone={done ? "success" : "neutral"} labels="top" aria-label="Export" className="max-w-sm">
<Progress.Label>Exporting</Progress.Label>
<Progress.Value>{() => (done ? "Complete" : `${value}%`)}</Progress.Value>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
);
}
Indeterminate
When you can't measure progress — a request in flight, a job with no known length — pass value={null}. The indicator drops its measured width and an eased sweep keeps the rail covered. The sweep respects prefers-reduced-motion, holding a steady sliver instead.
"use client";
import { Progress } from "@stridge/noctis";
export default function ProgressIndeterminate() {
return (
<Progress.Root value={null} aria-label="Loading" className="max-w-sm">
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
);
}
Formatting the value
Set max to count against a real total and format to render the value in any Intl.NumberFormat style. For a non-percentage bar, wire getAriaValueText so a screen reader announces the human value ("384 of 512 megabytes") rather than a bare percentage — the value formats and announces for the reader's locale.
"use client";
import { Progress } from "@stridge/noctis";
export default function ProgressFormattedValue() {
return (
// `max` counts against the real total and `format` renders the value in its own units. A custom
// `getAriaValueText` makes a screen reader announce the human value ("384 of 512 megabytes")
// instead of the bare "75 percent".
<Progress.Root
value={384}
max={512}
format={{ style: "unit", unit: "megabyte" }}
getAriaValueText={(_formatted, value) => `${value} of 512 megabytes`}
labels="top"
className="max-w-sm"
>
<Progress.Label>Downloading</Progress.Label>
<Progress.Value />
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
);
}
Controlled
Progress is display-only — it is never focusable and has no keyboard interaction (the correct ARIA outcome for a progressbar). The interactive surface is your own control; drive the value from it and the fill eases to each reading.
"use client";
import { Progress, Slider } from "@stridge/noctis";
import { useState } from "react";
// Progress is display-only — never focusable. The interactive surface is the consumer's own control;
// here a Slider drives the value so the fill eases to each new reading in real time.
export default function ProgressControlled() {
const [value, setValue] = useState(50);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Progress.Root value={value} labels="top" aria-label="Buffered">
<Progress.Label>Buffered</Progress.Label>
<Progress.Value>{(_formatted, v) => `${v}%`}</Progress.Value>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
<Slider.Root value={value} onValueChange={(next) => setValue(next as number)} aria-label="Set progress">
<Slider.Control>
<Slider.Track>
<Slider.Indicator />
</Slider.Track>
<Slider.Thumb />
</Slider.Control>
</Slider.Root>
</div>
);
}
Tone from value
Progress keeps tone explicit — map it from the value in your own code, mirroring the quota breakpoints your warnings already use, so the bar turns at the same threshold a note fires. (For a measurement with healthy zones rather than a task, reach for Meter, which derives this for you.)
"use client";
import { Progress } from "@stridge/noctis";
// Progress keeps tone explicit — map it from the value in your own code, mirroring the quota breakpoints
// your warnings already use, so the bar turns at the same threshold a note fires. (For a measurement
// with healthy zones rather than a task, reach for Meter, which derives this for you.)
function toneForUsage(percent: number): Progress.Tone {
if (percent >= 90) return "danger";
if (percent >= 75) return "warning";
return "neutral";
}
const USAGE = [
{ label: "Documents", value: 42 },
{ label: "Photos", value: 81 },
{ label: "System", value: 96 },
] as const;
export default function ProgressValueBands() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{USAGE.map((row) => (
<Progress.Root
key={row.label}
value={row.value}
tone={toneForUsage(row.value)}
labels="top"
aria-label={row.label}
>
<Progress.Label>{row.label}</Progress.Label>
<Progress.Value>{(_formatted, value) => `${value}%`}</Progress.Value>
<Progress.Track>
<Progress.Indicator />
</Progress.Track>
</Progress.Root>
))}
</div>
);
}
Anatomy
Compose a progress bar from its parts. Progress.Root owns the value (a number, or null for indeterminate), the max/min bounds, the value format, and the shared tone/size/labels.
Progress.Root— the container and theprogressbar. Props:value(required;nullis indeterminate),max(default100),min(default0),format,getAriaValueText/aria-valuetext,locale,tone(defaultneutral),size(defaultmd),labels(defaulttop),thickness, plus the Base UIProgress.Rootprops. Name it withProgress.Labelor anaria-label.Progress.Track— the recessed rail. Holds a singleProgress.Indicator.Progress.Indicator— the fill. Base UI sizes its width to the value; the resolvedtonecolours it, and it eases as the value changes. An indeterminate bar sweeps it.Progress.Label— the bar's visible name. Laid out by the root'slabelsaxis.Progress.Value— the formatted completion text. Pass a child function(formattedValue, value) => …to render a custom string.
Every part carries a data-slot (progress, progress-track, progress-indicator, progress-label, progress-value) for host-side styling — pair the root slot with the data-tone/data-size/data-labels axes it stamps, or with the Base UI status attributes (data-indeterminate, data-progressing, data-complete) every part carries. The bar is RTL-aware: the fill grows from the inline start and the sweep mirrors by direction. It is non-interactive by design — never focusable.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The track and fill follow the radius knob (--noctis-radius-control), and the label/value colours, track thickness, and indicator fill are all minted (matching Meter), so they're the public override seam: set one on any ancestor and every progress bar in that region retunes — e.g. .dense { --noctis-progress-track-block-size: 0.1875rem; } thins every bar (or pass thickness per instance). See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI (Track, Indicator, Label, Value) list just the props they pass through. Expand a row for the full type and description.