Rolling percentage, buffer, stepped segments and a tick that draws on completion.
01Preview
q3-forecast.xlsx
1.0 of 5.8 MB · about 14s left
launch-film-4k.mov
Preparing export…
design-review.fig
Upload stopped: the connection dropped.
Connect repository · Step 2 of 4
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react @number-flow/react motion03Usage
import { ProgressBar } from "@/components/ui/progress-bar";
<ProgressBar
value={uploaded}
max={size}
label="q3-forecast.xlsx"
description="2.4 MB of 5.8 MB · about 12s left"
/>
// Size unknown yet
<ProgressBar value={null} aria-label="Preparing export" />
// Steps
<ProgressBar value={2} max={4} segments={4} label="Connect repository" showValue={false} />04Source
"use client";
import { Progress } from "@base-ui/react/progress";
import NumberFlow, { type Format } from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
// The indeterminate sweep is a keyframe (it loops and is never interrupted).
// Under reduced motion it stops traveling and the whole track breathes instead,
// specific enough to outrank a global animation kill switch.
const CSS = `
@keyframes stealth-progress-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
@keyframes stealth-progress-breathe { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.6; } }
.stealth-progress [data-part="sweep"] { animation: stealth-progress-sweep 1.5s cubic-bezier(0.76, 0, 0.24, 1) infinite; }
@media (prefers-reduced-motion: reduce) {
.stealth-progress [data-part="sweep"] { width: 100% !important; transform: none !important; animation: stealth-progress-breathe 2.4s ease-in-out infinite !important; }
}
`;
const HEIGHT = { sm: "h-[3px]", md: "h-1", lg: "h-1.5" } as const;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
export type ProgressBarProps = Omit<React.ComponentProps<"div">, "children"> & {
/** Current value. null means indeterminate: the work has started but its size isn't known. */
value: number | null;
min?: number;
max?: number;
/** Visible label above the track, also its accessible name. Without it, pass aria-label. */
label?: React.ReactNode;
/** A quieter line under the track: "2.4 MB of 5.8 MB · about 12s left". */
description?: React.ReactNode;
/** Show the rolling value at the end of the label row. Defaults to true when there is a label. */
showValue?: boolean;
/** How far ahead something else has got: downloaded vs played, read vs processed. Same scale as value. */
buffer?: number;
/** Split the track into equal steps, for "step 2 of 4". Each step fills in turn. */
segments?: number;
size?: "sm" | "md" | "lg";
/** The work stopped and needs attention. The fill turns the danger color. */
error?: boolean;
/** The work is on hold. The fill steps back to a quieter color. */
paused?: boolean;
/** Intl options for the displayed value. Without them it shows a whole percentage. */
format?: Format;
locale?: Intl.LocalesArgument;
getAriaValueText?: (formattedValue: string, value: number | null) => string;
};
export function ProgressBar({
value,
min = 0,
max = 100,
label,
description,
showValue,
buffer,
segments,
size = "md",
error = false,
paused = false,
format,
locale,
getAriaValueText,
className,
...rest
}: ProgressBarProps) {
const reduce = useReducedMotion();
const indeterminate = value === null || !Number.isFinite(value);
const range = max - min || 1;
const pct = indeterminate ? 0 : clamp01(((value as number) - min) / range) * 100;
const bufferPct = buffer == null ? null : clamp01((buffer - min) / range) * 100;
const complete = !indeterminate && pct >= 100 && !error;
const withValue = showValue ?? label != null;
const state = error ? "error" : paused ? "paused" : complete ? "complete" : indeterminate ? "indeterminate" : "progressing";
// Remember where the fill came from, so segments can fill one after another
// in the direction of travel. Adjusting state during render is React's
// sanctioned way to derive from a changing prop.
const [shown, setShown] = useState(pct);
const [from, setFrom] = useState(pct);
if (pct !== shown) {
setFrom(shown);
setShown(pct);
}
const fill = error ? "bg-danger" : paused ? "bg-fg-3" : "bg-fg";
const suffix = error ? ", failed" : paused ? ", paused" : "";
return (
<Progress.Root
value={indeterminate ? null : value}
min={min}
max={max}
format={format}
locale={locale}
getAriaValueText={(formatted, v) => (getAriaValueText ? getAriaValueText(formatted, v) : `${formatted ?? "Loading"}${suffix}`)}
data-state={state}
data-size={size}
className={cn("stealth-progress flex w-full min-w-0 flex-col gap-2", className)}
{...rest}
>
<style href="stealth-progress-bar" precedence="default">
{CSS}
</style>
{(label != null || withValue) && (
<div className="flex h-[18px] min-w-0 items-center justify-between gap-3">
{label != null ? <Progress.Label className="min-w-0 truncate text-[13px] leading-[18px] text-fg">{label}</Progress.Label> : <span />}
{withValue && (
<Progress.Value
className={cn(
"tabular grid h-[18px] shrink-0 items-center justify-items-end text-[12px] leading-[18px] transition-colors duration-200",
error ? "text-danger" : paused ? "text-fg-3" : "text-fg-2",
)}
>
{() => (
// The number and the tick share one cell, so completing never shifts the row.
<>
<span aria-hidden className="invisible col-start-1 row-start-1">100%</span>
<AnimatePresence initial={false}>
{complete ? (
<motion.span
key="done"
className="col-start-1 row-start-1 flex h-[18px] items-center text-success"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={reduce ? { duration: 0.15 } : spring.pop}
>
<Tick reduce={!!reduce} />
</motion.span>
) : indeterminate ? (
<span key="none" className="col-start-1 row-start-1" />
) : (
<motion.span
key="value"
className="col-start-1 row-start-1"
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: "blur(2px)", transition: { duration: 0.12 } }}
>
<NumberFlow
value={format ? (value as number) : pct / 100}
format={format ?? { style: "percent", maximumFractionDigits: 0 }}
locales={locale}
animated={!reduce}
willChange
/>
</motion.span>
)}
</AnimatePresence>
</>
)}
</Progress.Value>
)}
</div>
)}
{segments && segments > 1 && !indeterminate ? (
<div className={cn("flex w-full gap-[3px]", HEIGHT[size])}>
{Array.from({ length: segments }, (_, i) => {
const f = clamp01((pct / 100) * segments - i);
const start = (from / 100) * segments;
const rising = pct >= from;
// Steps fill in order, 90ms apart, and empty in reverse.
const steps = rising ? Math.max(0, i - Math.floor(start)) : Math.max(0, Math.ceil(start) - 1 - i);
return (
<div key={i} className="relative h-full min-w-0 flex-1 overflow-hidden rounded-full bg-line-2">
<div
className={cn("absolute inset-0 rounded-full transition-[transform,background-color] duration-[360ms] ease-out-quart motion-reduce:duration-0", fill)}
style={{ transform: `translateX(${(f - 1) * 100}%)`, transitionDelay: reduce ? "0ms" : `${steps * 90}ms` }}
/>
</div>
);
})}
</div>
) : (
<Progress.Track className={cn("relative w-full overflow-hidden rounded-full bg-line-2", HEIGHT[size])}>
{bufferPct != null && !indeterminate && (
<div
className="absolute inset-0 rounded-full bg-fg-4 transition-transform duration-500 ease-out-quart"
style={{ transform: `translateX(${bufferPct - 100}%)` }}
/>
)}
{indeterminate ? (
<Progress.Indicator data-part="sweep" className={cn("absolute inset-y-0 left-0 w-2/5 rounded-full", fill)} />
) : (
// A full-width fill slid left keeps its rounded end at every value; scaling would squash it.
<Progress.Indicator
className={cn("absolute inset-y-0 left-0 rounded-full transition-[transform,background-color] duration-[480ms] ease-out-quart", fill)}
style={{ width: "100%", transform: `translateX(${pct - 100}%)` }}
/>
)}
</Progress.Track>
)}
{description != null && (
<div className={cn("min-w-0 text-[12px] leading-[18px] transition-colors duration-200", error ? "text-danger" : "text-fg-3")}>{description}</div>
)}
</Progress.Root>
);
}
function Tick({ reduce }: { reduce: boolean }) {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}
/>
</svg>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | null | — | Current value. null is indeterminate: a sweep instead of a fill. |
| min | number | 0 | Start of the range. |
| max | number | 100 | End of the range. Reaching it swaps the value for a tick. |
| label | ReactNode | — | Shown above the track and used as its accessible name. Without it, pass aria-label. |
| description | ReactNode | — | A quieter line under the track for sizes, time left or the error. |
| showValue | boolean | true with a label | The rolling value at the end of the label row. |
| buffer | number | — | A second, quieter fill for how far ahead something else has got. Same scale as value. |
| segments | number | — | Split the track into equal steps that fill one after another. |
| size | "sm" | "md" | "lg" | "md" | Track height: 3, 4 or 6px. |
| error | boolean | false | The work stopped. Fill, value and description turn the danger color. |
| paused | boolean | false | The work is on hold. The fill and value step back to a quieter color. |
| format | Intl.NumberFormatOptions | — | Format the displayed value (e.g. bytes as a unit). Defaults to a whole percentage. |
| locale | Intl.LocalesArgument | — | Locale for the displayed and announced value. |
| getAriaValueText | (formatted: string, value: number | null) => string | — | What screen readers hear. By default the formatted value, plus "paused" or "failed". |
06Notes
Behavior
- The fill is a full-width bar slid left, not a width or a scale, so its rounded end stays round at 1% and at 99%.
- The value sits in a cell sized for "100%", so rolling digits and the completion tick never nudge the label.
- Segments remember where the value came from and fill one after another in the direction of travel; going back empties them in reverse.
- Values outside min–max are clamped; a non-finite value is treated as indeterminate rather than drawing nonsense.
Motion
- Determinate changes ease over 480ms (ease-out-quart), so frequent small updates read as one continuous fill rather than steps.
- Digits roll with NumberFlow. At 100% they blur out and a tick springs in and draws its stroke over 320ms.
- Indeterminate: a 40% bar sweeps across every 1.5s on the in-out curve. Segments fill over 360ms, 90ms apart.
- Reduced motion stops the digits rolling and the sweep traveling: the track breathes its opacity over 2.4s instead.
Accessibility
- Built on the Progress primitive: role="progressbar" with aria-valuenow, min and max, labeled by the visible label.
- aria-valuetext adds "paused" or "failed" so the state isn't carried by color alone; indeterminate bars omit aria-valuenow.
- The visible rolling number is aria-hidden; the announced value comes from the root.