{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"changelog-timeline","type":"registry:component","title":"Changelog timeline","description":"What shipped, drawn as you read: a rail that fills, dots that light, filters that fold the rest away.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/changelog-timeline.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 TIMELINE: what shipped, drawn as you read\n *\n *   read      a hairline rail runs down the page and fills as you\n *             scroll; each entry's dot fills as the line reaches it\n *   arrive    entries rise out of a light blur the first time they\n *             come into view\n *   filter    All, New, Improved, Fixed: a pill is thrown to the\n *             choice, entries that don't match fold away and the\n *             count rolls\n *   older     \"Show 6 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 ChangelogTimelineProps 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\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};\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/** 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 ChangelogTimeline({ entries, initialCount = 4, filters: filtersProp, locales, className = \"\", ...props }: ChangelogTimelineProps) {\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 [progress, setProgress] = React.useState(0);\n  const [passed, setPassed] = React.useState<Set<string>>(() => new Set());\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const railRef = React.useRef<HTMLDivElement>(null);\n  const dotRefs = React.useRef(new Map<string, HTMLSpanElement>());\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\n  // The rail fills to the middle of the screen as you read; dots fill as the line reaches them.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    let frame = 0;\n    let visible = false;\n    const update = () => {\n      frame = 0;\n      const rail = railRef.current?.getBoundingClientRect();\n      if (!rail) return;\n      const anchor = window.innerHeight * 0.55;\n      const p = Math.min(1, Math.max(0, (anchor - rail.top) / Math.max(1, rail.height)));\n      setProgress((x) => (Math.abs(x - p) < 0.002 ? x : p));\n      const now = new Set<string>();\n      dotRefs.current.forEach((dot, key) => {\n        if (dot.getBoundingClientRect().top + 5 <= anchor) now.add(key);\n      });\n      setPassed((s) => (s.size === now.size && [...now].every((k) => s.has(k)) ? s : now));\n    };\n    const onScroll = () => {\n      if (visible && !frame) frame = requestAnimationFrame(update);\n    };\n    // Only listen while the timeline is on screen.\n    const observer = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) update();\n    });\n    observer.observe(root);\n    window.addEventListener(\"scroll\", onScroll, { passive: true, capture: true });\n    window.addEventListener(\"resize\", onScroll);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", onScroll, { capture: true });\n      window.removeEventListener(\"resize\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, []);\n\n  // Folding entries move the dots: re-check once the folds have settled.\n  React.useEffect(() => {\n    const t = window.setTimeout(() => window.dispatchEvent(new Event(\"resize\")), 520);\n    return () => window.clearTimeout(t);\n  }, [filter, showAll]);\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 ref={rootRef} 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 rail, and the line that fills it as you read. */}\n        <div ref={railRef} aria-hidden=\"true\" className=\"absolute bottom-2 top-2 left-[5px] w-px bg-border sm:left-[133px]\">\n          <div className=\"absolute inset-0 origin-top bg-foreground/70\" style={{ transform: `scaleY(${progress})` }} />\n        </div>\n\n        <ol className=\"relative\">\n          {entries.map((e) => {\n            const shown = shownIds.has(e.id);\n            const lit = passed.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 sm:grid-cols-[110px_11px_1fr] sm:gap-x-[17px]\">\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 line 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: lit ? \"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: lit ? \"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-[31px] sm:pl-[155px]\">\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-timeline"}