{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"live-activity","type":"registry:component","title":"Live activity","description":"One pill for whatever is happening now: it updates in place, tap it and it grows into a panel, done blurs into a check.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/live-activity.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * LIVE ACTIVITY: one pill for whatever is happening now\n *\n *   arrive    the pill opens out of nothing when an activity\n *             starts and folds away when it's cleared\n *   update    the same activity morphs in place: its label\n *             letter by letter, its progress ring filling, its\n *             figure rolling\n *   switch    a different activity blurs in over the last while\n *             the pill reshapes to fit it\n *   expand    tap it and the pill itself grows into a panel\n *             (size and corners easing together): title, a\n *             progress bar, steps that tick themselves off, and\n *             actions. Escape or a tap outside folds it back\n *   done      the spinner blurs into a check that draws itself;\n *             error, into an alert\n *\n * Put it where activity belongs (top of the app, over a\n * composer); it never covers what it's about.\n * ───────────────────────────────────────────────────────── */\n\nexport type LiveActivityStatus = \"running\" | \"done\" | \"error\";\n\nexport interface LiveActivityStep {\n  label: string;\n  done?: boolean;\n}\n\nexport interface LiveActivityAction {\n  label: string;\n  onClick: () => void;\n  primary?: boolean;\n}\n\nexport interface LiveActivityData {\n  /** A new id is a different activity (it blurs in); the same id updates in place. */\n  id: string;\n  /** In the pill, e.g. \"Editing 3 files\". */\n  label: string;\n  status?: LiveActivityStatus;\n  /** 0 to 1: a ring in the pill and a bar in the panel. */\n  progress?: number;\n  /** A figure at the pill's end, e.g. elapsed seconds. */\n  count?: { value: number; format?: Intl.NumberFormatOptions; suffix?: string };\n  icon?: React.ReactNode;\n  /** The panel's heading; defaults to the label. */\n  title?: string;\n  /** A line under it. */\n  detail?: string;\n  steps?: LiveActivityStep[];\n  actions?: LiveActivityAction[];\n}\n\nexport interface LiveActivityProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  activity: LiveActivityData | null;\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** The panel's width when expanded. */\n  panelWidth?: number;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst MORPH = 480;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\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\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 240ms ${EASE}, transform 360ms ${EASE}, filter 240ms ${EASE}`,\n});\n\n/** Spinner, check or alert in one slot, trading places through a blur. */\nfunction StatusMark({ status, icon, reduced, size = 16 }: { status: LiveActivityStatus; icon?: React.ReactNode; reduced: boolean; size?: number }) {\n  return (\n    <span aria-hidden=\"true\" className=\"relative flex shrink-0 items-center justify-center\" style={{ width: size, height: size }}>\n      <span className=\"absolute inset-0 flex items-center justify-center text-muted-foreground [&_svg]:size-full\" style={swap(status === \"running\", reduced)}>\n        {icon ?? (\n          <span className={`size-[85%] rounded-full border-[1.5px] border-current/30 border-t-current ${status === \"running\" ? \"animate-spin\" : \"\"} motion-reduce:animate-none`} />\n        )}\n      </span>\n      <span className=\"absolute inset-0 text-emerald-600\" style={swap(status === \"done\", reduced)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-full\">\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: status === \"done\" ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 420ms ${EASE} 120ms` }}\n          />\n        </svg>\n      </span>\n      <span className=\"absolute inset-0 text-red-500\" style={swap(status === \"error\", reduced)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-full\">\n          <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          <path d=\"M8 4.75v3.75M8 11h.01\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n        </svg>\n      </span>\n    </span>\n  );\n}\n\nfunction Ring({ value, reduced }: { value: number; reduced: boolean }) {\n  const c = 2 * Math.PI * 6;\n  return (\n    <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-4 shrink-0 -rotate-90 text-primary\">\n      <circle cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.18\" strokeWidth=\"2\" />\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"6\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeDasharray={c}\n        style={{ strokeDashoffset: c * (1 - Math.min(1, Math.max(0, value))), transition: reduced ? \"none\" : `stroke-dashoffset 500ms ${EASE}` }}\n      />\n    </svg>\n  );\n}\n\nexport function LiveActivity({ activity, expanded: expandedProp, defaultExpanded = false, onExpandedChange, panelWidth = 320, className = \"\", ...props }: LiveActivityProps) {\n  const reduced = useReducedMotion();\n  const panelId = React.useId();\n  const [ownExpanded, setOwnExpanded] = React.useState(defaultExpanded);\n  // Keep the last activity while the pill folds away, so it doesn't empty as it goes.\n  const [last, setLast] = React.useState(activity);\n  if (activity && activity !== last) setLast(activity);\n  const shown = activity ?? last;\n  const present = Boolean(activity);\n  // A cleared activity resets the panel, so the next one starts as a pill.\n  if (!activity && ownExpanded) setOwnExpanded(false);\n  const expanded = (expandedProp ?? ownExpanded) && present;\n  const status = shown?.status ?? \"running\";\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const compactRef = React.useRef<HTMLDivElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const [size, setSize] = React.useState({ compact: { w: 0, h: 36 }, panel: { w: panelWidth, h: 0 } });\n  const [enterKey, setEnterKey] = React.useState(shown?.id);\n\n  const setExpanded = React.useCallback(\n    (next: boolean) => {\n      if (expandedProp === undefined) setOwnExpanded(next);\n      onExpandedChange?.(next);\n    },\n    [expandedProp, onExpandedChange],\n  );\n\n  // Both faces are measured as they are; the surface eases between their sizes.\n  React.useLayoutEffect(() => {\n    const c = compactRef.current;\n    const p = panelRef.current;\n    if (!c || !p) return;\n    const measure = () =>\n      setSize((s) => {\n        const next = { compact: { w: c.offsetWidth, h: c.offsetHeight }, panel: { w: p.offsetWidth, h: p.offsetHeight } };\n        return s.compact.w === next.compact.w && s.compact.h === next.compact.h && s.panel.w === next.panel.w && s.panel.h === next.panel.h ? s : next;\n      });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(c);\n    observer.observe(p);\n    return () => observer.disconnect();\n  }, []);\n\n  // A different activity blurs in over the last.\n  React.useLayoutEffect(() => {\n    if (!shown || shown.id === enterKey) return;\n    setEnterKey(shown.id);\n    const el = compactRef.current;\n    if (!el || reduced) return;\n    el.animate(\n      [\n        { opacity: 0, filter: \"blur(5px)\", transform: \"translateY(4px)\" },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 360, easing: EASE },\n    );\n  }, [shown, enterKey, reduced]);\n\n  // Escape or a tap outside folds the panel back into the pill.\n  React.useEffect(() => {\n    if (!expanded) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      setExpanded(false);\n      triggerRef.current?.focus();\n    };\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) setExpanded(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [expanded, setExpanded]);\n\n  const w = !present ? 0 : expanded ? size.panel.w : size.compact.w;\n  const h = !present ? size.compact.h : expanded ? size.panel.h : size.compact.h;\n  const progress = shown?.progress;\n  const steps = shown?.steps ?? [];\n  const doneSteps = steps.filter((s) => s.done).length;\n\n  return (\n    <div ref={rootRef} className={`relative inline-block ${className}`} {...props}>\n      <div\n        className=\"relative overflow-hidden bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)]\"\n        style={{\n          width: w,\n          height: h,\n          borderRadius: expanded ? 22 : h / 2,\n          opacity: present ? 1 : 0,\n          transform: present ? \"none\" : \"scale(0.85)\",\n          transition: reduced\n            ? \"none\"\n            : `width ${MORPH}ms ${EASE}, height ${MORPH}ms ${EASE}, border-radius ${MORPH}ms ${EASE}, opacity 260ms ${EASE}, transform ${MORPH}ms ${EASE}`,\n        }}\n      >\n        {/* The pill's face. */}\n        <div\n          className=\"absolute left-0 top-0\"\n          style={{\n            opacity: expanded ? 0 : 1,\n            filter: expanded ? \"blur(4px)\" : \"none\",\n            pointerEvents: expanded ? \"none\" : \"auto\",\n            transition: reduced ? \"none\" : `opacity ${expanded ? 140 : 300}ms ${EASE}, filter 300ms ${EASE}`,\n          }}\n        >\n          <div ref={compactRef} className=\"w-max\">\n            <button\n              ref={triggerRef}\n              type=\"button\"\n              aria-expanded={expanded}\n              aria-controls={panelId}\n              inert={!present || expanded}\n              onClick={() => setExpanded(true)}\n              className=\"flex h-9 items-center gap-2 whitespace-nowrap rounded-full pl-2.5 pr-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40\"\n            >\n              {/* With progress, the ring is the mark; it hands over to the check when it's done. */}\n              <span className=\"relative flex size-4 shrink-0 items-center justify-center\">\n                <span className=\"absolute inset-0\" style={swap(progress === undefined || status !== \"running\", reduced)}>\n                  <StatusMark status={status} icon={shown?.icon} reduced={reduced} />\n                </span>\n                <span className=\"absolute inset-0\" style={swap(progress !== undefined && status === \"running\", reduced)}>\n                  <Ring value={progress ?? 0} reduced={reduced} />\n                </span>\n              </span>\n              <TextMorph>{shown?.label ?? \"\"}</TextMorph>\n              {shown?.count && (\n                <span className=\"text-[12px] font-normal tabular-nums text-muted-foreground\">\n                  <NumberRoll value={shown.count.value} format={shown.count.format} suffix={shown.count.suffix} duration={600} />\n                </span>\n              )}\n            </button>\n          </div>\n        </div>\n\n        {/* The panel the pill grows into. */}\n        <div\n          className=\"absolute left-0 top-0\"\n          // The face that isn't showing never catches the pointer.\n          style={{\n            opacity: expanded ? 1 : 0,\n            filter: expanded ? \"none\" : \"blur(4px)\",\n            pointerEvents: expanded ? \"auto\" : \"none\",\n            transition: reduced ? \"none\" : `opacity ${expanded ? `300ms ${EASE} 120ms` : `120ms ${EASE}`}, filter 300ms ${EASE}`,\n          }}\n        >\n          <div ref={panelRef} id={panelId} role=\"group\" aria-label={shown?.title ?? shown?.label} inert={!expanded} className=\"p-4\" style={{ width: `min(${panelWidth}px, calc(100vw - 24px))` }}>\n            <div className=\"flex items-start gap-2.5\">\n              <span className=\"mt-px\">\n                <StatusMark status={status} icon={shown?.icon} reduced={reduced} size={18} />\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"text-[14px] font-medium leading-snug text-foreground\">\n                  <TextMorph animateWidth={false}>{shown?.title ?? shown?.label ?? \"\"}</TextMorph>\n                </p>\n                {shown?.detail && <p className=\"mt-0.5 text-[12.5px] leading-snug text-muted-foreground\">{shown.detail}</p>}\n              </div>\n              {shown?.count && (\n                <span className=\"shrink-0 text-[12.5px] tabular-nums text-muted-foreground\">\n                  <NumberRoll value={shown.count.value} format={shown.count.format} suffix={shown.count.suffix} duration={600} />\n                </span>\n              )}\n            </div>\n\n            {progress !== undefined && (\n              <div className=\"mt-3.5\">\n                <div className=\"h-1.5 overflow-hidden rounded-full bg-muted\">\n                  <div\n                    className={`h-full rounded-full ${status === \"error\" ? \"bg-red-500\" : status === \"done\" ? \"bg-emerald-500\" : \"bg-primary\"}`}\n                    style={{ width: `${Math.round(Math.min(1, Math.max(0, progress)) * 100)}%`, transition: reduced ? \"none\" : `width 500ms ${EASE}, background-color 300ms` }}\n                  />\n                </div>\n                <p className=\"mt-1.5 flex justify-between text-[11.5px] tabular-nums text-muted-foreground\">\n                  <span>{steps.length ? `${doneSteps} of ${steps.length} steps` : \"\"}</span>\n                  <NumberRoll value={Math.min(1, Math.max(0, progress))} format={{ style: \"percent\" }} duration={500} />\n                </p>\n              </div>\n            )}\n\n            {steps.length > 0 && (\n              <ul className=\"mt-3 space-y-1.5\">\n                {steps.map((s, i) => {\n                  const current = !s.done && steps.slice(0, i).every((x) => x.done) && status === \"running\";\n                  return (\n                    <li key={s.label} className={`flex items-center gap-2 text-[12.5px] transition-colors duration-300 ${s.done ? \"text-muted-foreground\" : current ? \"text-foreground\" : \"text-muted-foreground/70\"}`}>\n                      <StatusMark status={s.done ? \"done\" : current ? \"running\" : \"running\"} icon={!s.done && !current ? <span className=\"size-[60%] rounded-full border border-current/40\" /> : undefined} reduced={reduced} size={14} />\n                      {s.label}\n                    </li>\n                  );\n                })}\n              </ul>\n            )}\n\n            {shown?.actions && shown.actions.length > 0 && (\n              <div className=\"mt-4 flex justify-end gap-2\">\n                {shown.actions.map((a) => (\n                  <button\n                    key={a.label}\n                    type=\"button\"\n                    onClick={a.onClick}\n                    className={`h-8 rounded-full px-3.5 text-[12.5px] font-medium transition-colors ${FOCUS} ${\n                      a.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                    {a.label}\n                  </button>\n                ))}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n\n      {/* Announce what changed in words, never the ticking figures. */}\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {activity ? `${activity.label}${activity.status === \"done\" ? \", done\" : activity.status === \"error\" ? \", failed\" : \"\"}` : \"\"}\n      </p>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/live-activity"}