{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline-scroll",
  "title": "Timeline Scroll",
  "description": "Minimal horizontal timeline of years and milestones that pins and scrolls sideways with the page, or drags with inertia. The centered year lights up.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/timeline-scroll/timeline-scroll.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from \"react\"\nimport { animate, motion, useMotionValue, useMotionValueEvent } from \"motion/react\"\nimport gsap from \"gsap\"\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\"\nimport { useGSAP } from \"@gsap/react\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\ngsap.registerPlugin(useGSAP, ScrollTrigger)\n\nexport interface TimelineScrollItem {\n  year: string | number\n  title: string\n  description?: string\n  tag?: string\n}\n\nexport interface TimelineScrollProps {\n  /** Milestones in order: { year, title, description?, tag? }. */\n  items: TimelineScrollItem[]\n  /** \"scroll\" pins the section and moves it sideways with vertical scroll; \"drag\" is a free horizontal drag/swipe with inertia and no pinning. Default: \"scroll\" */\n  mode?: \"scroll\" | \"drag\"\n  /** Width of each milestone column (any CSS length). Default: \"clamp(240px, 44cqw, 560px)\" */\n  itemWidth?: string\n  /** Font size of the years (any CSS length). Default: \"clamp(3.5rem, min(15cqw, 24cqh), 12rem)\" */\n  yearSize?: string\n  /** Color of the active year, its tick and the progress line. Default: \"#ff4d12\" */\n  accent?: string\n  /** Fill the baseline in the accent color up to the center. Default: true */\n  showProgress?: boolean\n  /** Rolling year counter in the top-right corner. Default: true */\n  showCounter?: boolean\n  /** Vertical placement of the timeline. Default: \"center\" */\n  align?: \"top\" | \"center\"\n  /** Scroll distance multiplier. Higher = slower horizontal travel. Default: 1 */\n  scrollLength?: number\n  /** Snap to the nearest milestone when scrolling or dragging stops. Default: false */\n  snap?: boolean\n  /** Small heading in the top-left corner. Default: \"Our journey\" */\n  title?: string\n  /** Scroll container to track instead of the window. Default: window */\n  scroller?: HTMLElement | null\n  /** Height of the section (any CSS length). Default: \"100vh\" */\n  height?: string\n  /** Additional classes for the section. */\n  className?: string\n}\n\nconst EASE_OUT = \"cubic-bezier(0.22,1,0.36,1)\"\nconst MINOR_TICKS = [0, 1 / 6, 2 / 6, 4 / 6, 5 / 6]\n\n/** Year counter: digits roll individually when every year is numeric and the same length. */\nfunction RollingYear({ years, active }: { years: string[]; active: number }) {\n  const digits = years.length > 0 && years.every((y) => /^\\d+$/.test(y) && y.length === years[0].length)\n  const row = 1.1\n\n  if (digits) {\n    return (\n      <span className=\"inline-flex overflow-hidden tabular-nums\" style={{ height: `${row}em`, lineHeight: row }}>\n        {years[active].split(\"\").map((d, k) => (\n          <span\n            key={k}\n            className=\"flex flex-col transition-transform duration-700\"\n            style={{\n              transform: `translateY(-${Number(d) * row}em)`,\n              transitionTimingFunction: EASE_OUT,\n              transitionDelay: `${k * 45}ms`,\n            }}\n          >\n            {Array.from({ length: 10 }, (_, n) => (\n              <span key={n} style={{ height: `${row}em` }}>\n                {n}\n              </span>\n            ))}\n          </span>\n        ))}\n      </span>\n    )\n  }\n\n  return (\n    <span className=\"inline-flex overflow-hidden\" style={{ height: `${row}em`, lineHeight: row }}>\n      <span\n        className=\"flex flex-col items-end transition-transform duration-700\"\n        style={{ transform: `translateY(-${active * row}em)`, transitionTimingFunction: EASE_OUT }}\n      >\n        {years.map((y, i) => (\n          <span key={i} className=\"whitespace-nowrap\" style={{ height: `${row}em` }}>\n            {y}\n          </span>\n        ))}\n      </span>\n    </span>\n  )\n}\n\n/**\n * Minimal horizontal timeline of years and milestones. Pins and scrolls sideways\n * with the page (or drags freely); the year at the center lights up in the accent.\n */\nexport function TimelineScroll({\n  items,\n  mode = \"scroll\",\n  itemWidth = \"clamp(240px, 44cqw, 560px)\",\n  yearSize = \"clamp(3.5rem, min(15cqw, 24cqh), 12rem)\",\n  accent = \"#ff4d12\",\n  showProgress = true,\n  showCounter = true,\n  align = \"center\",\n  scrollLength = 1,\n  snap = false,\n  title = \"Our journey\",\n  scroller,\n  height = \"100vh\",\n  className,\n}: TimelineScrollProps) {\n  const sectionRef = useRef<HTMLElement>(null)\n  const stageRef = useRef<HTMLDivElement>(null)\n  const trackRef = useRef<HTMLDivElement>(null)\n  const fillRef = useRef<HTMLDivElement>(null)\n  const activeRef = useRef(0)\n  const [active, setActive] = useState(0)\n  const [geom, setGeom] = useState({ distance: 0, step: 0 })\n  const reduced = useReducedMotion()\n  const x = useMotionValue(0)\n  const fill = useMotionValue(0)\n  const n = items.length\n  const years = items.map((item) => String(item.year))\n  const drag = mode === \"drag\"\n\n  const commit = (next: number) => {\n    if (next === activeRef.current) return\n    activeRef.current = next\n    setActive(next)\n  }\n\n  // Drag mode: measure travel distance and column width\n  useEffect(() => {\n    const stage = stageRef.current\n    const track = trackRef.current\n    if (!drag || reduced || !stage || !track) return\n    const ro = new ResizeObserver(() => {\n      const distance = Math.max(track.scrollWidth - stage.clientWidth, 0)\n      const step = n > 1 ? distance / (n - 1) : 0\n      setGeom({ distance, step })\n      const clamped = Math.min(0, Math.max(-distance, x.get()))\n      x.set(clamped)\n      fill.set(distance > 0 ? -clamped / distance : 0)\n    })\n    ro.observe(stage)\n    ro.observe(track)\n    return () => ro.disconnect()\n  }, [drag, reduced, n, x, fill])\n\n  useMotionValueEvent(x, \"change\", (v) => {\n    if (!drag) return\n    fill.set(geom.distance > 0 ? Math.min(1, Math.max(0, -v / geom.distance)) : 0)\n    if (geom.step > 0) commit(Math.min(n - 1, Math.max(0, Math.round(-v / geom.step))))\n  })\n\n  // Scroll mode: pin and translate the track with a scrubbed timeline\n  useGSAP(\n    () => {\n      const section = sectionRef.current\n      const stage = stageRef.current\n      const track = trackRef.current\n      if (drag || reduced || !section || !stage || !track || n === 0) return\n\n      const distance = () => Math.max(track.scrollWidth - stage.clientWidth, 0)\n      const tl = gsap.timeline({\n        defaults: { ease: \"none\" },\n        scrollTrigger: {\n          trigger: section,\n          scroller: scroller ?? undefined,\n          start: \"top top\",\n          end: () => `+=${Math.max(distance(), stage.clientHeight) * scrollLength}`,\n          pin: true,\n          scrub: 0.8,\n          invalidateOnRefresh: true,\n          anticipatePin: 1,\n          snap:\n            snap && n > 1\n              ? { snapTo: 1 / (n - 1), duration: { min: 0.2, max: 0.6 }, delay: 0.05, ease: \"power4.inOut\" }\n              : undefined,\n        },\n        onUpdate: () => commit(Math.round(tl.progress() * Math.max(n - 1, 0))),\n      })\n      tl.to(track, { x: () => -distance() }, 0)\n      if (fillRef.current) tl.fromTo(fillRef.current, { scaleX: 0 }, { scaleX: 1 }, 0)\n\n      const refreshTimer = window.setTimeout(() => ScrollTrigger.refresh(), 100)\n      return () => window.clearTimeout(refreshTimer)\n    },\n    {\n      scope: sectionRef,\n      dependencies: [drag, reduced, n, itemWidth, yearSize, scrollLength, snap, scroller, showProgress, align],\n      revertOnUpdate: true,\n    }\n  )\n\n  // A pin adds spacing that moves everything below it. Re-sort and refresh all\n  // triggers after this section (re)builds, so later pinned sections recompute\n  // their start instead of pinning too early and overlapping this one.\n  useEffect(() => {\n    const id = requestAnimationFrame(() => {\n      ScrollTrigger.sort()\n      ScrollTrigger.refresh()\n    })\n    return () => cancelAnimationFrame(id)\n  }, [drag, reduced, n, itemWidth, yearSize, scrollLength, snap, scroller, align])\n\n  const goTo = (index: number) => {\n    if (!geom.step) return\n    const i = Math.min(n - 1, Math.max(0, index))\n    animate(x, -i * geom.step, { duration: 0.8, ease: [0.22, 1, 0.36, 1] })\n  }\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!drag) return\n    if (e.key === \"ArrowRight\") {\n      e.preventDefault()\n      goTo(active + 1)\n    } else if (e.key === \"ArrowLeft\") {\n      e.preventDefault()\n      goTo(active - 1)\n    } else if (e.key === \"Home\") {\n      e.preventDefault()\n      goTo(0)\n    } else if (e.key === \"End\") {\n      e.preventDefault()\n      goTo(n - 1)\n    }\n  }\n\n  if (n === 0) return null\n\n  if (reduced) {\n    return (\n      <section\n        aria-label={title}\n        className={cn(\n          \"w-full bg-background px-[clamp(1.25rem,6cqw,4rem)] py-[clamp(3rem,8cqw,6rem)] text-foreground [container-type:inline-size]\",\n          className\n        )}\n      >\n        {title && <p className=\"mb-10 text-sm font-medium text-muted-foreground\">{title}</p>}\n        <ol className=\"space-y-10 border-l border-border pl-6\">\n          {items.map((item, i) => (\n            <li key={i}>\n              {item.tag && <p className=\"font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground\">{item.tag}</p>}\n              <p\n                className=\"font-[family-name:var(--font-display)] text-[clamp(2.5rem,10cqw,5rem)] font-semibold leading-none tracking-[-0.04em] tabular-nums\"\n                style={{ color: accent }}\n              >\n                {item.year}\n              </p>\n              <p className=\"mt-2 text-base font-medium\">{item.title}</p>\n              {item.description && <p className=\"mt-1 text-sm text-muted-foreground\">{item.description}</p>}\n            </li>\n          ))}\n        </ol>\n      </section>\n    )\n  }\n\n  const trackStyle: CSSProperties = {\n    gridTemplateRows: \"auto auto auto\",\n    gridAutoColumns: itemWidth,\n    paddingInline: `calc(50cqw - (${itemWidth}) / 2)`,\n    rowGap: \"clamp(10px, 2.6cqh, 24px)\",\n  }\n  const fillInset = `calc((${itemWidth}) / 2)`\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label={title}\n      className={cn(\"relative w-full overflow-hidden bg-background text-foreground\", className)}\n      style={{ height }}\n    >\n      <div\n        ref={stageRef}\n        tabIndex={drag ? 0 : undefined}\n        onKeyDown={onKeyDown}\n        aria-roledescription={drag ? \"draggable timeline\" : undefined}\n        className={cn(\n          \"absolute inset-0 flex flex-col outline-none [container-type:size]\",\n          align === \"center\" ? \"justify-center\" : \"justify-start pt-[clamp(72px,16cqh,140px)]\",\n          drag && \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n        )}\n      >\n        {/* Header */}\n        <div className=\"pointer-events-none absolute inset-x-[clamp(20px,5cqw,56px)] top-[clamp(20px,5cqh,48px)] z-10 flex items-start justify-between gap-6\">\n          <div>\n            <p className=\"text-sm font-medium\">{title}</p>\n            <p className=\"mt-1 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground\">\n              {drag ? \"Drag\" : \"Scroll\"} to explore\n            </p>\n          </div>\n          {showCounter && (\n            <div className=\"text-right font-[family-name:var(--font-display)] text-[clamp(1.5rem,4.5cqw,3rem)] font-semibold leading-none tracking-[-0.03em]\">\n              <RollingYear years={years} active={active} />\n              <p className=\"mt-1 font-mono text-[11px] font-normal tracking-[0.16em] text-muted-foreground tabular-nums\">\n                {String(active + 1).padStart(2, \"0\")} / {String(n).padStart(2, \"0\")}\n              </p>\n            </div>\n          )}\n        </div>\n\n        {/* Track */}\n        <motion.div\n          ref={trackRef}\n          drag={drag ? \"x\" : false}\n          dragConstraints={{ left: -geom.distance, right: 0 }}\n          dragElastic={0.08}\n          dragMomentum\n          dragTransition={{\n            power: 0.35,\n            timeConstant: 320,\n            bounceStiffness: 260,\n            bounceDamping: 36,\n            modifyTarget:\n              snap && geom.step > 0\n                ? (target: number) => Math.min(0, Math.max(-geom.distance, Math.round(target / geom.step) * geom.step))\n                : undefined,\n          }}\n          className={cn(\"grid w-max grid-flow-col will-change-transform\", drag && \"cursor-grab select-none active:cursor-grabbing\")}\n          style={drag ? { ...trackStyle, x } : trackStyle}\n        >\n          {/* Baseline + progress */}\n          <div aria-hidden className=\"relative h-6\" style={{ gridRow: 2, gridColumn: `1 / ${n + 1}` }}>\n            <div className=\"absolute inset-x-0 top-1/2 h-px bg-current opacity-15\" />\n            {showProgress && (\n              <motion.div\n                ref={fillRef}\n                className=\"absolute top-1/2 h-px origin-left\"\n                style={{\n                  left: fillInset,\n                  right: fillInset,\n                  backgroundColor: accent,\n                  ...(drag ? { scaleX: fill } : { transform: \"scaleX(0)\" }),\n                }}\n              />\n            )}\n          </div>\n\n          {items.map((item, i) => {\n            const isActive = i === active\n            return (\n              <article\n                key={i}\n                aria-current={isActive ? \"step\" : undefined}\n                className=\"grid px-[clamp(8px,1.5cqw,20px)] text-center\"\n                style={{ gridColumn: i + 1, gridRow: \"1 / span 3\", gridTemplateRows: \"subgrid\" }}\n              >\n                <div className=\"flex flex-col items-center justify-end\">\n                  <p\n                    className={cn(\n                      \"mb-2 h-4 font-mono text-[11px] uppercase tracking-[0.16em] transition-opacity duration-500\",\n                      isActive ? \"opacity-100\" : \"opacity-50\"\n                    )}\n                    style={{ color: isActive ? accent : undefined }}\n                  >\n                    {item.tag ?? \"\"}\n                  </p>\n                  <div\n                    className=\"relative font-[family-name:var(--font-display)] font-semibold leading-[0.85] tracking-[-0.04em] tabular-nums transition-transform duration-700\"\n                    style={{\n                      fontSize: yearSize,\n                      transform: `scale(${isActive ? 1.08 : 1})`,\n                      transformOrigin: \"50% 100%\",\n                      transitionTimingFunction: EASE_OUT,\n                    }}\n                  >\n                    <span\n                      className=\"block text-muted-foreground transition-opacity duration-500\"\n                      style={{ opacity: isActive ? 0 : 0.45 }}\n                    >\n                      {item.year}\n                    </span>\n                    <span\n                      aria-hidden\n                      className=\"absolute inset-0 transition-opacity duration-500\"\n                      style={{ color: accent, opacity: isActive ? 1 : 0 }}\n                    >\n                      {item.year}\n                    </span>\n                  </div>\n                </div>\n\n                <div aria-hidden className=\"relative h-6 [margin-inline:calc(-1*clamp(8px,1.5cqw,20px))]\">\n                  {MINOR_TICKS.map((at) => (\n                    <span\n                      key={at}\n                      className=\"absolute top-1/2 h-1.5 w-px -translate-y-1/2 bg-current opacity-25\"\n                      style={{ left: `${at * 100}%` }}\n                    />\n                  ))}\n                  <span className=\"absolute left-1/2 top-1/2 h-3 w-px -translate-y-1/2 bg-current opacity-50\" />\n                  <span\n                    className=\"absolute left-1/2 top-1/2 -ml-1 -mt-1 size-2 rounded-full transition-transform duration-500\"\n                    style={{ backgroundColor: accent, transform: `scale(${isActive ? 1 : 0})`, transitionTimingFunction: EASE_OUT }}\n                  />\n                </div>\n\n                <div\n                  className={cn(\"mx-auto max-w-[32ch] transition-opacity duration-500\", isActive ? \"opacity-100\" : \"opacity-35\")}\n                >\n                  <h3 className=\"text-[clamp(0.95rem,2.2cqw,1.25rem)] font-medium tracking-tight\">{item.title}</h3>\n                  {item.description && (\n                    <p className=\"mt-1 text-[clamp(0.8rem,1.6cqw,0.95rem)] leading-snug text-muted-foreground\">\n                      {item.description}\n                    </p>\n                  )}\n                </div>\n              </article>\n            )\n          })}\n        </motion.div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/timeline-scroll",
  "type": "registry:component"
}