Task progress
For work that takes minutes: phases, a live timer, stats, cancel, and the result when it’s done.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/task-progress.jsonOverview
Deep research, long builds and batch jobs need more than a spinner. Phases fill left to right with the current one sweeping, the timer and stats roll as they change, and a line tells people they can leave. One status line names the phase and morphs from "Searching" to "Reading" to "Finished in 4m 12s" as a check draws itself. The button is one surface too: Cancel grows into "Open report" when it’s done, or turns into Retry when it fails.
States
- running
- Finished phases fill from the left (620ms); the current one sweeps. The status line names the phase under a sweep of light and morphs as it moves on. Timer and numeric stats roll; the message and Cancel sit below.
- done
- The last bar fills, the message folds away, a check draws itself and the line morphs to "Finished in 4m 12s". Cancel turns blue and grows into "Open report →".
- error
- The failed phase fills red, the reason opens in, an alert swaps in and the line reads "Stopped at reading". The button morphs into Retry.
- cancelled
- "Cancelled after 1m 02s"; the button folds away and focus stays on the card.
Usage
import { TaskProgress } from "@/components/task-progress";
<TaskProgress
title="Researching waffle cone suppliers"
status="running"
phases={["Searching", "Reading", "Writing"]}
phase={1}
startedAt={startedAt}
stats={[{ label: "sources", value: 42 }, { label: "read", value: 12 }]}
message="You can close this tab. We’ll keep going."
onCancel={stop}
/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Installs with it
With the AI SDK
Map the Vercel AI SDK’s message parts and chat status onto the component’s props.
// Server (inside createUIMessageStream): report progress as a data part with a fixed id,
// so each write updates the same card instead of adding a new one.
writer.write({ type: "data-research", id: "research", data: { phase: 1, sources: 42, read: 12 } });
// Client
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import { TaskProgress } from "@/components/task-progress";
function Research({ message, startedAt }: { message: UIMessage; startedAt: number }) {
const { status, stop } = useChat();
const part = message.parts.find((p) => p.type === "data-research");
if (!part) return null;
const { phase, sources, read } = part.data as { phase: number; sources: number; read: number };
return (
<TaskProgress
title="Deep research"
status={status === "streaming" ? "running" : status === "error" ? "error" : "done"}
phases={["Searching", "Reading", "Writing"]}
phase={phase}
startedAt={startedAt}
stats={[{ label: "sources", value: sources }, { label: "read", value: read }]}
onCancel={stop}
/>
);
}Props
| Prop | Type | Description |
|---|---|---|
| title | string | What the task is doing. |
| status | "running" | "done" | "error" | "cancelled" | Where the task is. |
| phases / phase | string[] / number | The stages and the index of the current one. |
| startedAt / duration | number | Start time for the live timer, or the total once known (ms). |
| stats | { label; value }[] | Live numbers, e.g. sources found and read. |
| message | string | A line while running, e.g. that it’s safe to leave. |
| onCancel / onOpen / openLabel | () => void / () => void / stringdefault "Open result" | Cancel while running; open the result when done. |
| errorText / onRetry | string / () => void | Why it failed, and a Retry. |
Accessibility and motion
- The current phase and the outcome are announced politely; the bars, rolling digits and status line are decorative copies of that text.
- The timer ticks once a second and stops when the task settles or unmounts. Stats that are numbers roll; anything else renders as you pass it.
- Installs Number roll and Text morph. With reduced motion nothing sweeps, fills or rolls; states change in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll, Text morph.
Source
›Show the full source of task-progress.tsx (353 lines)
"use client";
import * as React from "react";
import { AlertCircle, ArrowRight, CircleSlash, RotateCcw } from "lucide-react";
import { NumberRoll } from "./number-roll";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* TASK PROGRESS: work that takes minutes, not seconds
*
* running phases fill left to right; the current one sweeps;
* the status line names the phase as it morphs from
* one to the next; timer and stats roll; Cancel
* done the last bar fills, a check draws itself, "Finished
* in 4m 12s"; Cancel grows into "Open report"
* error the failed phase fills red, the reason opens in,
* "Stopped at reading"; the button becomes Retry
* cancelled stopped by the user; the button folds away
*
* The button and the status line are one surface each: they
* change shape and wording instead of being swapped. For deep
* research, long builds and batch jobs. Tell people they can
* leave; the card is what they come back to.
* ───────────────────────────────────────────────────────── */
export type TaskProgressStatus = "running" | "done" | "error" | "cancelled";
export interface TaskStat {
label: string;
/** Numbers roll when they change. */
value: React.ReactNode;
}
export interface TaskProgressProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
title: string;
status: TaskProgressStatus;
/** The stages, e.g. ["Searching", "Reading", "Writing"]. */
phases: string[];
/** Index of the current phase. */
phase: number;
/** Start time (ms); shows a live timer. */
startedAt?: number;
/** Total time (ms), if you know it once finished. */
duration?: number;
stats?: TaskStat[];
/** A line under the progress, e.g. "You can close this tab. We'll keep going." */
message?: string;
onCancel?: () => void;
onOpen?: () => void;
openLabel?: string;
errorText?: string;
onRetry?: () => void;
}
const MORPH = "cubic-bezier(0.16,1,0.3,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.
const SHEEN =
"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70";
const TWO_DIGITS = { minimumIntegerDigits: 2 };
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */
const swap = (on: boolean, reduced: boolean): React.CSSProperties => ({
opacity: on ? 1 : 0,
transform: on ? "none" : "scale(0.6)",
filter: on ? "none" : "blur(3px)",
transition: reduced ? "none" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,
});
function formatTime(ms: number) {
const s = Math.max(0, Math.floor(ms / 1000));
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
/** A piece of a line that opens out of nothing and folds back into it. */
function Reveal({ show, reduced, className = "", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {
return (
<span
aria-hidden={!show || undefined}
className={`grid ${className}`}
style={{
gridTemplateColumns: show ? "1fr" : "0fr",
opacity: show ? 1 : 0,
filter: show ? "none" : "blur(3px)",
transition: reduced ? "none" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? "320ms" : "160ms"} ${MORPH}, filter 320ms ${MORPH}`,
}}
>
<span className="min-w-0 whitespace-nowrap [clip-path:inset(-4px_-2px)]">{children}</span>
</span>
);
}
/** A block that opens to its height and folds back to nothing. */
function Fold({ show, reduced, children }: { show: boolean; reduced: boolean; children: React.ReactNode }) {
return (
<div
aria-hidden={!show || undefined}
className="grid"
style={{
gridTemplateRows: show ? "1fr" : "0fr",
opacity: show ? 1 : 0,
transition: reduced ? "none" : `grid-template-rows 460ms ${MORPH}, opacity ${show ? "320ms" : "160ms"} ${MORPH}`,
}}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
);
}
/** Seconds that roll up as they pass; minutes slide open at the first minute. */
function Elapsed({ ms, reduced }: { ms: number; reduced: boolean }) {
const total = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(total / 60);
return (
<span className="inline-flex items-baseline">
<Reveal show={minutes > 0} reduced={reduced}>
<NumberRoll value={minutes} suffix="m" duration={500} />
</Reveal>
<NumberRoll value={minutes ? total % 60 : total} format={minutes ? TWO_DIGITS : undefined} suffix="s" direction="up" duration={500} />
</span>
);
}
function DrawnCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {
return (
<svg viewBox="0 0 16 16" fill="none" className="size-3.5">
<path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={1}
style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${MORPH} 200ms` : "none" }}
/>
</svg>
);
}
export function TaskProgress({
title,
status,
phases,
phase,
startedAt,
duration,
stats,
message,
onCancel,
onOpen,
openLabel = "Open result",
errorText,
onRetry,
className = "",
...props
}: TaskProgressProps) {
const reduced = useReducedMotion();
const running = status === "running";
const [now, setNow] = React.useState(startedAt ?? 0);
const [endedAt, setEndedAt] = React.useState<number | undefined>(undefined);
const cardRef = React.useRef<HTMLDivElement>(null);
const actionRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (!running) {
setEndedAt((prev) => prev ?? Date.now());
return;
}
setEndedAt(undefined);
if (startedAt === undefined) return;
setNow(Date.now());
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [running, startedAt]);
const elapsed = duration ?? (startedAt === undefined ? undefined : (running ? now : (endedAt ?? now)) - startedAt);
const current = Math.min(Math.max(phase, 0), phases.length - 1);
// The one button: Cancel while it runs, Retry after a failure, the result once done.
const action =
running && onCancel
? { kind: "cancel", label: "Cancel", run: onCancel }
: status === "error" && onRetry
? { kind: "retry", label: "Retry", run: onRetry }
: status === "done" && onOpen
? { kind: "open", label: openLabel, run: onOpen }
: null;
const [lastAction, setLastAction] = React.useState(action);
if (action && (action.kind !== lastAction?.kind || action.label !== lastAction.label)) setLastAction(action);
const shown = action ?? lastAction;
const primary = action?.kind === "open";
// If the button folds away while focused (Cancel pressed), keep focus on the card instead of the page.
React.useLayoutEffect(() => {
if (!action && document.activeElement === actionRef.current) cardRef.current?.focus({ preventScroll: true });
}, [action]);
const statusLine = running ? phases[current] : status === "done" ? "Finished" : status === "error" ? `Stopped at ${phases[current].toLowerCase()}` : "Cancelled";
const timeJoin = status === "done" ? "in" : "after";
const showTime = (status === "done" || status === "cancelled") && elapsed !== undefined;
const iconKey = status === "running" ? null : status;
return (
<div ref={cardRef} tabIndex={-1} className={`w-full rounded-xl border border-border bg-background p-4 outline-none ${className}`} {...props}>
<div className="flex items-baseline justify-between gap-4">
<p className="min-w-0 truncate text-[13.5px] font-medium text-foreground">{title}</p>
{elapsed !== undefined && (
<span aria-hidden="true" className="shrink-0 font-mono text-[11.5px] tabular-nums text-muted-foreground">
<Elapsed ms={elapsed} reduced={reduced} />
</span>
)}
</div>
{/* Each phase fills from the left when it's finished; the live one sweeps. */}
<div className="mt-3.5 flex gap-1" aria-hidden="true">
{phases.map((p, i) => {
const complete = status === "done" || i < current;
const failed = status === "error" && i === current;
const live = running && i === current;
return (
<span key={p} className="relative h-1 flex-1 overflow-hidden rounded-full bg-border">
<span
className={`absolute inset-0 origin-left rounded-full transition-colors duration-300 ${failed ? "bg-red-500" : "bg-foreground"}`}
style={{ transform: complete || failed ? "none" : "scaleX(0)", transition: reduced ? "none" : `transform 620ms ${MORPH}, background-color 300ms` }}
/>
<span
className={`absolute inset-y-0 left-0 w-2/5 rounded-full bg-foreground/60 motion-reduce:hidden ${live ? "animate-[ui-scan_1.6s_cubic-bezier(0.45,0,0.55,1)_infinite]" : ""}`}
style={{ opacity: live ? 1 : 0, transition: reduced ? "none" : "opacity 300ms" }}
/>
</span>
);
})}
</div>
<div aria-hidden="true" className="mt-2 flex gap-1 text-[11.5px]">
{phases.map((p, i) => (
<span
key={p}
className={`flex-1 truncate transition-colors duration-300 ${status === "error" && i === current ? "text-red-500" : running && i === current ? "text-foreground" : "text-muted-foreground"}`}
>
{p}
</span>
))}
</div>
<p role="status" aria-live="polite" className="sr-only">
{status === "done"
? `${title} finished${elapsed !== undefined ? ` in ${formatTime(elapsed)}` : ""}`
: status === "error"
? `${title} failed. ${errorText ?? ""}`
: status === "cancelled"
? `${title} cancelled`
: `${phases[current]}`}
</p>
{stats && stats.length > 0 && (
<p className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
{stats.map((s) => (
<span key={s.label} className="inline-flex items-baseline gap-[0.3em]">
<span className="font-mono tabular-nums text-foreground">{typeof s.value === "number" ? <NumberRoll value={s.value} duration={700} /> : s.value}</span>
{s.label}
</span>
))}
</p>
)}
{message && (
<Fold show={running} reduced={reduced}>
<p className="pt-2 text-[12px] text-muted-foreground">{message}</p>
</Fold>
)}
<Fold show={status === "error"} reduced={reduced}>
<p className="pt-3 text-[12.5px] text-red-500">{errorText ?? "Something went wrong partway through."}</p>
</Fold>
<div className="mt-4 flex min-h-8 items-center justify-between gap-3">
{/* One status line: it names the phase while it runs and becomes the outcome. */}
<span aria-hidden="true" className="flex min-w-0 items-center text-[12px] text-muted-foreground">
<span
className="relative flex h-3.5 shrink-0 items-center justify-center"
style={{ width: iconKey ? 14 : 0, marginRight: iconKey ? 6 : 0, transition: reduced ? "none" : `width 380ms ${MORPH}, margin 380ms ${MORPH}` }}
>
<span className="absolute inset-0 flex items-center justify-center text-emerald-600 dark:text-emerald-400" style={swap(iconKey === "done", reduced)}>
<DrawnCheck drawn={iconKey === "done"} reduced={reduced} />
</span>
<span className="absolute inset-0 flex items-center justify-center text-red-500" style={swap(iconKey === "error", reduced)}>
<AlertCircle className="size-3.5" />
</span>
<span className="absolute inset-0 flex items-center justify-center" style={swap(iconKey === "cancelled", reduced)}>
<CircleSlash className="size-3.5" />
</span>
</span>
<span className={`truncate ${running ? SHEEN : ""}`}>
<TextMorph>{statusLine}</TextMorph>
</span>
{elapsed !== undefined && (
<Reveal show={showTime} reduced={reduced}>
<span className="inline-flex items-baseline pl-[0.3em] tabular-nums">
<TextMorph>{timeJoin}</TextMorph>
<Elapsed ms={elapsed} reduced={reduced} />
</span>
</Reveal>
)}
</span>
{shown && (
<Reveal show={Boolean(action)} reduced={reduced} className="shrink-0">
<span className="block p-0.5">
<button
ref={actionRef}
type="button"
inert={!action}
onClick={() => action?.run()}
className={`inline-flex h-8 items-center rounded-lg px-3 text-[12.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-300 active:scale-[0.97] ${FOCUS} ${
primary ? "bg-primary text-primary-foreground hover:bg-primary/90" : "text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent"
}`}
>
<span
aria-hidden="true"
className="relative flex h-3 shrink-0 items-center justify-center overflow-hidden"
style={{ width: shown.kind === "retry" ? 12 : 0, marginRight: shown.kind === "retry" ? 6 : 0, transition: reduced ? "none" : `width 380ms ${MORPH}, margin 380ms ${MORPH}` }}
>
<RotateCcw className="size-3 shrink-0" style={swap(shown.kind === "retry", reduced)} />
</span>
<TextMorph>{shown.label}</TextMorph>
<span
aria-hidden="true"
className="relative flex h-3.5 shrink-0 items-center justify-center overflow-hidden"
style={{ width: shown.kind === "open" ? 14 : 0, marginLeft: shown.kind === "open" ? 6 : 0, transition: reduced ? "none" : `width 380ms ${MORPH}, margin 380ms ${MORPH}` }}
>
<ArrowRight className="size-3.5 shrink-0" style={swap(shown.kind === "open", reduced)} />
</span>
</button>
</span>
</Reveal>
)}
</div>
</div>
);
}
Registry item: /r/task-progress.json