{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"changelog-trace","type":"registry:component","title":"Changelog trace","description":"What shipped, routed like a circuit: the line steps aside between releases, and a bead runs down it as you read.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/changelog-trace.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 * CHANGELOG TRACE: what shipped, routed like a circuit\n *\n *   read      the line is a trace: straight through each\n *             release, stepped aside in between. A bead runs\n *             down it as you scroll, a beat behind the page,\n *             following every bend, and fills each dot it reaches\n *   arrive    entries rise out of a light blur the first time\n *             they come into view\n *   filter    All, New, Improved, Fixed: the pill is thrown to\n *             the choice, entries that don't match fold away,\n *             the trace re-routes as they close and the count\n *             rolls\n *   older     \"Show 3 older\" folds the rest open and morphs to\n *             \"Show fewer\"\n *\n * Every entry has an anchor, so a release can be linked to.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ChangelogEntry {\n  id: string;\n  date: string | Date;\n  title: string;\n  version?: string;\n  /** e.g. [\"new\"], [\"improved\", \"fixed\"]. */\n  tags?: string[];\n  body?: React.ReactNode;\n  /** An image or video under the text. */\n  media?: React.ReactNode;\n}\n\nexport interface ChangelogTraceProps extends React.HTMLAttributes<HTMLDivElement> {\n  entries: ChangelogEntry[];\n  /** How many show before \"Show older\". */\n  initialCount?: number;\n  /** Filter chips, by tag; defaults to the tags in the entries. */\n  filters?: { tag: string; label: string }[];\n  locales?: string | string[];\n}\n\ntype Point = { x: number; y: number };\ntype Route = { xs: number[]; ys: number[]; ls: number[]; total: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst TAG_LABEL: Record<string, string> = { new: \"New\", improved: \"Improved\", fixed: \"Fixed\" };\nconst TAG_TONE: Record<string, string> = {\n  new: \"bg-primary/10 text-primary\",\n  improved: \"bg-muted text-foreground/80\",\n  fixed: \"bg-muted text-muted-foreground\",\n};\nconst STEP = 14; // how far the trace steps aside between releases\nconst CLEAR = 24; // straight run either side of a dot\nconst CORNER = 8; // how much of each bend is rounded\nconst LAG = 120; // how far the bead trails the page, in ms\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/** The bends: straight through each stop, stepped aside between. A step shrinks with its gap, so a release folding away flattens it smoothly. */\nfunction corners(x: number, stops: number[]): Point[] {\n  const points: Point[] = [{ x, y: stops[0] }];\n  for (let i = 0; i < stops.length - 1; i++) {\n    const a = stops[i];\n    const b = stops[i + 1];\n    const step = Math.min(STEP, Math.max(0, (b - a - 2 * CLEAR) / 3));\n    if (step < 0.5) continue;\n    points.push({ x, y: a + CLEAR }, { x: x - step, y: a + CLEAR + step }, { x: x - step, y: b - CLEAR - step }, { x, y: b - CLEAR });\n  }\n  points.push({ x, y: stops[stops.length - 1] });\n  return points;\n}\n\n/** Rounds every bend into the path, and samples it so the bead can find any height along it without asking the DOM. */\nfunction trace(points: Point[]): { d: string; route: Route } {\n  const r = (n: number) => Math.round(n * 100) / 100;\n  const xs = [points[0].x];\n  const ys = [points[0].y];\n  const ls = [0];\n  const add = (x: number, y: number) => {\n    const i = xs.length - 1;\n    ls.push(ls[i] + Math.hypot(x - xs[i], y - ys[i]));\n    xs.push(x);\n    ys.push(y);\n  };\n  let d = `M${r(points[0].x)} ${r(points[0].y)}`;\n  for (let i = 1; i < points.length - 1; i++) {\n    const [prev, p, next] = [points[i - 1], points[i], points[i + 1]];\n    const d1 = Math.hypot(p.x - prev.x, p.y - prev.y);\n    const d2 = Math.hypot(next.x - p.x, next.y - p.y);\n    const k = Math.min(CORNER, d1 / 2, d2 / 2);\n    if (!(k > 0)) continue;\n    const a = { x: p.x + ((prev.x - p.x) * k) / d1, y: p.y + ((prev.y - p.y) * k) / d1 };\n    const b = { x: p.x + ((next.x - p.x) * k) / d2, y: p.y + ((next.y - p.y) * k) / d2 };\n    d += `L${r(a.x)} ${r(a.y)}Q${r(p.x)} ${r(p.y)} ${r(b.x)} ${r(b.y)}`;\n    add(a.x, a.y);\n    for (let s = 1; s <= 6; s++) {\n      const t = s / 6;\n      const u = 1 - t;\n      add(u * u * a.x + 2 * u * t * p.x + t * t * b.x, u * u * a.y + 2 * u * t * p.y + t * t * b.y);\n    }\n  }\n  const last = points[points.length - 1];\n  d += `L${r(last.x)} ${r(last.y)}`;\n  add(last.x, last.y);\n  return { d, route: { xs, ys, ls, total: ls[ls.length - 1] } };\n}\n\n/** Where the trace is at height y, and how far along it that is. The trace only ever runs downward. */\nfunction at(route: Route, y: number) {\n  const { xs, ys, ls } = route;\n  const n = ys.length - 1;\n  if (y <= ys[0]) return { x: xs[0], l: 0 };\n  if (y >= ys[n]) return { x: xs[n], l: ls[n] };\n  let lo = 0;\n  let hi = n;\n  while (hi - lo > 1) {\n    const mid = (lo + hi) >> 1;\n    if (ys[mid] < y) lo = mid;\n    else hi = mid;\n  }\n  const t = ys[hi] === ys[lo] ? 0 : (y - ys[lo]) / (ys[hi] - ys[lo]);\n  return { x: xs[lo] + (xs[hi] - xs[lo]) * t, l: ls[lo] + (ls[hi] - ls[lo]) * t };\n}\n\n/** Position inside `root` from layout boxes, so transforms (an entry still arriving) don't throw it off. */\nfunction offsetIn(el: HTMLElement, root: HTMLElement) {\n  let x = el.offsetWidth / 2;\n  let y = el.offsetHeight / 2;\n  let node: HTMLElement | null = el;\n  while (node && node !== root) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n    node = node.offsetParent as HTMLElement | null;\n  }\n  return { x, y };\n}\n\n/** Rises out of a light blur the first time it scrolls into view. */\nfunction Arrive({ reduced, children }: { reduced: boolean; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [seen, setSeen] = React.useState(reduced);\n  React.useEffect(() => {\n    if (seen) return;\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setSeen(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -12% 0px\" }\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [seen]);\n  return (\n    <div\n      ref={ref}\n      style={{\n        opacity: seen ? 1 : 0,\n        transform: seen ? \"none\" : \"translateY(14px)\",\n        filter: seen ? \"none\" : \"blur(4px)\",\n        transition: reduced ? \"none\" : `opacity 560ms ${EASE}, transform 700ms ${EASE}, filter 560ms ${EASE}`,\n      }}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport function ChangelogTrace({ entries, initialCount = 4, filters: filtersProp, locales, className = \"\", ...props }: ChangelogTraceProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [filter, setFilter] = React.useState<string | null>(null);\n  const [showAll, setShowAll] = React.useState(false);\n  const [lit, setLit] = React.useState<Set<string>>(() => new Set());\n  const listRef = React.useRef<HTMLOListElement>(null);\n  const baseRef = React.useRef<SVGPathElement>(null);\n  const fillRef = React.useRef<SVGPathElement>(null);\n  const beadRef = React.useRef<SVGCircleElement>(null);\n  const dotRefs = React.useRef(new Map<string, HTMLSpanElement>());\n  const reducedRef = React.useRef(reduced);\n  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [pill, setPill] = React.useState({ left: 0, width: 0, ready: false });\n\n  const filters = React.useMemo(() => {\n    if (filtersProp) return filtersProp;\n    const tags = Array.from(new Set(entries.flatMap((e) => e.tags ?? [])));\n    return tags.map((tag) => ({ tag, label: TAG_LABEL[tag] ?? tag.charAt(0).toUpperCase() + tag.slice(1) }));\n  }, [entries, filtersProp]);\n  const chips = [{ tag: null as string | null, label: \"All\" }, ...filters];\n\n  const matches = entries.filter((e) => !filter || e.tags?.includes(filter));\n  const limit = showAll ? Infinity : initialCount;\n  const shownIds = new Set(matches.slice(0, limit).map((e) => e.id));\n  const hiddenOlder = Math.max(0, matches.length - initialCount);\n  const idsKey = entries.map((e) => e.id).join(\"\\n\");\n\n  React.useEffect(() => {\n    reducedRef.current = reduced;\n  }, [reduced]);\n\n  // The trace is laid out from where the dots sit, and drawn as far as the reader has got. Every frame writes straight to the SVG;\n  // React only hears about it when a dot lights.\n  React.useEffect(() => {\n    const list = listRef.current;\n    const base = baseRef.current;\n    const fill = fillRef.current;\n    const bead = beadRef.current;\n    if (!list || !base || !fill || !bead) return;\n    let route: Route | null = null;\n    let dots: { id: string; y: number }[] = [];\n    let drawn = NaN;\n    let frame = 0;\n    let last = 0;\n    let visible = false;\n    let litKey = \"\";\n\n    const paint = () => {\n      if (!route) return;\n      const top = route.ys[0];\n      const end = route.ys[route.ys.length - 1];\n      const y = Math.min(end, Math.max(top, drawn));\n      const { x, l } = at(route, y);\n      fill.style.strokeDashoffset = String(route.total - l);\n      bead.style.transform = `translate(${x}px, ${y}px)`;\n      bead.style.opacity = y > top + 1 && y < end - 1 ? \"1\" : \"0\";\n      const now = dots.filter((d) => d.y <= y + 0.5).map((d) => d.id);\n      const key = now.join(\" \");\n      if (key !== litKey) {\n        litKey = key;\n        setLit(new Set(now));\n      }\n    };\n\n    // One frame: aim at 55% down the screen, close part of the gap (all of it with reduced motion), draw.\n    const tick = (now: number) => {\n      frame = 0;\n      if (!route) return;\n      const top = route.ys[0];\n      const end = route.ys[route.ys.length - 1];\n      const target = Math.min(end, Math.max(top, window.innerHeight * 0.55 - list.getBoundingClientRect().top));\n      const dt = last ? Math.min(64, now - last) : 16;\n      last = now;\n      if (Number.isNaN(drawn)) drawn = top;\n      drawn += (target - drawn) * (reducedRef.current ? 1 : 1 - Math.exp(-dt / LAG));\n      if (Math.abs(target - drawn) < 0.25) drawn = target;\n      paint();\n      if (drawn !== target) frame = requestAnimationFrame(tick);\n      else last = 0;\n    };\n    const run = () => {\n      if (visible && !frame) frame = requestAnimationFrame(tick);\n    };\n\n    const ids = idsKey.split(\"\\n\");\n    const layout = () => {\n      // Folded releases at the end sit just below the list; hold them to its last line.\n      const end = list.offsetHeight - 8;\n      const found = ids.flatMap((key) => {\n        const el = dotRefs.current.get(key);\n        if (!el) return [];\n        const { x, y } = offsetIn(el, list);\n        return [{ id: key, x, y: Math.min(y, end) }];\n      });\n      if (!found.length) return;\n      dots = found.map(({ id, y }) => ({ id, y }));\n      const { d, route: next } = trace(corners(found[0].x, [...dots.map((dot) => dot.y), Math.max(end, dots[dots.length - 1].y)]));\n      route = next;\n      base.setAttribute(\"d\", d);\n      fill.setAttribute(\"d\", d);\n      fill.setAttribute(\"pathLength\", String(next.total));\n      fill.style.strokeDasharray = `${next.total} ${next.total}`;\n      if (Number.isNaN(drawn)) fill.style.strokeDashoffset = String(next.total);\n      else paint();\n      run();\n    };\n\n    // Folding entries resize the list every frame they move, so the trace re-routes with them.\n    const resize = new ResizeObserver(layout);\n    resize.observe(list);\n    list.querySelectorAll(\":scope > li\").forEach((li) => resize.observe(li));\n    // Only listen while the timeline is on screen.\n    const seen = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      run();\n    });\n    seen.observe(list);\n    window.addEventListener(\"scroll\", run, { passive: true, capture: true });\n    window.addEventListener(\"resize\", run);\n    return () => {\n      resize.disconnect();\n      seen.disconnect();\n      window.removeEventListener(\"scroll\", run, { capture: true });\n      window.removeEventListener(\"resize\", run);\n      cancelAnimationFrame(frame);\n    };\n  }, [idsKey]);\n\n  React.useLayoutEffect(() => {\n    const el = chipRefs.current[chips.findIndex((c) => c.tag === filter)];\n    if (!el) return;\n    setPill((p) => (p.left === el.offsetLeft && p.width === el.offsetWidth ? p : { left: el.offsetLeft, width: el.offsetWidth, ready: p.width > 0 }));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [filter, filters.length]);\n\n  const dateText = (d: string | Date) => new Date(d).toLocaleDateString(locales ?? \"en-GB\", { day: \"numeric\", month: \"short\", year: \"numeric\" });\n  const fold = (open: boolean): React.CSSProperties => ({\n    gridTemplateRows: open ? \"1fr\" : \"0fr\",\n    opacity: open ? 1 : 0,\n    transition: reduced ? \"none\" : `grid-template-rows 480ms ${EASE}, opacity ${open ? \"380ms\" : \"160ms\"} ${EASE}`,\n  });\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"mb-8 flex flex-wrap items-center justify-between gap-3\">\n        <div role=\"radiogroup\" aria-label=\"Show\" className=\"relative inline-flex rounded-full bg-muted p-0.5\">\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n            style={{ width: pill.width, transform: `translateX(${pill.left}px)`, transition: pill.ready && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\" }}\n          />\n          {chips.map((c, i) => {\n            const selected = c.tag === filter;\n            return (\n              <button\n                key={c.label}\n                ref={(el) => {\n                  chipRefs.current[i] = el;\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={selected}\n                tabIndex={selected ? 0 : -1}\n                onClick={() => setFilter(c.tag)}\n                onKeyDown={(e) => {\n                  if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n                  e.preventDefault();\n                  const next = (i + (e.key === \"ArrowRight\" ? 1 : -1) + chips.length) % chips.length;\n                  setFilter(chips[next].tag);\n                  chipRefs.current[next]?.focus();\n                }}\n                className={`relative h-8 rounded-full px-3.5 text-[12.5px] font-medium transition-colors duration-300 ${FOCUS} ${selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"}`}\n              >\n                {c.label}\n              </button>\n            );\n          })}\n        </div>\n        <p className=\"flex items-baseline gap-[0.3em] text-[12.5px] tabular-nums text-muted-foreground\">\n          <NumberRoll value={matches.length} duration={600} />\n          <TextMorph>{matches.length === 1 ? \"update\" : \"updates\"}</TextMorph>\n        </p>\n      </div>\n\n      <div className=\"relative\">\n        {/* The trace, the part of it you've read, and the bead at its tip. */}\n        <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\">\n          <path ref={baseRef} fill=\"none\" strokeWidth={1} className=\"stroke-border\" />\n          <path ref={fillRef} fill=\"none\" strokeWidth={1} strokeLinecap=\"round\" className=\"stroke-foreground/70\" />\n          <circle ref={beadRef} r={2.5} className=\"fill-foreground\" style={{ opacity: 0, transition: reduced ? \"none\" : \"opacity 200ms\" }} />\n        </svg>\n\n        <ol ref={listRef} className=\"relative\">\n          {entries.map((e) => {\n            const shown = shownIds.has(e.id);\n            const on = lit.has(e.id);\n            return (\n              <li key={e.id} id={e.id} inert={!shown} aria-hidden={!shown || undefined} className=\"grid scroll-mt-24\" style={fold(shown)}>\n                <div className=\"min-h-0 overflow-hidden\">\n                  <Arrive reduced={reduced}>\n                    <article className=\"grid grid-cols-[11px_1fr] gap-x-5 pb-12 pl-4 sm:grid-cols-[104px_11px_1fr] sm:gap-x-6 sm:pl-0\">\n                      <div className=\"hidden pt-0.5 text-right sm:block\">\n                        <time dateTime={new Date(e.date).toISOString()} className=\"text-[12.5px] text-muted-foreground\">\n                          {dateText(e.date)}\n                        </time>\n                        {e.version && <p className=\"mt-1 font-mono text-[11.5px] text-muted-foreground/80\">{e.version}</p>}\n                      </div>\n                      {/* The dot fills when the bead reaches it. */}\n                      <span\n                        ref={(el) => {\n                          if (el) dotRefs.current.set(e.id, el);\n                          else dotRefs.current.delete(e.id);\n                        }}\n                        aria-hidden=\"true\"\n                        className=\"relative mt-[5px] flex size-[11px] items-center justify-center rounded-full bg-background shadow-[inset_0_0_0_1px_var(--border)] transition-[box-shadow] duration-500\"\n                        style={{ boxShadow: on ? \"inset 0 0 0 1px color-mix(in oklab, var(--foreground) 70%, transparent)\" : undefined }}\n                      >\n                        <span\n                          className=\"size-[5px] rounded-full bg-foreground/70\"\n                          style={{ transform: on ? \"scale(1)\" : \"scale(0)\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }}\n                        />\n                      </span>\n                      <div className=\"min-w-0\">\n                        <div className=\"mb-2 flex flex-wrap items-center gap-2 sm:hidden\">\n                          <time dateTime={new Date(e.date).toISOString()} className=\"text-[12px] text-muted-foreground\">\n                            {dateText(e.date)}\n                          </time>\n                          {e.version && <span className=\"font-mono text-[11px] text-muted-foreground/80\">{e.version}</span>}\n                        </div>\n                        {e.tags && e.tags.length > 0 && (\n                          <div className=\"mb-2 flex flex-wrap gap-1.5\">\n                            {e.tags.map((t) => (\n                              <span key={t} className={`rounded-full px-2 py-px text-[11px] font-medium ${TAG_TONE[t] ?? \"bg-muted text-foreground/80\"}`}>\n                                {TAG_LABEL[t] ?? t}\n                              </span>\n                            ))}\n                          </div>\n                        )}\n                        <h3 className=\"text-[17px] font-medium leading-snug tracking-[-0.015em] text-foreground\">\n                          <a href={`#${e.id}`} className={`rounded hover:underline hover:decoration-border hover:underline-offset-4 ${FOCUS}`}>\n                            {e.title}\n                          </a>\n                        </h3>\n                        {e.body && <div className=\"mt-2 text-[14px] leading-relaxed text-muted-foreground [&_li]:mt-1 [&_ul]:mt-2 [&_ul]:list-disc [&_ul]:pl-5\">{e.body}</div>}\n                        {e.media && <div className=\"mt-4 overflow-hidden rounded-[16px] border border-border\">{e.media}</div>}\n                      </div>\n                    </article>\n                  </Arrive>\n                </div>\n              </li>\n            );\n          })}\n        </ol>\n\n        {hiddenOlder > 0 && (\n          <div className=\"pl-[47px] sm:pl-[163px]\">\n            <button\n              type=\"button\"\n              aria-expanded={showAll}\n              onClick={() => setShowAll((s) => !s)}\n              className={`inline-flex h-9 items-center rounded-full px-4 text-[13px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n            >\n              <TextMorph>{showAll ? \"Show fewer\" : `Show ${hiddenOlder} older`}</TextMorph>\n            </button>\n          </div>\n        )}\n      </div>\n      <p id={`${id}-status`} role=\"status\" className=\"sr-only\">\n        {`${matches.length} ${matches.length === 1 ? \"update\" : \"updates\"}`}\n      </p>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/changelog-trace"}