Stages of a long job with live timers, drawn checks and a filling rail.
01Preview
acme-web
Productionmain · 4f2a91c · Fix checkout rounding
Queued—
- Provisioning build machine, not started
4 vCPU runner, cached
- Installing dependencies, not started
pnpm · 3 workspaces
- Building, not started
next build
- Running checks, not started
Types and 214 tests
- Deploying to edge, not started
18 regions
The previous deploy stays live until this one is ready
02Install
Add it with the shadcn CLI. The source lands in your project and becomes yours: no package to update, no wrapper between you and the markup.
npx shadcn@latest add @stealth/task-stepsOr copy the source below by hand. It needs:
npm install motion @number-flow/react03Usage
import { TaskStep, TaskSteps } from "@/components/ui/task-steps";
<TaskSteps aria-label="Deploy acme-web">
<TaskStep status="done" label="Building" description="412 modules" startedAt={t0} endedAt={t1} />
<TaskStep status="active" label="Deploying" description="Uploading 96 files" startedAt={t1} />
<TaskStep status="pending" label="Warming caches" />
</TaskSteps>
// When a step fails, say why and offer the way forward.
<TaskStep
status="failed"
label="Running checks"
startedAt={t2}
endedAt={t3}
error="2 of 214 tests failed in checkout.test.ts."
action={<Button onClick={retry}>Retry from checks</Button>}
/>04Source
"use client";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
export type TaskStepStatus = "pending" | "active" | "done" | "failed" | "skipped";
type Time = number | Date;
const ms = (t: Time | undefined) => (t == null ? undefined : typeof t === "number" ? t : t.getTime());
// One shared clock for every running timer on the page. It ticks on the wall
// clock's second boundary so all timers change together, and sleeps while the
// tab is hidden (elapsed time is derived from Date.now, so nothing drifts).
const clock = (() => {
let now = 0;
let timer = 0;
const subs = new Set<() => void>();
const emit = () => {
now = Date.now();
subs.forEach((fn) => fn());
};
const schedule = () => {
window.clearTimeout(timer);
if (document.hidden) return;
timer = window.setTimeout(() => {
emit();
schedule();
}, 1000 - (Date.now() % 1000) + 4);
};
const onVisibility = () => {
if (!document.hidden) emit();
schedule();
};
return {
subscribe(fn: () => void) {
subs.add(fn);
if (subs.size === 1) {
now = Date.now();
schedule();
document.addEventListener("visibilitychange", onVisibility);
}
return () => {
subs.delete(fn);
if (subs.size) return;
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibility);
now = 0;
};
},
// Before anyone subscribes there is no tick yet; take one reading and keep it.
get: () => (now ||= Date.now()),
};
})();
const idle = () => () => {};
/**
* Milliseconds between `startedAt` and `endedAt`, or until now while it is
* still running (re-rendering once a second). Null on the server and before
* hydration, so nothing time-dependent is baked into the HTML.
*/
export function useElapsed(startedAt?: Time, endedAt?: Time) {
const start = ms(startedAt);
const end = ms(endedAt);
const running = start != null && end == null;
const now = useSyncExternalStore(running ? clock.subscribe : idle, clock.get, () => null);
if (start == null) return null;
if (end != null) return Math.max(0, end - start);
return now == null ? null : Math.max(0, now - start);
}
/** 42s · 3m 07s · 1h 12m. Short, unambiguous, the same width for every second. */
export function formatDuration(value: number) {
const s = Math.floor(value / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
return `${Math.floor(s / 3600)}h ${String(Math.floor(s / 60) % 60).padStart(2, "0")}m`;
}
export type TaskStepsProps = React.ComponentProps<"ol">;
/** An ordered list of the stages of one long job. Give it an aria-label that names the job. */
export function TaskSteps({ className, ...rest }: TaskStepsProps) {
return <ol className={cn("flex min-w-0 flex-col", className)} {...rest} />;
}
export type TaskStepProps = Omit<React.ComponentProps<"li">, "children"> & {
status: TaskStepStatus;
/** The stage, as a noun or -ing verb: "Building", "Deploying to edge". */
label: React.ReactNode;
/** One line under the label. While active it can change as work progresses; each new line crossfades in. */
description?: React.ReactNode;
/** When the step started. With no endedAt, the timer runs. */
startedAt?: Time;
/** When the step finished or failed. Freezes the timer. */
endedAt?: Time;
/** What went wrong, shown when the status is failed. */
error?: React.ReactNode;
/** Controls under the error: Retry, View logs. Shown when the status is failed. */
action?: React.ReactNode;
};
const spoken: Record<TaskStepStatus, string> = {
pending: "Not started",
active: "In progress",
done: "Done",
failed: "Failed",
skipped: "Skipped",
};
export function TaskStep({ status, label, description, startedAt, endedAt, error, action, className, ref, ...rest }: TaskStepProps) {
const reduce = useReducedMotion();
const self = useRef<HTMLLIElement>(null);
const block = useRef<HTMLDivElement>(null);
// Pressing Retry removes the button that had focus. Catch it on the step
// itself so focus never falls back to the top of the page.
useEffect(() => {
if (status !== "failed" && block.current?.contains(document.activeElement)) self.current?.focus();
}, [status]);
const elapsed = useElapsed(startedAt, endedAt);
const failed = status === "failed";
const showTime = elapsed != null && (status === "active" || status === "done" || failed);
const descKey = typeof description === "string" ? description : status;
return (
<li
ref={(node) => {
self.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
tabIndex={-1}
data-status={status}
aria-current={status === "active" ? "step" : undefined}
className={cn(
"group/step relative grid grid-cols-[20px_minmax(0,1fr)] gap-x-3 rounded-md pb-4 outline-none last:pb-0",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3",
className,
)}
{...rest}
>
{/* The segment to the next step. It fills from the top once this step is done. */}
<span aria-hidden className="absolute bottom-1 left-[9.5px] top-6 w-px overflow-hidden rounded-full bg-line-2 group-last/step:hidden">
<span
className={cn(
"absolute inset-0 origin-top bg-fg-3 transition-transform duration-500 ease-in-out-quart",
status === "done" ? "scale-y-100" : "scale-y-0",
)}
/>
</span>
<span className="relative z-[1] mt-px grid size-5 place-items-center">
<AnimatePresence initial={false}>
<motion.span
key={status}
className="absolute inset-0 grid place-items-center"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.8, transition: { duration: 0.12, ease: ease.in } }}
transition={reduce ? { duration: 0.15 } : spring.pop}
>
<Node status={status} reduce={!!reduce} />
</motion.span>
</AnimatePresence>
</span>
<div className="min-w-0">
<div className="flex min-h-[22px] items-baseline gap-3">
{/* The state word is spoken when it changes; the ticking timer is deliberately outside it. */}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{label}, {spoken[status].toLowerCase()}
{(status === "done" || failed) && elapsed != null ? ` after ${formatDuration(elapsed)}` : ""}
</span>
<span
aria-hidden
className={cn(
"min-w-0 flex-1 truncate text-[13px] font-medium leading-[22px] tracking-[-0.006em] transition-colors duration-200",
status === "active" || failed ? "text-fg" : status === "done" ? "text-fg-2" : status === "skipped" ? "text-fg-4" : "text-fg-3",
)}
>
{label}
</span>
<span
aria-hidden
className={cn(
"shrink-0 font-mono text-[11.5px] tabular leading-[22px] transition-colors duration-200",
status === "active" ? "text-fg-2" : failed ? "text-danger" : "text-fg-3",
)}
>
{showTime ? <Duration value={elapsed} /> : status === "skipped" ? <span className="text-fg-4">Skipped</span> : null}
</span>
</div>
{description != null && (
<div className="grid">
<AnimatePresence initial={false}>
<motion.p
key={descKey}
className={cn(
"col-start-1 row-start-1 truncate text-[12px] leading-[18px] transition-colors duration-200",
status === "active" ? "text-fg-2" : "text-fg-3",
)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -5, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.in } }}
transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}
>
{description}
</motion.p>
</AnimatePresence>
</div>
)}
{/* Grows open instead of shoving the steps below it down in one frame. */}
<AnimatePresence initial={false}>
{failed && (error != null || action != null) && (
<motion.div
key="error"
ref={block}
// Bleeds 4px sideways so focus rings on the actions aren't clipped by the height animation.
className="-mx-1 overflow-hidden px-1"
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0, transition: { duration: 0.16, ease: ease.in } }}
transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out }}
>
<div className="flex flex-col items-start gap-2.5 pb-1 pt-1.5">
{error != null && <p className="text-pretty text-[12px] leading-[18px] text-danger">{error}</p>}
{action != null && <div className="flex flex-wrap items-center gap-2">{action}</div>}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</li>
);
}
// Seconds roll like an odometer rather than flicker; minutes join once they exist.
function Duration({ value }: { value: number }) {
const s = Math.floor(value / 1000);
if (s >= 3600) return <>{formatDuration(value)}</>;
return (
<NumberFlowGroup>
<span className="inline-flex items-baseline gap-[0.35em]">
{s >= 60 && <NumberFlow value={Math.floor(s / 60)} suffix="m" />}
<NumberFlow value={s % 60} suffix="s" format={s >= 60 ? { minimumIntegerDigits: 2 } : undefined} />
</span>
</NumberFlowGroup>
);
}
const glyph = { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true };
function Node({ status, reduce }: { status: TaskStepStatus; reduce: boolean }) {
const draw = (delay = 0.06) =>
reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.32, ease: ease.out, delay } };
if (status === "done")
return (
<span className="grid size-5 place-items-center rounded-full bg-fg text-frame">
<svg {...glyph} className="size-3">
<motion.path d="M3.5 8.5 6.5 11.5 12.5 4.5" {...draw()} />
</svg>
</span>
);
if (status === "failed")
return (
<span className="grid size-5 place-items-center rounded-full bg-danger text-frame">
<svg {...glyph} className="size-3">
<motion.path d="m5 5 6 6" {...draw()} />
<motion.path d="m11 5-6 6" {...draw(0.14)} />
</svg>
</span>
);
if (status === "active")
return (
<span className="relative grid size-5 place-items-center rounded-full text-fg">
<svg viewBox="0 0 20 20" fill="none" aria-hidden className="absolute inset-0 size-5 animate-spin [animation-duration:0.9s]">
<circle cx="10" cy="10" r="8.75" stroke="var(--line-2)" strokeWidth="1.5" />
<path d="M10 1.25a8.75 8.75 0 0 1 8.75 8.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span className="size-[5px] rounded-full bg-fg" />
</span>
);
if (status === "skipped")
return (
<span className="grid size-5 place-items-center rounded-full border border-dashed border-fg-4">
<span className="h-px w-2 rounded-full bg-fg-4" />
</span>
);
return <span className="size-5 rounded-full border-[1.5px] border-line-2" />;
}05Props
TaskSteps
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | Names the job, e.g. “Deploy acme-web”. Renders an ordered list; every other prop goes to the ol. |
TaskStep
| Prop | Type | Default | Description |
|---|---|---|---|
| status* | "pending" | "active" | "done" | "failed" | "skipped" | — | Drives the marker, the colors, the rail below it and what is announced. |
| label* | ReactNode | — | The stage name. Truncates on one line; the timer never does. |
| description | ReactNode | — | One line of detail. Change it while active and each new line crossfades in without moving anything. |
| startedAt | number | Date | — | When the step began. With no endedAt, the timer ticks once a second. |
| endedAt | number | Date | — | When it finished or failed. Freezes the timer. |
| error | ReactNode | — | What went wrong. Shown under the label when failed. |
| action | ReactNode | — | Controls under the error, such as Retry and View logs. |
useElapsed
| Prop | Type | Default | Description |
|---|---|---|---|
| startedAt | number | Date | — | Returns elapsed milliseconds, live while there is no end. Null on the server. |
| endedAt | number | Date | — | Stops the clock. formatDuration(ms) prints 42s, 3m 07s or 1h 12m. |
06Notes
Behavior
- Every running timer on the page shares one clock that ticks on the second boundary and sleeps while the tab is hidden; elapsed time is derived from timestamps, so nothing drifts.
- Timers render nothing on the server and fill in after hydration, so no time is baked into the HTML.
- The error and actions grow open under a failed step instead of shoving the steps below it down in one frame.
- When a Retry inside the failed step removes itself, focus moves to the step rather than falling to the top of the page.
Motion
- Markers swap on the pop spring from 0.6 scale; the check and the cross draw their strokes in 320ms on the expo ease-out.
- The rail under a finished step fills from the top in 500ms on the in-out quart, carrying the eye to the next stage.
- Timer digits roll rather than flicker, and description lines slide 5px with a 2px blur as they swap.
- Reduced motion keeps 150ms crossfades, shows a still arc instead of a spinning one, and drops the scale, draw and roll.
Accessibility
- An ordered list; the active step carries aria-current="step".
- Each step has a polite live region that says its label and state when the state changes (“Building, done after 42s”). The ticking timer sits outside it, so it is never read every second.
- Markers, the rail and the timer are decorative; the same information is in the spoken text.