how far along work is: a bar, the count and the time left
npx shadcn add @ag/progressAlready installed? Preview an update
npx shadcn add @ag/progress --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-10
- New:
material: plain, a groove of the page's foreground; glass, a channel of the material's glass, the fill and the words in the text's own colour.
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Progress, type ProgressState } from "@/registry/base-nova/ui/progress"
// #region work
// A model looking at 35 frames, about a second each: running, stopped and resumed
// where it was, done. The numbers are made up for this page; the states are the real
// ones a piece of work goes through.
const FRAMES = 35
const MS_PER_FRAME = 1100
// #endregion
function Work() {
const [done, setDone] = React.useState(0)
const [state, setState] = React.useState<ProgressState>("paused")
React.useEffect(() => {
if (state !== "running") return
const timer = window.setInterval(
() =>
setDone((d) => {
if (d + 1 >= FRAMES) setState("done")
return Math.min(FRAMES, d + 1)
}),
350,
)
return () => window.clearInterval(timer)
}, [state])
return (
<Progress
label="judging frames"
done={done}
of={FRAMES}
unit="frame"
eta={(FRAMES - done) * MS_PER_FRAME}
state={state}
action={
state === "running" ? (
<Button
size="sm"
variant="secondary"
corners="pill"
onClick={() => setState("paused")}
>
stop
</Button>
) : state === "done" ? (
<Button
size="sm"
variant="secondary"
corners="pill"
onClick={() => {
setDone(0)
setState("running")
}}
>
again
</Button>
) : (
<Button
size="sm"
variant="secondary"
corners="pill"
onClick={() => setState("running")}
>
{done > 0 ? "resume" : "start"}
</Button>
)
}
/>
)
}
export default function ProgressDemo() {
return (
<div className="space-y-8">
<Sample
name="work"
glance
title="stop it, go on from where it was"
with="work"
>
<Work />
</Sample>
<Sample name="states" title="every state">
<div className="flex flex-col gap-5">
<Progress
label="looking at a film"
done={812}
of={5400}
unit="frame"
eta={1_490_000}
/>
<Progress
label="judging frames"
done={12}
of={35}
unit="frame"
state="paused"
/>
<Progress
label="judging frames"
done={35}
of={35}
unit="frame"
state="done"
/>
<Progress
label="judging frames"
done={20}
of={35}
unit="frame"
state="failed"
/>
<Progress label="asking the model" done={0} of={null} />
</div>
</Sample>
<Sample name="glass" title="in glass">
<MaterialProvider material="glass">
<div className="flex flex-col gap-5 rounded-2xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-10 text-white">
<Progress
label="looking at a film"
done={812}
of={5400}
unit="frame"
eta={1_490_000}
/>
<Progress
label="judging frames"
done={12}
of={35}
unit="frame"
state="paused"
/>
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// How far along a piece of work is, said three ways at once: a bar, the count (12 of
// 35 frames) and the time left (33s left). For work that takes long enough to watch: a
// model looking at a film, an upload, an import, a render. Data in: how much is done
// of how much (or not known yet), its unit, the time left (ms, like every time here)
// when someone has estimated it, and what state it is in.
//
// running the fill eases forward, a soft sheen passing over it while it moves
// paused stopped where it is, striped and dimmed: it can go on from here
// done full, still
// failed in the bad tone, where it stopped
// Not known yet how much there is (`of` null): a short stretch sweeps the track.
// An `action` sits at the row's end: stop, resume, retry, whatever the work allows.
// A role="progressbar" with its values for a screen reader; nothing moves under
// reduced motion. Two materials (`material`): plain, a groove of the page's
// foreground; glass, a channel of the material's glass.
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import "./progress.css"
export type ProgressState = "running" | "paused" | "done" | "failed"
export interface ProgressProps {
/** How much is done, in `unit`s. */
done: number
/** How much there is in all, or null while it is not known. */
of: number | null
/** What is counted, singular: "frame", "file". Plural by an s. */
unit?: string
/** Time left, ms, when it is estimated. */
eta?: number | null
state?: ProgressState
/** What the work is, said at the row's start; also its accessible name. */
label: React.ReactNode
/** At the row's end: a stop, a resume. */
action?: React.ReactNode
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
}
/** Plain: the page's foreground at its tiers, a groove of it. Glass: a channel of the
* material's glass, the fill and the words in the text's own colour, so it reads on
* whatever the glass stands on. */
const FACE: Record<
Material,
{
label: string
count: string
track: string
fill: string
paused: string
sweep: string
}
> = {
plain: {
label: "text-foreground/85",
count: "text-foreground/60",
track: "h-1.5 bg-foreground/10",
fill: "bg-foreground/75",
paused: "bg-foreground/40",
sweep: "bg-foreground/55",
},
glass: {
label: "text-current/85",
count: "text-current/60",
track: cn(
glassVariants({ shape: "surface" }),
"h-2 [--glass-blur:4px] [--glass-radius:4px]",
),
fill: "bg-current/80",
paused: "bg-current/45",
sweep: "bg-current/55",
},
}
/** A time left, ms, as a person says it: 33s, 2m 10s, 1h 4m. */
export function timeLeft(ms: number): string {
const s = Math.max(0, Math.ceil(ms / 1000))
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}
const STATE_WORD: Record<ProgressState, string | null> = {
running: null,
paused: "paused",
done: "done",
failed: "stopped",
}
export function Progress({
done,
of,
unit,
eta,
state = "running",
label,
action,
material,
scheme,
className,
}: ProgressProps) {
const m = useMaterial(material)
// Over a picture it is read by the part of it under the progress.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
const face = FACE[m]
const known = of !== null && of > 0
const share = known ? Math.min(1, Math.max(0, done / of)) : 0
const plural = (n: number) => (unit ? `${unit}${n === 1 ? "" : "s"}` : "")
const count = known
? `${done}/${of} ${plural(of)}`
: `${done} ${plural(done)}`
const word = STATE_WORD[state]
return (
<div
ref={here}
data-slot="progress"
data-state={state}
data-material={m}
className={cn("flex flex-col gap-1.5", schemeOf(s), className)}
>
<div className="flex items-center gap-3 text-sm">
<span className={cn("min-w-0 flex-1 truncate", face.label)}>
{label}
</span>
<span
className={cn("shrink-0 font-mono text-xs tabular-nums", face.count)}
>
{count}
{state === "running" && eta != null && ` · ${timeLeft(eta)} left`}
{word && ` · ${word}`}
</span>
{action}
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={known ? (of as number) : undefined}
aria-valuenow={known ? done : undefined}
aria-valuetext={`${count}${eta != null ? `, ${timeLeft(eta)} left` : ""}${word ? `, ${word}` : ""}`}
aria-label={typeof label === "string" ? label : undefined}
className={cn("relative overflow-hidden rounded-full", face.track)}
>
{known ? (
<div
data-slot="progress-fill"
className={cn(
"absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none",
state === "failed" ? "bg-tone-bad" : face.fill,
state === "paused" && face.paused,
)}
style={{ width: `${share * 100}%` }}
/>
) : (
state === "running" && (
<div
data-slot="progress-sweep"
className={cn(
"absolute inset-y-0 w-1/4 rounded-full",
face.sweep,
)}
/>
)
)}
</div>
</div>
)
}/* progress: what moves, keyed on what the DOM says it is (data-slot, data-state).
Running, a sheen passes over the fill; paused, the fill is striped; not yet known how
much there is, a stretch sweeps the track. The motion is declared only where motion
is welcome, so reduced motion never has to win a specificity contest to stop it. */
@keyframes progress-sheen {
from {
background-position: -150% 0;
}
to {
background-position: 250% 0;
}
}
@keyframes progress-sweep {
from {
left: -25%;
}
to {
left: 100%;
}
}
[data-slot="progress"][data-state="paused"] [data-slot="progress-fill"] {
background-image: repeating-linear-gradient(
-45deg,
transparent 0 3px,
color-mix(in oklab, var(--background) 45%, transparent) 3px 6px
);
}
/* Still, a sweep sits at the track's start. */
[data-slot="progress-sweep"] {
left: 0;
}
@media (prefers-reduced-motion: no-preference) {
[data-slot="progress"][data-state="running"] [data-slot="progress-fill"] {
background-image: linear-gradient(
100deg,
transparent 30%,
color-mix(in oklab, var(--background) 35%, transparent) 50%,
transparent 70%
);
background-size: 60% 100%;
background-repeat: no-repeat;
animation: progress-sheen 1.6s ease-in-out infinite;
}
[data-slot="progress-sweep"] {
animation: progress-sweep 1.4s ease-in-out infinite;
}
}