{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"task-progress","type":"registry:component","title":"Task progress","description":"For work that takes minutes: phases, a live timer, stats, cancel, and the result when it’s done.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/task-progress.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, ArrowRight, CircleSlash, RotateCcw } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * TASK PROGRESS: work that takes minutes, not seconds\n *\n *   running    phases fill left to right; the current one sweeps;\n *              the status line names the phase as it morphs from\n *              one to the next; timer and stats roll; Cancel\n *   done       the last bar fills, a check draws itself, \"Finished\n *              in 4m 12s\"; Cancel grows into \"Open report\"\n *   error      the failed phase fills red, the reason opens in,\n *              \"Stopped at reading\"; the button becomes Retry\n *   cancelled  stopped by the user; the button folds away\n *\n * The button and the status line are one surface each: they\n * change shape and wording instead of being swapped. For deep\n * research, long builds and batch jobs. Tell people they can\n * leave; the card is what they come back to.\n * ───────────────────────────────────────────────────────── */\n\nexport type TaskProgressStatus = \"running\" | \"done\" | \"error\" | \"cancelled\";\n\nexport interface TaskStat {\n  label: string;\n  /** Numbers roll when they change. */\n  value: React.ReactNode;\n}\n\nexport interface TaskProgressProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  title: string;\n  status: TaskProgressStatus;\n  /** The stages, e.g. [\"Searching\", \"Reading\", \"Writing\"]. */\n  phases: string[];\n  /** Index of the current phase. */\n  phase: number;\n  /** Start time (ms); shows a live timer. */\n  startedAt?: number;\n  /** Total time (ms), if you know it once finished. */\n  duration?: number;\n  stats?: TaskStat[];\n  /** A line under the progress, e.g. \"You can close this tab. We'll keep going.\" */\n  message?: string;\n  onCancel?: () => void;\n  onOpen?: () => void;\n  openLabel?: string;\n  errorText?: string;\n  onRetry?: () => void;\n}\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"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\";\nconst TWO_DIGITS = { minimumIntegerDigits: 2 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\nfunction formatTime(ms: number) {\n  const s = Math.max(0, Math.floor(ms / 1000));\n  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s`;\n}\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"160ms\"} ${MORPH}, filter 320ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\n/** A block that opens to its height and folds back to nothing. */\nfunction Fold({ show, reduced, children }: { show: boolean; reduced: boolean; children: React.ReactNode }) {\n  return (\n    <div\n      aria-hidden={!show || undefined}\n      className=\"grid\"\n      style={{\n        gridTemplateRows: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        transition: reduced ? \"none\" : `grid-template-rows 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"160ms\"} ${MORPH}`,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">{children}</div>\n    </div>\n  );\n}\n\n/** Seconds that roll up as they pass; minutes slide open at the first minute. */\nfunction Elapsed({ ms, reduced }: { ms: number; reduced: boolean }) {\n  const total = Math.max(0, Math.floor(ms / 1000));\n  const minutes = Math.floor(total / 60);\n  return (\n    <span className=\"inline-flex items-baseline\">\n      <Reveal show={minutes > 0} reduced={reduced}>\n        <NumberRoll value={minutes} suffix=\"m\" duration={500} />\n        &nbsp;\n      </Reveal>\n      <NumberRoll value={minutes ? total % 60 : total} format={minutes ? TWO_DIGITS : undefined} suffix=\"s\" direction=\"up\" duration={500} />\n    </span>\n  );\n}\n\nfunction DrawnCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${MORPH} 200ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nexport function TaskProgress({\n  title,\n  status,\n  phases,\n  phase,\n  startedAt,\n  duration,\n  stats,\n  message,\n  onCancel,\n  onOpen,\n  openLabel = \"Open result\",\n  errorText,\n  onRetry,\n  className = \"\",\n  ...props\n}: TaskProgressProps) {\n  const reduced = useReducedMotion();\n  const running = status === \"running\";\n  const [now, setNow] = React.useState(startedAt ?? 0);\n  const [endedAt, setEndedAt] = React.useState<number | undefined>(undefined);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const actionRef = React.useRef<HTMLButtonElement>(null);\n\n  React.useEffect(() => {\n    if (!running) {\n      setEndedAt((prev) => prev ?? Date.now());\n      return;\n    }\n    setEndedAt(undefined);\n    if (startedAt === undefined) return;\n    setNow(Date.now());\n    const timer = window.setInterval(() => setNow(Date.now()), 1000);\n    return () => window.clearInterval(timer);\n  }, [running, startedAt]);\n\n  const elapsed = duration ?? (startedAt === undefined ? undefined : (running ? now : (endedAt ?? now)) - startedAt);\n  const current = Math.min(Math.max(phase, 0), phases.length - 1);\n\n  // The one button: Cancel while it runs, Retry after a failure, the result once done.\n  const action =\n    running && onCancel\n      ? { kind: \"cancel\", label: \"Cancel\", run: onCancel }\n      : status === \"error\" && onRetry\n        ? { kind: \"retry\", label: \"Retry\", run: onRetry }\n        : status === \"done\" && onOpen\n          ? { kind: \"open\", label: openLabel, run: onOpen }\n          : null;\n  const [lastAction, setLastAction] = React.useState(action);\n  if (action && (action.kind !== lastAction?.kind || action.label !== lastAction.label)) setLastAction(action);\n  const shown = action ?? lastAction;\n  const primary = action?.kind === \"open\";\n\n  // If the button folds away while focused (Cancel pressed), keep focus on the card instead of the page.\n  React.useLayoutEffect(() => {\n    if (!action && document.activeElement === actionRef.current) cardRef.current?.focus({ preventScroll: true });\n  }, [action]);\n\n  const statusLine = running ? phases[current] : status === \"done\" ? \"Finished\" : status === \"error\" ? `Stopped at ${phases[current].toLowerCase()}` : \"Cancelled\";\n  const timeJoin = status === \"done\" ? \"in\" : \"after\";\n  const showTime = (status === \"done\" || status === \"cancelled\") && elapsed !== undefined;\n  const iconKey = status === \"running\" ? null : status;\n\n  return (\n    <div ref={cardRef} tabIndex={-1} className={`w-full rounded-xl border border-border bg-background p-4 outline-none ${className}`} {...props}>\n      <div className=\"flex items-baseline justify-between gap-4\">\n        <p className=\"min-w-0 truncate text-[13.5px] font-medium text-foreground\">{title}</p>\n        {elapsed !== undefined && (\n          <span aria-hidden=\"true\" className=\"shrink-0 font-mono text-[11.5px] tabular-nums text-muted-foreground\">\n            <Elapsed ms={elapsed} reduced={reduced} />\n          </span>\n        )}\n      </div>\n\n      {/* Each phase fills from the left when it's finished; the live one sweeps. */}\n      <div className=\"mt-3.5 flex gap-1\" aria-hidden=\"true\">\n        {phases.map((p, i) => {\n          const complete = status === \"done\" || i < current;\n          const failed = status === \"error\" && i === current;\n          const live = running && i === current;\n          return (\n            <span key={p} className=\"relative h-1 flex-1 overflow-hidden rounded-full bg-border\">\n              <span\n                className={`absolute inset-0 origin-left rounded-full transition-colors duration-300 ${failed ? \"bg-red-500\" : \"bg-foreground\"}`}\n                style={{ transform: complete || failed ? \"none\" : \"scaleX(0)\", transition: reduced ? \"none\" : `transform 620ms ${MORPH}, background-color 300ms` }}\n              />\n              <span\n                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]\" : \"\"}`}\n                style={{ opacity: live ? 1 : 0, transition: reduced ? \"none\" : \"opacity 300ms\" }}\n              />\n            </span>\n          );\n        })}\n      </div>\n\n      <div aria-hidden=\"true\" className=\"mt-2 flex gap-1 text-[11.5px]\">\n        {phases.map((p, i) => (\n          <span\n            key={p}\n            className={`flex-1 truncate transition-colors duration-300 ${status === \"error\" && i === current ? \"text-red-500\" : running && i === current ? \"text-foreground\" : \"text-muted-foreground\"}`}\n          >\n            {p}\n          </span>\n        ))}\n      </div>\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status === \"done\"\n          ? `${title} finished${elapsed !== undefined ? ` in ${formatTime(elapsed)}` : \"\"}`\n          : status === \"error\"\n            ? `${title} failed. ${errorText ?? \"\"}`\n            : status === \"cancelled\"\n              ? `${title} cancelled`\n              : `${phases[current]}`}\n      </p>\n\n      {stats && stats.length > 0 && (\n        <p className=\"mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground\">\n          {stats.map((s) => (\n            <span key={s.label} className=\"inline-flex items-baseline gap-[0.3em]\">\n              <span className=\"font-mono tabular-nums text-foreground\">{typeof s.value === \"number\" ? <NumberRoll value={s.value} duration={700} /> : s.value}</span>\n              {s.label}\n            </span>\n          ))}\n        </p>\n      )}\n\n      {message && (\n        <Fold show={running} reduced={reduced}>\n          <p className=\"pt-2 text-[12px] text-muted-foreground\">{message}</p>\n        </Fold>\n      )}\n      <Fold show={status === \"error\"} reduced={reduced}>\n        <p className=\"pt-3 text-[12.5px] text-red-500\">{errorText ?? \"Something went wrong partway through.\"}</p>\n      </Fold>\n\n      <div className=\"mt-4 flex min-h-8 items-center justify-between gap-3\">\n        {/* One status line: it names the phase while it runs and becomes the outcome. */}\n        <span aria-hidden=\"true\" className=\"flex min-w-0 items-center text-[12px] text-muted-foreground\">\n          <span\n            className=\"relative flex h-3.5 shrink-0 items-center justify-center\"\n            style={{ width: iconKey ? 14 : 0, marginRight: iconKey ? 6 : 0, transition: reduced ? \"none\" : `width 380ms ${MORPH}, margin 380ms ${MORPH}` }}\n          >\n            <span className=\"absolute inset-0 flex items-center justify-center text-emerald-600 dark:text-emerald-400\" style={swap(iconKey === \"done\", reduced)}>\n              <DrawnCheck drawn={iconKey === \"done\"} reduced={reduced} />\n            </span>\n            <span className=\"absolute inset-0 flex items-center justify-center text-red-500\" style={swap(iconKey === \"error\", reduced)}>\n              <AlertCircle className=\"size-3.5\" />\n            </span>\n            <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(iconKey === \"cancelled\", reduced)}>\n              <CircleSlash className=\"size-3.5\" />\n            </span>\n          </span>\n          <span className={`truncate ${running ? SHEEN : \"\"}`}>\n            <TextMorph>{statusLine}</TextMorph>\n          </span>\n          {elapsed !== undefined && (\n            <Reveal show={showTime} reduced={reduced}>\n              <span className=\"inline-flex items-baseline pl-[0.3em] tabular-nums\">\n                <TextMorph>{timeJoin}</TextMorph>\n                &nbsp;\n                <Elapsed ms={elapsed} reduced={reduced} />\n              </span>\n            </Reveal>\n          )}\n        </span>\n\n        {shown && (\n          <Reveal show={Boolean(action)} reduced={reduced} className=\"shrink-0\">\n            <span className=\"block p-0.5\">\n              <button\n                ref={actionRef}\n                type=\"button\"\n                inert={!action}\n                onClick={() => action?.run()}\n                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} ${\n                  primary ? \"bg-primary text-primary-foreground hover:bg-primary/90\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"\n                }`}\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"relative flex h-3 shrink-0 items-center justify-center overflow-hidden\"\n                  style={{ width: shown.kind === \"retry\" ? 12 : 0, marginRight: shown.kind === \"retry\" ? 6 : 0, transition: reduced ? \"none\" : `width 380ms ${MORPH}, margin 380ms ${MORPH}` }}\n                >\n                  <RotateCcw className=\"size-3 shrink-0\" style={swap(shown.kind === \"retry\", reduced)} />\n                </span>\n                <TextMorph>{shown.label}</TextMorph>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"relative flex h-3.5 shrink-0 items-center justify-center overflow-hidden\"\n                  style={{ width: shown.kind === \"open\" ? 14 : 0, marginLeft: shown.kind === \"open\" ? 6 : 0, transition: reduced ? \"none\" : `width 380ms ${MORPH}, margin 380ms ${MORPH}` }}\n                >\n                  <ArrowRight className=\"size-3.5 shrink-0\" style={swap(shown.kind === \"open\", reduced)} />\n                </span>\n              </button>\n            </span>\n          </Reveal>\n        )}\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-scan":{"from":{"transform":"translateX(-100%)"},"to":{"transform":"translateX(250%)"}},"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/task-progress"}