{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"progress-tracker","type":"registry:ui","title":"Progress Tracker","description":"Onboarding checklist with an animated progress bar, ticks that draw themselves and auto play","dependencies":[],"files":[{"path":"registry/progress-tracker/progress-tracker.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\n/*\n * Progress Tracker – a checklist with a progress bar, in a single self-contained file.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ProgressStep = {\n  id: string;\n  label: string;\n  done?: boolean;\n  /** Small icon on the right, e.g. the app the step belongs to */\n  icon?: ReactNode;\n};\n\nexport type ProgressTrackerProps = {\n  title?: string;\n  steps: ProgressStep[];\n  /** Ticks the open steps one after another, then starts over – stops as soon as someone interacts */\n  autoPlay?: boolean;\n  /** Pause between two automatic steps, in ms */\n  interval?: number;\n  onChange?: (steps: ProgressStep[]) => void;\n  className?: string;\n};\n\nexport function ProgressTracker({\n  title = \"Getting started\",\n  steps: initial,\n  autoPlay = false,\n  interval = 2200,\n  onChange,\n  className,\n}: ProgressTrackerProps) {\n  const [done, setDone] = useState(() => new Set(initial.filter((s) => s.done).map((s) => s.id)));\n  const [playing, setPlaying] = useState(autoPlay);\n\n  // Steps added later bring their own done state; steps that go away no longer count\n  const seen = useRef(new Set(initial.map((s) => s.id)));\n  const ids = initial.map((s) => s.id).join(\"|\");\n  useEffect(() => {\n    const fresh = initial.filter((s) => !seen.current.has(s.id));\n    fresh.forEach((s) => seen.current.add(s.id));\n    if (fresh.some((s) => s.done)) setDone((prev) => new Set([...prev, ...fresh.filter((s) => s.done).map((s) => s.id)]));\n  }, [ids]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const total = initial.length;\n  const count = initial.filter((s) => done.has(s.id)).length;\n  const progress = total ? count / total : 0;\n\n  const toggle = (id: string) => {\n    const next = new Set(done);\n    if (next.has(id)) next.delete(id);\n    else next.add(id);\n    setDone(next);\n    onChange?.(initial.map((s) => ({ ...s, done: next.has(s.id) })));\n  };\n\n  // Auto play: tick the next open step; when all are done, start over\n  useEffect(() => {\n    if (!playing) return;\n    const open = initial.find((s) => !done.has(s.id));\n    const t = window.setTimeout(\n      () => {\n        if (open) toggle(open.id);\n        else setDone(new Set(initial.filter((s) => s.done).map((s) => s.id)));\n      },\n      open ? interval : interval * 2,\n    );\n    return () => clearTimeout(t);\n  }, [playing, done]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  return (\n    <div\n      className={[\"ui-progress\", className].filter(Boolean).join(\" \")}\n      data-complete={count === total || undefined}\n      onPointerDown={() => setPlaying(false)}\n      onKeyDown={() => setPlaying(false)}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many trackers render */}\n      <style href=\"ui-progress-tracker\" precedence=\"default\">\n        {css}\n      </style>\n\n      <div className=\"ui-progress__head\">\n        <h3 className=\"ui-progress__title\" dir=\"auto\" title={title}>\n          {title}\n        </h3>\n        <span className=\"ui-progress__count\" aria-hidden=\"true\">\n          <span className=\"ui-progress__digit\" key={count}>\n            {num.format(count)}\n          </span>\n          /{num.format(total)}\n        </span>\n      </div>\n\n      <div\n        className=\"ui-progress__bar\"\n        role=\"progressbar\"\n        aria-label={title}\n        aria-valuemin={0}\n        aria-valuemax={total}\n        aria-valuenow={count}\n        aria-valuetext={`${num.format(count)} of ${num.format(total)} done`}\n      >\n        <span className=\"ui-progress__fill\" style={{ transform: `scaleX(${progress})` }}>\n          {/* A new key replays the shine each time progress grows */}\n          <span className=\"ui-progress__shine\" key={count} />\n        </span>\n      </div>\n\n      <ul className=\"ui-progress__list\">\n        {initial.map((step, index) => {\n          const checked = done.has(step.id);\n          return (\n            <li key={step.id} style={{ animationDelay: `${180 + Math.min(index, 12) * 90}ms` }}>\n              <button\n                type=\"button\"\n                role=\"checkbox\"\n                aria-checked={checked}\n                className=\"ui-progress__step\"\n                onClick={() => toggle(step.id)}\n              >\n                <span className=\"ui-progress__check\" aria-hidden=\"true\">\n                  <svg viewBox=\"0 0 24 24\">\n                    <path d=\"M6.5 12.5l3.5 3.5 7.5-8\" pathLength={1} />\n                  </svg>\n                </span>\n                <span className=\"ui-progress__label\" title={step.label || undefined}>\n                  {/* An empty label keeps the row's height */}\n                  <span dir=\"auto\">{step.label || \"\\u00a0\"}</span>\n                </span>\n                {step.icon && (\n                  <span className=\"ui-progress__icon\" aria-hidden=\"true\">\n                    {step.icon}\n                  </span>\n                )}\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n\n// 1284 → \"1,284\"\nconst num = new Intl.NumberFormat(\"en\");\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-progress (see Theming in the docs) */\n.ui-progress {\n  --ui-pg-card: var(--aui-surface, #ffffff);\n  --ui-pg-line: var(--aui-border, #e5e5e5);\n  --ui-pg-ink: var(--aui-ink, #171717);\n  --ui-pg-muted: var(--aui-muted, #a3a3a3);\n  --ui-pg-ring: var(--aui-border-strong, #8f8f8f);\n  --ui-pg-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-pg-track: #f0f0f0;\n  --ui-pg-hover: var(--aui-subtle, #f7f7f7);\n  --ui-pg-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  --ui-pg-pop: cubic-bezier(0.34, 1.56, 0.64, 1);\n\n  box-sizing: border-box;\n  width: 100%;\n  max-width: 360px;\n  padding: 22px 16px 14px;\n  background: var(--ui-pg-card);\n  border: 1px solid var(--ui-pg-line);\n  border-radius: calc(var(--aui-radius-lg, 16px) + 4px);\n  box-shadow:\n    0 1px 2px rgba(0, 0, 0, 0.03),\n    0 8px 24px rgba(0, 0, 0, 0.035);\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  color: var(--ui-pg-ink);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-progress *,\n.ui-progress *::before,\n.ui-progress *::after {\n  box-sizing: border-box;\n}\n\n.ui-progress__head {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 0 6px;\n}\n.ui-progress__title {\n  margin: 0;\n  min-width: 0;\n  overflow-wrap: anywhere;\n  font-size: 16px;\n  font-weight: 500;\n  letter-spacing: -0.01em;\n}\n.ui-progress__count {\n  font-size: 14px;\n  color: var(--ui-pg-muted);\n  font-variant-numeric: tabular-nums;\n  white-space: nowrap;\n}\n/* The number rolls in from below whenever it changes */\n.ui-progress__digit {\n  display: inline-block;\n  animation: ui-progress-roll 560ms var(--ui-pg-ease);\n}\n\n.ui-progress__bar {\n  position: relative;\n  height: 8px;\n  margin: 16px 6px 12px;\n  border-radius: 999px;\n  background: var(--ui-pg-track);\n  overflow: hidden;\n}\n.ui-progress__fill {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: var(--ui-pg-ink);\n  transform-origin: 0 50%;\n  overflow: hidden;\n  transition: transform 1100ms var(--ui-pg-ease);\n}\n.ui-progress__shine {\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(90deg, transparent 30%, rgba(255, 255, 255, 0.28) 50%, transparent 70%) 0 0 / 220% 100%\n    no-repeat;\n  animation: ui-progress-shine 1400ms ease-out both;\n}\n\n.ui-progress__list {\n  display: grid;\n  /* One column that never grows past the card, so long labels shorten instead of pushing rows out */\n  grid-template-columns: minmax(0, 1fr);\n  gap: 2px;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n/* On mount the card settles in, then the rows fade up one after another */\n.ui-progress {\n  animation: ui-progress-in 520ms var(--ui-pg-ease) both;\n}\n.ui-progress__list > li {\n  position: relative;\n  animation: ui-progress-row-in 640ms var(--ui-pg-ease) both;\n}\n\n.ui-progress__step {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  width: 100%;\n  margin: 0;\n  padding: 9px 6px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-sm, 7px) + 5px);\n  background: transparent;\n  font: inherit;\n  font-size: 15px;\n  line-height: 1.45;\n  color: inherit;\n  text-align: left;\n  cursor: pointer;\n  transition: background-color 160ms ease;\n}\n.ui-progress__step:hover {\n  background: var(--ui-pg-hover);\n}\n.ui-progress__step:focus-visible {\n  outline: 2px solid var(--ui-pg-focus);\n  outline-offset: -2px;\n}\n\n/* Checkbox: the ring fills and pops, then the tick draws itself */\n.ui-progress__check {\n  position: relative;\n  flex: none;\n  display: grid;\n  place-items: center;\n  width: 20px;\n  height: 20px;\n  border: 1.5px solid var(--ui-pg-ring);\n  border-radius: 50%;\n  background: transparent;\n  transition:\n    background-color 220ms var(--ui-pg-ease),\n    border-color 220ms var(--ui-pg-ease);\n}\n.ui-progress__check svg {\n  width: 14px;\n  height: 14px;\n  fill: none;\n  stroke: var(--aui-on-primary, #ffffff);\n  stroke-width: 2.4;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n  stroke-dasharray: 1;\n  stroke-dashoffset: 1;\n  transition: stroke-dashoffset 120ms ease-in;\n}\n.ui-progress__step[aria-checked=\"true\"] .ui-progress__check {\n  background: var(--aui-primary, var(--ui-pg-ink));\n  border-color: var(--aui-primary, var(--ui-pg-ink));\n  animation: ui-progress-pop 560ms var(--ui-pg-ease);\n}\n.ui-progress__step[aria-checked=\"true\"] .ui-progress__check svg {\n  stroke-dashoffset: 0;\n  transition: stroke-dashoffset 420ms var(--ui-pg-ease) 160ms;\n}\n\n/* Label: grays out while a line strikes through it, line by line */\n.ui-progress__label {\n  flex: 1;\n  min-width: 0;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  transition: color 300ms ease;\n}\n.ui-progress__label > span {\n  background: linear-gradient(currentColor, currentColor) 0 58% / 0% 1.5px no-repeat;\n  transition: background-size 380ms var(--ui-pg-ease);\n}\n.ui-progress__step[aria-checked=\"true\"] .ui-progress__label {\n  color: var(--ui-pg-muted);\n}\n.ui-progress__step[aria-checked=\"true\"] .ui-progress__label > span {\n  background-size: 100% 1.5px;\n  transition: background-size 620ms var(--ui-pg-ease) 220ms;\n}\n\n.ui-progress__icon {\n  flex: none;\n  display: grid;\n  place-items: center;\n  width: 18px;\n  height: 18px;\n  transition: opacity 300ms ease;\n}\n/* Any icon – an svg or an image of any shape – fits its 16px tile */\n.ui-progress__icon {\n  overflow: hidden;\n}\n.ui-progress__icon > :is(svg, img) {\n  width: 100%;\n  height: 100%;\n  object-fit: contain;\n}\n.ui-progress__step[aria-checked=\"true\"] .ui-progress__icon {\n  opacity: 0.45;\n}\n\n@keyframes ui-progress-pop {\n  0% { transform: scale(0.9); }\n  60% { transform: scale(1.06); }\n  100% { transform: scale(1); }\n}\n@keyframes ui-progress-in {\n  from {\n    opacity: 0;\n    transform: translateY(8px);\n  }\n}\n@keyframes ui-progress-row-in {\n  from {\n    opacity: 0;\n    filter: blur(3px);\n    transform: translateY(8px);\n  }\n}\n@keyframes ui-progress-roll {\n  from { opacity: 0; transform: translateY(60%); }\n  to { opacity: 1; transform: none; }\n}\n@keyframes ui-progress-shine {\n  from { background-position: 120% 0; opacity: 1; }\n  to { background-position: -20% 0; opacity: 0; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-progress,\n  .ui-progress *,\n  .ui-progress *::before,\n  .ui-progress *::after {\n    animation: none !important;\n    transition-duration: 1ms !important;\n    transition-delay: 0ms !important;\n  }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-progress {\n  --ui-pg-card: var(--aui-surface, #232326);\n  --ui-pg-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-pg-ink: var(--aui-ink, #ededed);\n  --ui-pg-muted: var(--aui-muted, #8f8f8f);\n  --ui-pg-ring: var(--aui-border-strong, #6b6b70);\n  --ui-pg-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  --ui-pg-track: rgba(255, 255, 255, 0.1);\n  --ui-pg-hover: var(--aui-subtle, #2a2a2d);\n  box-shadow:\n    var(--aui-shadow-card, 0 1px 2px rgba(0, 0, 0, 0.4),\n    0 8px 24px rgba(0, 0, 0, 0.35));\n}\n/* The filled check is light in dark mode, so the tick turns dark */\n:where(.dark, [data-theme=\"dark\"]) .ui-progress__check svg { stroke: var(--aui-on-primary, #121213); }\n:where(.dark, [data-theme=\"dark\"]) .ui-progress__shine {\n  background-image: linear-gradient(90deg, transparent 30%, rgba(255, 255, 255, 0.7) 50%, transparent 70%);\n}\n`;\n"}]}