{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"timeline","type":"registry:ui","title":"Timeline","description":"Vertical timeline whose glowing progress line fills as you scroll, lighting up markers and revealing entries.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/timeline.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, type ReactNode, type RefObject } from \"react\";\n\nexport interface TimelineItem {\n  /** Short label shown above the title, e.g. a date or version. */\n  date: string;\n  title: string;\n  description?: ReactNode;\n  /** Optional icon rendered inside the marker. */\n  icon?: ReactNode;\n}\n\nexport interface TimelineProps {\n  items: TimelineItem[];\n  /** Element that scrolls. Defaults to the window. */\n  scrollContainerRef?: RefObject<HTMLElement | null>;\n  /** Where in the viewport the progress head sits, 0 (top) to 1 (bottom). */\n  anchor?: number;\n  /** Progress line color. */\n  color?: string;\n  /** Px entries slide up while fading in. */\n  distance?: number;\n  className?: string;\n}\n\nconst css = `\n.timeline-entry{opacity:0;transform:translate3d(0,var(--timeline-distance),0);transition:opacity .6s ease,transform .7s cubic-bezier(.2,.8,.2,1)}\n.timeline-entry[data-shown]{opacity:1;transform:none}\n.timeline-dot{transition:background-color .3s,border-color .3s,box-shadow .3s,color .3s}\n.timeline-dot[data-reached]{border-color:var(--timeline-color);background:var(--timeline-color);color:#0a0a0a;box-shadow:0 0 0 5px color-mix(in oklab,var(--timeline-color) 22%,transparent),0 0 18px var(--timeline-color)}\n@media (prefers-reduced-motion: reduce){.timeline-entry{opacity:1;transform:none;transition:none}.timeline-dot{transition:none}}\n`;\n\nexport function Timeline({ items, scrollContainerRef, anchor = 0.5, color = \"#a3e635\", distance = 24, className }: TimelineProps) {\n  const root = useRef<HTMLOListElement>(null);\n  const fill = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const el = root.current;\n    const bar = fill.current;\n    if (!el || !bar) return;\n    const scroller = scrollContainerRef?.current ?? null;\n    const target: HTMLElement | Window = scroller ?? window;\n    const entries = Array.from(el.querySelectorAll<HTMLElement>(\"[data-entry]\"));\n    const dots = Array.from(el.querySelectorAll<HTMLElement>(\"[data-dot]\"));\n    let raf = 0;\n\n    const update = () => {\n      raf = 0;\n      const view = scroller ? scroller.getBoundingClientRect() : { top: 0, height: window.innerHeight };\n      const r = el.getBoundingClientRect();\n      const head = view.top + view.height * anchor - r.top; // px from the list's top\n      const p = Math.min(1, Math.max(0, head / r.height));\n      bar.style.transform = `scaleY(${p})`;\n      for (const d of dots) {\n        const b = d.getBoundingClientRect();\n        const reached = b.top + b.height / 2 - r.top <= head;\n        if (reached !== d.hasAttribute(\"data-reached\")) d.toggleAttribute(\"data-reached\", reached);\n      }\n    };\n    const schedule = () => {\n      if (!raf) raf = requestAnimationFrame(update);\n    };\n\n    // Entries reveal once, when they scroll into the container.\n    const io = new IntersectionObserver(\n      (list) => {\n        for (const e of list) {\n          if (e.isIntersecting) {\n            (e.target as HTMLElement).dataset.shown = \"\";\n            io.unobserve(e.target);\n          }\n        }\n      },\n      { root: scroller, rootMargin: \"0px 0px -12% 0px\", threshold: 0.15 },\n    );\n    entries.forEach((e) => io.observe(e));\n\n    const ro = new ResizeObserver(schedule);\n    ro.observe(el);\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n    update();\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n      ro.disconnect();\n      target.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [scrollContainerRef, anchor, items.length]);\n\n  return (\n    <ol\n      ref={root}\n      className={`relative ${className ?? \"\"}`}\n      style={{ [\"--timeline-color\" as string]: color, [\"--timeline-distance\" as string]: `${distance}px` }}\n    >\n      <style href=\"timeline\" precedence=\"default\">\n        {css}\n      </style>\n      {/* Track + fill. The fill scales from the top, so scrolling never triggers layout. */}\n      <div aria-hidden className=\"absolute bottom-3 left-[15px] top-3 w-0.5 -translate-x-1/2 rounded-full bg-border\">\n        <div\n          ref={fill}\n          className=\"absolute inset-0 origin-top rounded-full\"\n          style={{\n            transform: \"scaleY(0)\",\n            background: `linear-gradient(to bottom, transparent, ${color} 12%)`,\n            boxShadow: `0 0 12px ${color}`,\n          }}\n        />\n      </div>\n      {items.map((item, i) => (\n        <li key={i} className=\"relative pb-12 pl-14 last:pb-0\">\n          <span\n            data-dot\n            aria-hidden\n            className=\"timeline-dot absolute left-0 top-0.5 grid size-[30px] place-items-center rounded-full border-2 border-border bg-background text-muted-foreground [&_svg]:size-3.5\"\n          >\n            {item.icon ?? <span className=\"size-1.5 rounded-full bg-current\" />}\n          </span>\n          <div data-entry className=\"timeline-entry\" style={{ transitionDelay: \"60ms\" }}>\n            <p className=\"font-mono text-xs uppercase tracking-[0.18em] text-muted-foreground\">{item.date}</p>\n            <h3 className=\"mt-1.5 text-lg font-semibold tracking-tight text-foreground\">{item.title}</h3>\n            {item.description && <div className=\"mt-2 text-sm leading-relaxed text-muted-foreground\">{item.description}</div>}\n          </div>\n        </li>\n      ))}\n    </ol>\n  );\n}\n"}]}