{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"timeline","type":"registry:ui","title":"Timeline","description":"Vertical timeline drawn as a light rail: a glowing head travels a hairline track as you scroll, leaving a soft trail and igniting each entry's dot as it arrives.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["navigation"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/timeline/Timeline.tsx","content":"\"use client\";\n\nimport { memo, useCallback, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { rafThrottle } from \"../../internals/motion/raf\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport \"./timeline.css\";\n\nexport interface TimelineItem {\n\tid: string;\n\tlabel: string;\n}\n\nexport interface TimelineProps {\n\t/** Timeline entries */\n\titems?: TimelineItem[];\n\t/** Heading text */\n\ttitle?: string;\n\t/** Subheading text */\n\tdescription?: string;\n\t/** Additional CSS classes for the outer wrapper */\n\tclassName?: string;\n\t/** Content render prop, called for each item */\n\tcontent?: (item: TimelineItem) => ReactNode;\n\t/**\n\t * Colour of the travelling head, its trail and the lit dots. Any CSS\n\t * colour. Unset, it follows the theme's `--primary` token and falls back\n\t * to a soft violet when no theme is present. Also settable from CSS via\n\t * `--timeline-accent`.\n\t */\n\taccent?: string;\n}\n\n/** Half of the 40px dot box: the dot's centre sits this far below its row's padding edge. */\nconst DOT_HALF = 20;\n/** The head stops this far above the bottom of the track, inside the rail's fade-out. */\nconst END_PAD = 56;\n/** Sticky offset of the label column (`top-40`), used until the real value is measured. */\nconst STICKY_FALLBACK = 160;\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\n/**\n * One row's content, memoised on (render prop, item): a dot lighting up\n * re-renders the rows to flip `data-state` / `data-lit`, and that must not\n * re-invoke the consumer's `content` render prop for the whole list.\n */\nconst TimelineContent = memo(function TimelineContent({\n\tcontent,\n\titem,\n}: {\n\tcontent?: (item: TimelineItem) => ReactNode;\n\titem: TimelineItem;\n}) {\n\treturn <>{content ? content(item) : null}</>;\n});\n\nexport function Timeline({\n\titems = [],\n\ttitle,\n\tdescription,\n\tclassName,\n\tcontent,\n\taccent,\n}: TimelineProps) {\n\tconst reduced = useReducedMotion();\n\n\tconst rootRef = useRef<HTMLDivElement | null>(null);\n\tconst timelineRef = useRef<HTMLDivElement | null>(null);\n\tconst rowEls = useRef<(HTMLDivElement | null)[]>([]);\n\n\t/** Each dot's resting offset from the top of the track. */\n\tconst dotOffsets = useRef<number[]>([]);\n\t/** Viewport y of the reading line: where a docked (sticky) dot's centre sits. */\n\tconst readingLine = useRef(STICKY_FALLBACK + DOT_HALF);\n\t/**\n\t * Where the head sits, in px from the top of the track. Written straight to\n\t * the root as `--tl-progress` instead of through state: scroll fires once\n\t * per frame, and a re-render per frame would rebuild every row to move one\n\t * custom property. State only changes when a dot is crossed.\n\t */\n\tconst head = useRef(0);\n\tconst heightRef = useRef(0);\n\n\tconst [timelineHeight, setTimelineHeight] = useState(0);\n\tconst [ready, setReady] = useState(false);\n\t/** Index of the row whose dot the head has most recently reached, or -1. */\n\tconst [activeIndex, setActiveIndex] = useState(-1);\n\n\t/** Reads the track's position and places the head on the reading line —\n\t * the line a sticky dot docks on. */\n\tconst place = useCallback(() => {\n\t\tconst track = timelineRef.current;\n\t\tif (!track) return;\n\t\tconst rect = track.getBoundingClientRect();\n\t\tconst offsets = dotOffsets.current;\n\t\tconst start = offsets[0] ?? 0;\n\t\tconst end = Math.max(start, heightRef.current - END_PAD);\n\t\tconst raw = readingLine.current - rect.top;\n\t\tconst next = Math.min(end, Math.max(start, raw));\n\t\thead.current = next;\n\t\trootRef.current?.style.setProperty(\"--tl-progress\", `${next}px`);\n\n\t\tlet idx = -1;\n\t\tfor (let i = 0; i < offsets.length; i++) {\n\t\t\tif ((offsets[i] as number) <= next + 0.5) idx = i;\n\t\t}\n\t\tsetActiveIndex(idx);\n\t\tsetReady(true);\n\t}, []);\n\n\t/** Layout reads that only change on resize: track height, each dot's\n\t * resting offset (its unstuck position), and the sticky offset. */\n\tconst measure = useCallback(() => {\n\t\tconst track = timelineRef.current;\n\t\tif (!track) return;\n\t\theightRef.current = track.offsetHeight;\n\t\tsetTimelineHeight(heightRef.current);\n\t\tconst next: number[] = [];\n\t\tlet firstDotCentre = DOT_HALF;\n\t\tfor (const row of rowEls.current) {\n\t\t\tif (!row) continue;\n\t\t\tconst padTop = parseFloat(getComputedStyle(row).paddingTop) || 0;\n\t\t\t// The dot box is positioned inside the sticky label (its offsetParent),\n\t\t\t// which rests at the row's padding edge — so this is the unstuck centre.\n\t\t\tconst box = row.querySelector<HTMLElement>(\"[data-timeline-dot-box]\");\n\t\t\tconst centre = box && box.offsetHeight ? box.offsetTop + box.offsetHeight / 2 : DOT_HALF;\n\t\t\tif (next.length === 0) firstDotCentre = centre;\n\t\t\tnext.push(row.offsetTop + padTop + centre);\n\t\t}\n\t\tdotOffsets.current = next;\n\t\tconst label = track.querySelector<HTMLElement>(\"[data-timeline-label]\");\n\t\tconst top = label ? parseFloat(getComputedStyle(label).top) : NaN;\n\t\treadingLine.current = (Number.isFinite(top) ? top : STICKY_FALLBACK) + firstDotCentre;\n\t\tplace();\n\t}, [place]);\n\n\t// Layout effect: the first measurement is paint-visible geometry (rail\n\t// height, head position, the lit first dot). In a passive effect they would\n\t// paint at 0 for a frame and then pop into place.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst onScroll = rafThrottle(place);\n\t\tconst onResize = rafThrottle(measure);\n\n\t\tconst resizeObserver = new ResizeObserver(() => onResize());\n\t\tif (timelineRef.current) resizeObserver.observe(timelineRef.current);\n\t\twindow.addEventListener(\"scroll\", onScroll, { passive: true });\n\t\twindow.addEventListener(\"resize\", onResize, { passive: true });\n\t\tmeasure();\n\n\t\treturn () => {\n\t\t\tonScroll.cancel();\n\t\t\tonResize.cancel();\n\t\t\tresizeObserver.disconnect();\n\t\t\twindow.removeEventListener(\"scroll\", onScroll);\n\t\t\twindow.removeEventListener(\"resize\", onResize);\n\t\t};\n\t}, [place, measure]);\n\n\t// Items added or removed after mount: re-measure once the new rows exist.\n\tconst itemCount = items.length;\n\tconst measuredCount = useRef(itemCount);\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (measuredCount.current === itemCount) return;\n\t\tmeasuredCount.current = itemCount;\n\t\tmeasure();\n\t}, [itemCount, measure]);\n\n\tconst rowState = (index: number): \"past\" | \"active\" | \"upcoming\" => {\n\t\tif (index === activeIndex) return \"active\";\n\t\treturn index < activeIndex ? \"past\" : \"upcoming\";\n\t};\n\n\t// `--tl-progress` is owned by `place()` between renders; a render re-states\n\t// the latest value so React never writes a stale one back over it.\n\tconst rootStyle = {\n\t\t\"--timeline-accent\": accent,\n\t\t\"--tl-progress\": `${head.current}px`,\n\t} as CSSProperties;\n\n\treturn (\n\t\t<div\n\t\t\tref={rootRef}\n\t\t\tclassName={cn(\"tl-root w-full font-sans md:px-10\", className)}\n\t\t\tstyle={rootStyle}\n\t\t\tdata-motion={reduced ? \"reduced\" : \"full\"}\n\t\t>\n\t\t\t{(title || description) && (\n\t\t\t\t<div className=\"mx-auto max-w-7xl px-4 py-12 md:px-8 md:py-16 lg:px-10\">\n\t\t\t\t\t{title && (\n\t\t\t\t\t\t<h2 className=\"text-foreground mb-3 max-w-4xl text-lg font-medium tracking-tight text-balance md:text-4xl\">\n\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t</h2>\n\t\t\t\t\t)}\n\t\t\t\t\t{description && (\n\t\t\t\t\t\t<p className=\"text-muted-foreground max-w-sm text-sm md:text-base\">{description}</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<div\n\t\t\t\tref={timelineRef}\n\t\t\t\tclassName=\"relative z-0 mx-auto max-w-7xl pb-20\"\n\t\t\t\tdata-timeline-track=\"\"\n\t\t\t>\n\t\t\t\t{items.map((item, index) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={`${item.id}-${index}`}\n\t\t\t\t\t\tref={(el) => {\n\t\t\t\t\t\t\trowEls.current[index] = el;\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"tl-row flex justify-start pt-10 md:gap-10 md:pt-32\"\n\t\t\t\t\t\tdata-timeline-row=\"\"\n\t\t\t\t\t\tdata-state={rowState(index)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* Sticky label */}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName=\"tl-label sticky top-40 z-40 flex max-w-xs flex-col items-center self-start md:w-full md:flex-row lg:max-w-sm\"\n\t\t\t\t\t\t\tdata-timeline-label=\"\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName=\"absolute left-3 flex size-10 items-center justify-center md:left-3\"\n\t\t\t\t\t\t\t\tdata-timeline-dot-box=\"\"\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span className=\"tl-dot\" data-timeline-dot=\"\" data-lit={index <= activeIndex}>\n\t\t\t\t\t\t\t\t\t<span className=\"tl-dot-flare\"></span>\n\t\t\t\t\t\t\t\t\t<span className=\"tl-dot-core\"></span>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className=\"tl-label-text relative hidden md:block md:pl-20\">\n\t\t\t\t\t\t\t\t<span className=\"tl-index\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t{pad(index + 1)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<h3 className=\"tl-heading text-xl font-semibold tracking-tight md:text-4xl\">\n\t\t\t\t\t\t\t\t\t{item.label}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Item content */}\n\t\t\t\t\t\t<div className=\"tl-content w-full pr-4 pl-20 md:pl-4\">\n\t\t\t\t\t\t\t<TimelineContent content={content} item={item} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\n\t\t\t\t{/* Rail: hairline + the lit trail behind the head, faded at both ends */}\n\t\t\t\t<div className=\"tl-rail\" style={{ height: `${timelineHeight}px` }} aria-hidden=\"true\">\n\t\t\t\t\t<div className=\"tl-rail-line\"></div>\n\t\t\t\t\t<div className=\"tl-trail\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Travelling head (full motion only) */}\n\t\t\t\t{!reduced && items.length > 0 && (\n\t\t\t\t\t<div className=\"tl-head\" data-timeline-head=\"\" data-ready={ready} aria-hidden=\"true\">\n\t\t\t\t\t\t<span className=\"tl-head-streak\"></span>\n\t\t\t\t\t\t<span className=\"tl-head-halo\"></span>\n\t\t\t\t\t\t<span className=\"tl-head-core\"></span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/timeline/Timeline.tsx"},{"path":"react/src/components/timeline/index.ts","content":"\"use client\";\n\nexport { Timeline } from \"./Timeline\";\nexport type { TimelineProps, TimelineItem } from \"./Timeline\";\n","type":"registry:ui","target":"@ui/fancy/components/timeline/index.ts"},{"path":"react/src/components/timeline/timeline.css","content":"/*\n * Timeline \"light rail\". Mirrors the Svelte <style> block rule for rule. The\n * Svelte compiler scoped every selector; here each one is anchored on the\n * source's own root class `.tl-root` so no generic name leaks into the page.\n */\n.tl-root {\n\t/* Public hooks: --timeline-accent (colour). --tl-progress is written by the component. */\n\t--_tl-accent: var(--timeline-accent, var(--primary, oklch(0.7 0.13 285)));\n\t--_tl-cool: oklch(0.72 0.1 262);\n\t/* Light surfaces: the glow leans on the cool tint so a dark accent never reads as a smudge. */\n\t--_tl-glow: color-mix(in oklab, var(--_tl-accent) 40%, var(--_tl-cool));\n\t--_tl-hot: color-mix(in oklab, var(--_tl-accent) 45%, white);\n\t--_tl-core: var(--_tl-accent);\n\t--_tl-hair: rgba(0, 0, 0, 0.12);\n\t--_tl-ring: rgba(0, 0, 0, 0.2);\n\t--_tl-surface: var(--background, #fafafa);\n\t--_tl-muted: rgba(0, 0, 0, 0.42);\n\t--_tl-glow-a: 38%;\n\t--_tl-ease: cubic-bezier(0.16, 1, 0.3, 1);\n\t--_tl-x: 2rem;\n}\n\n.dark .tl-root {\n\t--_tl-glow: color-mix(in oklab, var(--_tl-accent) 72%, var(--_tl-cool));\n\t--_tl-core: var(--_tl-hot);\n\t--_tl-hair: rgba(255, 255, 255, 0.12);\n\t--_tl-ring: rgba(255, 255, 255, 0.2);\n\t--_tl-muted: rgba(255, 255, 255, 0.38);\n\t--_tl-glow-a: 60%;\n}\n\n/* ── Rail ─────────────────────────────────────────────── */\n.tl-root .tl-rail {\n\tposition: absolute;\n\ttop: 0;\n\tleft: calc(var(--_tl-x) - 1px);\n\twidth: 3px;\n\tpointer-events: none;\n\t-webkit-mask-image: linear-gradient(\n\t\tto bottom,\n\t\ttransparent 0,\n\t\t#000 7%,\n\t\t#000 93%,\n\t\ttransparent 100%\n\t);\n\tmask-image: linear-gradient(to bottom, transparent 0, #000 7%, #000 93%, transparent 100%);\n}\n\n.tl-root .tl-rail-line,\n.tl-root .tl-trail {\n\tposition: absolute;\n\tinset: 0 auto 0 1px;\n\twidth: 1px;\n}\n\n.tl-root .tl-rail-line {\n\tbackground: var(--_tl-hair);\n}\n\n/* The filled part: faint far behind, brightening into the head, nothing past it. */\n.tl-root .tl-trail {\n\tbackground: linear-gradient(\n\t\tto bottom,\n\t\ttransparent 0,\n\t\tcolor-mix(in oklab, var(--_tl-accent) 22%, transparent) calc(var(--tl-progress) - 320px),\n\t\tcolor-mix(in oklab, var(--_tl-glow) 85%, transparent) var(--tl-progress),\n\t\ttransparent var(--tl-progress)\n\t);\n}\n\n/* ── Head ─────────────────────────────────────────────── */\n.tl-root .tl-head {\n\tposition: absolute;\n\ttop: 0;\n\tleft: calc(var(--_tl-x) + 0.5px);\n\tz-index: 50;\n\twidth: 0;\n\theight: 0;\n\tpointer-events: none;\n\ttransform: translate3d(0, var(--tl-progress), 0);\n\topacity: 0;\n}\n\n.tl-root .tl-head[data-ready=\"true\"] {\n\topacity: 1;\n}\n\n.tl-root .tl-head > span {\n\tposition: absolute;\n\tdisplay: block;\n\tborder-radius: 999px;\n}\n\n.tl-root .tl-head-streak {\n\tleft: -1px;\n\ttop: -96px;\n\twidth: 2px;\n\theight: 96px;\n\tbackground: linear-gradient(\n\t\tto top,\n\t\tvar(--_tl-hot),\n\t\tcolor-mix(in oklab, var(--_tl-glow) 55%, transparent) 35%,\n\t\ttransparent\n\t);\n\tfilter: blur(0.6px);\n}\n\n.tl-root .tl-head-halo {\n\tleft: -28px;\n\ttop: -28px;\n\twidth: 56px;\n\theight: 56px;\n\tbackground: radial-gradient(\n\t\tclosest-side,\n\t\tcolor-mix(in oklab, var(--_tl-glow) var(--_tl-glow-a), transparent),\n\t\tcolor-mix(in oklab, var(--_tl-glow) 14%, transparent) 55%,\n\t\ttransparent\n\t);\n\tfilter: blur(6px);\n}\n\n.tl-root .tl-head-core {\n\tleft: -3.5px;\n\ttop: -3.5px;\n\twidth: 7px;\n\theight: 7px;\n\tbackground: var(--_tl-core);\n\tbox-shadow:\n\t\t0 0 0 1.5px color-mix(in oklab, var(--_tl-hot) 45%, transparent),\n\t\t0 0 10px 1px color-mix(in oklab, var(--_tl-glow) 70%, transparent);\n}\n\n/* ── Dots ─────────────────────────────────────────────── */\n.tl-root .tl-dot {\n\tposition: relative;\n\tdisplay: grid;\n\tplace-items: center;\n\twidth: 15px;\n\theight: 15px;\n\tborder-radius: 999px;\n\tborder: 1px solid var(--_tl-ring);\n\tbackground: var(--_tl-surface);\n\ttransform: scale(0.8);\n}\n\n.tl-root .tl-dot-core {\n\twidth: 7px;\n\theight: 7px;\n\tborder-radius: 999px;\n\tbackground: var(--_tl-core);\n\topacity: 0;\n\ttransform: scale(0.2);\n}\n\n.tl-root .tl-dot-flare {\n\tposition: absolute;\n\tinset: -1px;\n\tborder-radius: 999px;\n\tborder: 1px solid color-mix(in oklab, var(--_tl-glow) 70%, transparent);\n\topacity: 0;\n\tpointer-events: none;\n}\n\n.tl-root .tl-dot[data-lit=\"true\"] {\n\ttransform: scale(1);\n\tborder-color: color-mix(in oklab, var(--_tl-accent) 55%, var(--_tl-ring));\n\tbox-shadow: 0 0 12px color-mix(in oklab, var(--_tl-glow) 40%, transparent);\n}\n\n.tl-root .tl-dot[data-lit=\"true\"] .tl-dot-core {\n\topacity: 1;\n\ttransform: scale(1);\n}\n\n/* ── Labels ───────────────────────────────────────────── */\n/* Sits above the label out of flow, so the dot centres on the label itself. */\n.tl-root .tl-index {\n\tposition: absolute;\n\tbottom: calc(100% + 0.2rem);\n\tdisplay: block;\n\tfont-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n\tfont-size: 0.6875rem;\n\tletter-spacing: 0.12em;\n\tcolor: var(--_tl-muted);\n\ttransition: color 260ms ease;\n}\n\n.tl-root .tl-heading {\n\tcolor: var(--_tl-muted);\n\ttransition: color 260ms ease;\n}\n\n.tl-root .tl-row[data-state=\"active\"] .tl-heading {\n\tcolor: color-mix(in oklab, var(--_tl-accent) 28%, var(--foreground, #0a0a0a));\n}\n\n.tl-root .tl-row[data-state=\"active\"] .tl-index {\n\tcolor: color-mix(in oklab, var(--_tl-glow) 70%, var(--foreground, #0a0a0a));\n}\n\n.tl-root .tl-row[data-state=\"past\"] .tl-label-text,\n.tl-root .tl-row[data-state=\"past\"] .tl-content {\n\topacity: 0.5;\n}\n\n/* ── Motion ───────────────────────────────────────────── */\n@media (prefers-reduced-motion: no-preference) {\n\t.tl-root[data-motion=\"full\"] .tl-head {\n\t\ttransition: opacity 500ms var(--_tl-ease);\n\t}\n\n\t.tl-root[data-motion=\"full\"] .tl-dot {\n\t\ttransition:\n\t\t\ttransform 260ms var(--_tl-ease),\n\t\t\tborder-color 260ms ease,\n\t\t\tbox-shadow 260ms ease;\n\t}\n\n\t.tl-root[data-motion=\"full\"] .tl-dot-core {\n\t\ttransition:\n\t\t\ttransform 260ms var(--_tl-ease),\n\t\t\topacity 200ms ease;\n\t}\n\n\t.tl-root[data-motion=\"full\"] .tl-dot[data-lit=\"true\"] .tl-dot-flare {\n\t\tanimation: tl-ignite 700ms var(--_tl-ease) both;\n\t}\n\n\t.tl-root[data-motion=\"full\"] .tl-label-text,\n\t.tl-root[data-motion=\"full\"] .tl-content {\n\t\ttransition: opacity 300ms var(--_tl-ease);\n\t}\n}\n\n@keyframes tl-ignite {\n\tfrom {\n\t\topacity: 0.9;\n\t\ttransform: scale(1);\n\t}\n\tto {\n\t\topacity: 0;\n\t\ttransform: scale(2.6);\n\t}\n}\n\n/* ── Reduced motion: a plain filled rail, no glow, dots lit by position ── */\n.tl-root[data-motion=\"reduced\"] .tl-trail {\n\tbackground: linear-gradient(\n\t\tto bottom,\n\t\tcolor-mix(in oklab, var(--_tl-accent) 70%, transparent) var(--tl-progress),\n\t\ttransparent var(--tl-progress)\n\t);\n}\n\n.tl-root[data-motion=\"reduced\"] .tl-dot[data-lit=\"true\"] {\n\tbox-shadow: none;\n}\n","type":"registry:ui","target":"@ui/fancy/components/timeline/timeline.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/timeline?framework=react"}}