Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleAlert, CircleCheck, CircleSlash, Loader2, RotateCw, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance plus the indeterminate sliver. There is no exit keyframe on purpose:
 * the consumer owns the list and unmounts the card from `onDismiss`, so a
 * self-played exit would fight whatever list transition they already have.
 */
const KEYFRAMES = `@keyframes zpt-in{from{opacity:0;transform:translateY(6px) scale(0.99)}}@keyframes zpt-slide{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}`

/** How long a finished job stays on screen before it asks to be dismissed. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/progress-toast.json

Prompt

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 value paints a fill and prints a percentage, anything else sweeps and omits aria-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.activeElement rather than trusted to bubble.

On This Page