Progress Toast
A toast bound to one long job — determinate or indeterminate progress, a live percentage in its accessible name, cancel while it runs, and terminal states that stay long enough to read with a retry on failure.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/progress-toast.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ProgressToast" component with lucide-react
icons and no other dependency: the entrance keyframe and the indeterminate sweep
ship inside the file through a React 19 hoisted <style href precedence> tag.
It is the card a long job gets while it runs. The component reports; it never
runs anything. The consumer owns the job, its status and the list of cards — this
card owns four things they should not have to rewrite per job type: a percentage
that never lies, a result that stays long enough to read, exactly one cancel, and
announcements at milestones instead of at ticks.
Contract
- <ProgressToast label description? value? status? elapsedMs? icon? holdMs?
errorHoldMs? announceEvery? showDismiss? paused? successLabel? errorLabel?
cancelledLabel? cancelLabel? cancellingLabel? retryLabel? dismissLabel?
onCancel? onRetry? onDismiss? className? ...divProps />
forwardRef<HTMLDivElement>, spreads the rest onto the root, merges className
with cn().
- status: "running" | "success" | "error" | "cancelled", default "running". It is
the consumer's — the card never advances it.
- value: number | null | undefined. A finite number is a fraction of 1, clamped to
[0, 1]. null, undefined and NaN all mean indeterminate.
- elapsedMs: how long the job has been running, measured by the consumer. The card
never reads a clock during render; every time-derived value comes from this
injected instant or is not shown at all.
- holdMs (default 4500) is how long a settled card stays before it asks to be
dismissed; errorHoldMs (default Infinity) is the same for failures. Any value
that is not a positive finite number means sticky.
- announceEvery (default 25) is the percent step between polite announcements; 0
or non-finite silences them.
- onCancel() / onRetry() / onDismiss(reason: "hold" | "dismiss") are the three
things the consumer must implement, and each one gates its own control: no
onCancel, no Cancel button; no onRetry, no Retry on a failure; no onDismiss, no
close key (the card never removes itself, so that key would visibly do nothing).
onDismiss is where the consumer unmounts the card.
- <ProgressToastGroup label="Background tasks" className?> is an optional wrapper
around a list of cards. It renders in normal flow, so the consumer pins it
("fixed bottom-4 right-4 z-50 w-96") or drops it inline.
- Also export: ProgressToastStatus, ProgressToastDismissReason, ProgressToastProps,
ProgressToastGroupProps.
Behavior
- Percentage. fraction = clamp01(value) when value is a finite number, else null.
While running, percent = min(99, floor(fraction × 100)) — a bar that reads 100%
with work still to do is a bar nobody trusts again, so only status="success" may
say 100. A settled non-success card rounds instead of flooring and keeps the
percentage it stopped at as evidence ("Failed · stopped at 41%").
- Bar fill. 100% for success; otherwise the fraction, and an indeterminate job that
settles closes its track. Width transitions over 300ms and is dropped entirely
under prefers-reduced-motion.
- Estimate. etaMs = elapsedMs × (1 − fraction) ÷ fraction, computed only while
running, only when elapsedMs is a finite positive number and fraction is at
least 0.05; below that floor an estimate built from two data points is noise, so
the card says "estimating time left…" instead of a number. Durations are
deliberately coarse — under a minute rounded up to the next 5 seconds with a 5s
floor, then whole minutes, then "1h 20m" — because a countdown that ticks 47,
46, 45 invites a trust it has not earned.
- Indeterminate. A sweeping sliver over a faint wash, both aria-hidden. Under
prefers-reduced-motion the sliver is hidden and the wash alone carries "busy,
amount unknown".
- Hold. A settled card starts a timeout for holdMs (errorHoldMs on failures) and
calls onDismiss("hold") when it expires. Hovering the card, focusing anything
inside it, the paused prop, or (inside a group) hovering any sibling, all freeze
it: the timeout is cleared and the remainder is banked from a performance.now()
deadline, so hovering three times hands out one hold, not three. A fresh status
is a fresh window, a fresh set of latches and a cleared "Cancelling…" label —
reset by comparing the previous status during render, which is the documented
way to react to a changed prop and one cascading render cheaper than an effect.
- One shot, everywhere. Three refs — cancel, retry, dismiss — are each read AND
written synchronously inside their handler, so two clicks in the same tick send
one abort, and a hold expiring in the same tick as a click on the close button
produces one onDismiss, not two. aria-disabled has no browser behaviour; the
guard in the handler is the behaviour.
- Cancel. Pressing it latches, calls onCancel() once and flips the button to
"Cancelling…" while staying focusable — the job is not stopped until the
consumer moves status, which is honest about real teardown taking time.
- Retry. Rendered only for status="error" with onRetry; fires at most once per
error state, and the latch reopens when the status changes.
- Announcements. Two sr-only regions, not one: role="status" (polite) carries
milestones and the success or cancelled sentence, role="alert" (assertive)
carries failures, because a job that failed while the reader was elsewhere is
the one thing worth interrupting for. Both sentences are derived from the props,
not written by an effect: the polite one is floor(percent / announceEvery) ×
announceEvery, so its text mutates four times over a job instead of once per
frame (milestone 0 is "…started", which a restarted job says again). The alert
is empty while running, otherwise a second identical failure mutates nothing and
is never announced. Both regions mount empty and are filled on the next
animation frame — a region that mounts with its text already inside it never
mutated, and a node that never mutated is one most screen readers skip — and
that frame is cancelled on unmount. aria-busy appears nowhere: it tells
assistive tech to hold off reading the element, which is the opposite of what
this card wants.
- Keyboard. Tab reaches Cancel, Retry and the close button in that order, each
with a focus-visible ring. Escape dismisses a settled card and is deliberately
inert while the job runs — stopping work is Cancel's job and should cost a
deliberate press. Focus anywhere inside pauses the hold; blur resumes it from
the banked remainder.
- Focus successor. Cancel exists only while running, so settling unmounts it and
the browser drops focus on <body> without firing a focusout. Repair both by
hand: move focus to Retry, else the close button, else the card itself with a
temporary tabindex="-1" removed on blur, and re-derive the card's pause flag
from document.activeElement (left stuck on "focused" it would freeze the hold
forever). Keyboard users only — a pointer user never asked for focus. The same
effect has to poke the group's pause flag as well: it heard no focusout either,
and stuck on "focused" it freezes every hold in the group, not just this one.
- Group. Shares one pause across every card so the card being read never has a
sibling vanish and shift the layout, while each card still banks its own
remainder. When a card is unmounted from its own onDismiss it takes the focused
button with it; the group reads the departing element synchronously, then on the
next animation frame focuses an enabled control in the card that took its slot
(else any control, else itself) and re-derives its own pause flag from the DOM.
- Cleanup. The hold timeout is cleared on unmount and on every dependency change
(banking its remainder), the live-region frame and the group's own rAF are both
cancelled on unmount, and the temporary tabindex is removed on blur.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground with a border and
shadow-lg for the card; the track is bg-muted; the fill is bg-primary while
running, bg-[var(--chart-2)] on success, bg-destructive on failure and
bg-muted-foreground when cancelled; the status glyph follows the same map in
text-*; secondary lines are text-muted-foreground; controls are bordered with
hover:bg-accent and focus-visible:ring-2 focus-visible:ring-ring. No hex, no
rgb(), no oklch().
- Layout: glyph, then a min-w-0 column holding a title row (truncated label,
tabular-nums percentage, controls) over a 6px track over one detail line
joining outcome, "stopped at N%", description and estimate with " · ".
- ARIA: the root is role="group" aria-labelledby={titleId}; the track is
role="progressbar" aria-valuemin={0} aria-valuemax={100}, with aria-valuenow
omitted while indeterminate (that omission is how ARIA spells "unknown") and
aria-valuetext reading "62%, 15s left". The group is role="region" with an
aria-label and is deliberately not aria-live: the percentages inside it change
several times a second, and a live region around them would read every one.
Decorative glyphs are aria-hidden.
- data-state (running | cancelling | success | error | cancelled) and data-hold
(none | running | paused) are exposed for styling and for tests that would
otherwise have to assert on colours.
- Motion: a 200ms entrance and the 1.4s sweep, both dropped under
prefers-reduced-motion. There is no exit keyframe on purpose — the consumer owns
the list and unmounts from onDismiss, so a self-played exit would fight whatever
list transition they already have.
Customization levers
- Timing: holdMs, errorHoldMs and paused per card; the ETA floor (0.05) and the
duration rounding are two small functions at the top of the file — raise the
floor for jobs with jittery early progress, or switch to exact seconds if your
backend really does know.
- Density: the card is p-3 with gap-3 and a 6px track; a compact variant is a
smaller track, no detail line and text-xs throughout. Drop the description prop
for a one-line card.
- Anatomy: every sub-block is independent — omit onCancel for work that cannot be
interrupted, showDismiss={false} for a card the consumer removes itself, icon
for a file type or brand mark. Nothing on the card looks pressable unless it is.
- Tone: the fill and glyph colour maps are the whole variant system; swap
var(--chart-2) for a brand token, or give cancelled its own hue by adding one
entry to each map.
- Copy: successLabel, errorLabel, cancelledLabel, cancelLabel, cancellingLabel,
retryLabel and dismissLabel are flat strings, so another language needs no
structural change.
- Placement: the group is a plain flex column — pin it to a corner with a fixed
className, reverse it to newest-on-top with flex-col-reverse, or render the
cards inline in a panel with no group at all.Concepts
- Determinate and indeterminate in one card — the same card covers both, and which one it is comes from the data rather than a prop: a finite
valuepaints a fill and prints a percentage, anything else sweeps and omitsaria-valuenow, which is how ARIA spells "amount unknown". - A percentage that never lies — capping a running job at 99 and letting only
status="success"reach 100 costs one line and buys the number its credibility; the same discipline keeps the interrupted states showing where the work actually stopped. - An estimate with a floor — remaining time is derived from an injected
elapsedMs, never from a clock read during render, and it is withheld entirely under 5% done, because two data points make a prediction that sounds authoritative and is not. - Hold window with a banked remainder — a result is only useful if it survives long enough to read, so a settled card holds, pauses whenever a pointer or the keyboard is inside it, and resumes from the time it had left instead of restarting; failures hold forever, because a failure nobody saw is a failure that never happened.
- Milestones, not ticks — announcing every 25% rather than every frame is the difference between a live region people keep on and one they switch off; failures go out assertively, everything else politely.
- A deliberate successor for focus — a control that disappears with its own card takes the user's focus with it and the browser fires no event about it, so both the focus target and the pause flag are re-derived by hand from
document.activeElementrather than trusted to bubble.
Unsaved Changes Guard
A three-way exit for unsaved work — beforeunload armed only while dirty, in-app navigation held through an injected navigate handler, and a save / discard / stay dialog instead of the browser's bare prompt.
Dock
A macOS-style dock — icon tiles magnify as the cursor approaches, with spring physics and pure-CSS label bubbles.