{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tracing-beam","type":"registry:ui","title":"TracingBeam","description":"A reading lamp on a rail beside long-form content: it trails the reader's eye on a spring, leaves what has been read softly lit, and ticks off each heading as it passes","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["effects"],"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/tracing-beam/TracingBeam.tsx","content":"\"use client\";\n\nimport { 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 \"./tracing-beam.css\";\n\nexport interface TracingBeamProps {\n\t/** Additional CSS classes for the outer wrapper */\n\tclassName?: string;\n\t/** Long-form content the beam reads alongside */\n\tchildren?: ReactNode;\n\t/**\n\t * Colour of the lamp, its trail and the lit markers. Any CSS colour.\n\t * Unset, it follows the theme's `--primary` token and falls back to a\n\t * soft violet when no theme is present. Also settable from CSS via\n\t * `--tracing-beam-accent`.\n\t */\n\taccent?: string;\n\t/**\n\t * Where the reader's eye is assumed to be, as a fraction of the viewport\n\t * height from the top. The lamp sits on that line, clamped to the content.\n\t */\n\treadingLine?: number;\n\t/**\n\t * Draw a marker on the rail for every heading (`h1`–`h4`) inside the\n\t * content — or, when there is none, for every direct child. Markers light\n\t * up as the lamp passes them.\n\t */\n\tmarkers?: boolean;\n}\n\n/** Spring that carries the lamp: stiff enough to feel attached, loose enough to trail. */\nconst TENSION = 120;\nconst FRICTION = 22;\n/** Settle threshold, in px and px/s. */\nconst PRECISION = 0.05;\n/** Longest frame the spring integrates (a tab coming back is not a 2 s step). */\nconst MAX_DT = 1 / 30;\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.min(max, Math.max(min, value));\n}\n\n/**\n * Viewport px per local px. A scaled ancestor shrinks every rect we read but\n * not the offsets we write, so rect deltas are divided by this before use.\n * Sub-pixel rounding of `offsetHeight` is not a transform: it reads as 1.\n */\nfunction localScale(el: HTMLElement, rect: DOMRect): number {\n\tconst layout = el.offsetHeight;\n\tif (layout <= 0 || rect.height <= 0 || Math.abs(rect.height - layout) < 1) return 1;\n\treturn rect.height / layout;\n}\n\n/** Markers the lamp has reached light up; the rest wait. */\nfunction isLit(offset: number, head: number): boolean {\n\treturn offset <= head + 0.5;\n}\n\nfunction sameFlags(a: boolean[], b: boolean[]): boolean {\n\tif (a.length !== b.length) return false;\n\tfor (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;\n\treturn true;\n}\n\n/**\n * TracingBeam - A reading lamp on a rail beside long-form content. The lamp\n * sits on the reader's line of sight and follows the scroll on a spring; what\n * has been read stays softly lit, and a marker per heading lights up as the\n * lamp passes it.\n */\nexport function TracingBeam({\n\tclassName = \"\",\n\tchildren,\n\taccent,\n\treadingLine = 0.38,\n\tmarkers = true,\n}: TracingBeamProps) {\n\tconst reduced = useReducedMotion();\n\n\tconst rootRef = useRef<HTMLDivElement | null>(null);\n\tconst contentRef = useRef<HTMLDivElement | null>(null);\n\n\t/** Content height, so the rail's fade and the lamp's clamp know the end. */\n\tconst [height, setHeight] = useState(0);\n\t/** Marker offsets from the top of the content, in px. */\n\tconst [ticks, setTicks] = useState<number[]>([]);\n\t/** One flag per marker: lit once the lamp has reached it. */\n\tconst [lit, setLit] = useState<boolean[]>([]);\n\tconst [ready, setReady] = useState(false);\n\n\t/**\n\t * Mutable animation state. Where the lamp sits (`head`) is written straight\n\t * to the root as `--tb-progress` from the frame loop instead of through\n\t * state: a re-render per frame would rebuild the gutter to move one custom\n\t * property. State only changes when markup does (a marker lighting up).\n\t */\n\tconst stateRef = useRef({\n\t\thead: 0,\n\t\ttarget: 0,\n\t\tvelocity: 0,\n\t\trafId: 0,\n\t\tlastTime: 0,\n\t\tticks: [] as number[],\n\t\tlit: [] as boolean[],\n\t});\n\n\t/** Re-derives the lit flags from the current lamp position; re-renders only on a change. */\n\tconst syncLit = useCallback(() => {\n\t\tconst s = stateRef.current;\n\t\tconst next = s.ticks.map((offset) => isLit(offset, s.head));\n\t\tif (sameFlags(next, s.lit)) return;\n\t\ts.lit = next;\n\t\tsetLit(next);\n\t}, []);\n\n\t/** Writes the lamp's position to the root and lights the markers it has passed. */\n\tconst applyHead = useCallback(() => {\n\t\trootRef.current?.style.setProperty(\"--tb-progress\", `${stateRef.current.head}px`);\n\t\tsyncLit();\n\t}, [syncLit]);\n\n\t/** Reads where the content sits and sends the lamp to the reading line. */\n\tconst place = useCallback(() => {\n\t\tconst content = contentRef.current;\n\t\tif (!content) return;\n\t\tconst s = stateRef.current;\n\t\tconst rect = content.getBoundingClientRect();\n\t\tconst scale = localScale(content, rect);\n\t\tconst line = window.innerHeight * clamp(readingLine, 0, 1);\n\t\ts.target = clamp((line - rect.top) / scale, 0, Math.max(0, rect.height / scale));\n\t\tsetReady(true);\n\t\tif (reduced) {\n\t\t\tcancelAnimationFrame(s.rafId);\n\t\t\ts.rafId = 0;\n\t\t\ts.velocity = 0;\n\t\t\ts.head = s.target;\n\t\t\tapplyHead();\n\t\t\treturn;\n\t\t}\n\t\tif (!s.rafId) {\n\t\t\t/** One spring step; schedules the next until the lamp has settled on `target`. */\n\t\t\tconst step = (time: number) => {\n\t\t\t\tconst dt = Math.min(MAX_DT, (time - s.lastTime) / 1000 || 0);\n\t\t\t\ts.lastTime = time;\n\t\t\t\tconst force = TENSION * (s.target - s.head) - FRICTION * s.velocity;\n\t\t\t\ts.velocity += force * dt;\n\t\t\t\ts.head += s.velocity * dt;\n\t\t\t\tif (Math.abs(s.velocity) < PRECISION && Math.abs(s.target - s.head) < PRECISION) {\n\t\t\t\t\ts.head = s.target;\n\t\t\t\t\ts.velocity = 0;\n\t\t\t\t\ts.rafId = 0;\n\t\t\t\t\tapplyHead();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tapplyHead();\n\t\t\t\ts.rafId = requestAnimationFrame(step);\n\t\t\t};\n\t\t\ts.lastTime = performance.now();\n\t\t\ts.rafId = requestAnimationFrame(step);\n\t\t}\n\t}, [readingLine, reduced, applyHead]);\n\n\t/** Layout reads that only change on resize: content height and marker offsets. */\n\tconst measure = useCallback(() => {\n\t\tconst content = contentRef.current;\n\t\tif (!content) return;\n\t\tconst s = stateRef.current;\n\t\tconst rect = content.getBoundingClientRect();\n\t\tconst scale = localScale(content, rect);\n\t\tsetHeight(rect.height / scale);\n\t\tif (markers) {\n\t\t\tconst headings = content.querySelectorAll<HTMLElement>(\"h1, h2, h3, h4\");\n\t\t\tconst anchors = headings.length ? [...headings] : [...content.children];\n\t\t\ts.ticks = anchors\n\t\t\t\t.filter((el): el is HTMLElement => el instanceof HTMLElement)\n\t\t\t\t.map((el) => {\n\t\t\t\t\tconst box = el.getBoundingClientRect();\n\t\t\t\t\treturn (box.top + box.height / 2 - rect.top) / scale;\n\t\t\t\t});\n\t\t} else {\n\t\t\ts.ticks = [];\n\t\t}\n\t\tsetTicks(s.ticks);\n\t\tsyncLit();\n\t\tplace();\n\t}, [markers, place, syncLit]);\n\n\t// Layout effect: the first measurement is paint-visible geometry (rail\n\t// height, lamp position, the lit markers). In a passive effect they would\n\t// paint at 0 for a frame and then pop into place. `markers`, `readingLine`\n\t// or the motion preference changing after mount re-runs it, which is the\n\t// re-read of the layout the source schedules for those changes.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst s = stateRef.current;\n\t\tconst onScroll = rafThrottle(place);\n\t\tconst onResize = rafThrottle(measure);\n\n\t\tconst resizeObserver = new ResizeObserver(() => onResize());\n\t\tif (contentRef.current) resizeObserver.observe(contentRef.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\tcancelAnimationFrame(s.rafId);\n\t\t\ts.rafId = 0;\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// `--tb-progress` is owned by the frame loop between renders; a render\n\t// re-states the latest value so React never writes a stale one back over it.\n\tconst rootStyle = {\n\t\t\"--tracing-beam-accent\": accent,\n\t\t\"--tb-progress\": `${stateRef.current.head}px`,\n\t\t\"--tb-height\": `${height}px`,\n\t} as CSSProperties;\n\n\treturn (\n\t\t<div\n\t\t\tref={rootRef}\n\t\t\tclassName={cn(\"tb-root relative mx-auto w-full max-w-4xl\", className)}\n\t\t\tstyle={rootStyle}\n\t\t\tdata-motion={reduced ? \"reduced\" : \"full\"}\n\t\t\tdata-tracing-beam=\"\"\n\t\t>\n\t\t\t{/* Gutter: the rail, its markers and the lamp, all out of the content's way */}\n\t\t\t<div className=\"tb-gutter\" aria-hidden=\"true\">\n\t\t\t\t<div className=\"tb-rail\">\n\t\t\t\t\t<span className=\"tb-hair\"></span>\n\t\t\t\t\t<span className=\"tb-trail\"></span>\n\t\t\t\t</div>\n\t\t\t\t{ticks.map((offset, index) => (\n\t\t\t\t\t<span\n\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\tclassName=\"tb-tick\"\n\t\t\t\t\t\tstyle={{ top: `${offset}px` }}\n\t\t\t\t\t\tdata-tracing-beam-marker=\"\"\n\t\t\t\t\t\tdata-lit={lit[index] ?? false}\n\t\t\t\t\t></span>\n\t\t\t\t))}\n\t\t\t\t<div className=\"tb-head\" data-tracing-beam-head=\"\" data-ready={ready}>\n\t\t\t\t\t<span className=\"tb-spill\"></span>\n\t\t\t\t\t<span className=\"tb-lamp\"></span>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div ref={contentRef} className=\"tb-content\">\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/tracing-beam/TracingBeam.tsx"},{"path":"react/src/components/tracing-beam/index.ts","content":"\"use client\";\n\nexport { TracingBeam } from \"./TracingBeam\";\nexport type { TracingBeamProps } from \"./TracingBeam\";\n","type":"registry:ui","target":"@ui/fancy/components/tracing-beam/index.ts"},{"path":"react/src/components/tracing-beam/tracing-beam.css","content":"/*\n * TracingBeam \"reading lamp\". Mirrors the Svelte <style> block rule for rule.\n * The Svelte compiler scoped every selector; here each one is anchored on the\n * source's own root class `.tb-root` so no generic name leaks into the page.\n */\n.tb-root {\n\t/* Public hooks: --tracing-beam-accent (colour), --tracing-beam-gutter (rail column width).\n\t   --tb-progress and --tb-height are written by the component. */\n\t--_tb-accent: var(--tracing-beam-accent, var(--primary, oklch(0.7 0.13 285)));\n\t--_tb-cool: oklch(0.74 0.09 255);\n\t/* Light/dark tuning as `light-dark()` pairs, so the beam follows the page's\n\t   `color-scheme` the way the library's tokens and `dark:` utilities do.\n\t   Light surfaces: the glow leans on the cool tint so a dark accent never reads as a smudge.\n\t   Dark surfaces: a near-white accent (the default dark --primary) would wash out, so the\n\t   cool tint stays in. */\n\t--_tb-glow: light-dark(\n\t\tcolor-mix(in oklab, var(--_tb-accent) 45%, var(--_tb-cool)),\n\t\tcolor-mix(in oklab, var(--_tb-accent) 40%, var(--_tb-cool))\n\t);\n\t/* The lamp's core: the glow pushed towards white, never pure white. */\n\t--_tb-hot: light-dark(\n\t\tcolor-mix(in oklab, var(--_tb-glow) 45%, white),\n\t\tcolor-mix(in oklab, var(--_tb-glow) 55%, white)\n\t);\n\t--_tb-hair: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));\n\t--_tb-surface: var(--background, #fafafa);\n\t/* The rail already read, and the lamp's spill on the lines being read (core, edge). */\n\t--_tb-read: light-dark(\n\t\tcolor-mix(in oklab, var(--_tb-glow) 26%, transparent),\n\t\tcolor-mix(in oklab, var(--_tb-glow) 38%, transparent)\n\t);\n\t--_tb-spill: light-dark(\n\t\tcolor-mix(in oklab, var(--_tb-glow) 12%, transparent),\n\t\tcolor-mix(in oklab, var(--_tb-glow) 18%, transparent)\n\t);\n\t--_tb-spill-edge: light-dark(\n\t\tcolor-mix(in oklab, var(--_tb-glow) 4%, transparent),\n\t\tcolor-mix(in oklab, var(--_tb-glow) 6%, transparent)\n\t);\n\t--_tb-gutter: var(--tracing-beam-gutter, 2.5rem);\n\t/* The rail's x inside the gutter. */\n\t--_tb-x: 9px;\n\t--_tb-ease: cubic-bezier(0.16, 1, 0.3, 1);\n\tdisplay: grid;\n\tgrid-template-columns: var(--_tb-gutter) minmax(0, 1fr);\n}\n\n.dark .tb-root {\n\t/* Fallback for a `.dark` page that never sets `color-scheme`: the dark branch, forced. */\n\t--_tb-glow: color-mix(in oklab, var(--_tb-accent) 40%, var(--_tb-cool));\n\t--_tb-hot: color-mix(in oklab, var(--_tb-glow) 55%, white);\n\t--_tb-hair: rgba(255, 255, 255, 0.1);\n\t--_tb-read: color-mix(in oklab, var(--_tb-glow) 38%, transparent);\n\t--_tb-spill: color-mix(in oklab, var(--_tb-glow) 18%, transparent);\n\t--_tb-spill-edge: color-mix(in oklab, var(--_tb-glow) 6%, transparent);\n}\n\n.tb-root .tb-gutter {\n\tposition: relative;\n\tpointer-events: none;\n}\n\n.tb-root .tb-content {\n\tmin-width: 0;\n}\n\n/* ── Rail ─────────────────────────────────────────────── */\n.tb-root .tb-rail {\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: calc(var(--_tb-x) - 1px);\n\twidth: 3px;\n\t-webkit-mask-image: linear-gradient(\n\t\tto bottom,\n\t\ttransparent,\n\t\t#000 24px,\n\t\t#000 calc(100% - 24px),\n\t\ttransparent\n\t);\n\tmask-image: linear-gradient(\n\t\tto bottom,\n\t\ttransparent,\n\t\t#000 24px,\n\t\t#000 calc(100% - 24px),\n\t\ttransparent\n\t);\n}\n\n.tb-root .tb-hair,\n.tb-root .tb-trail {\n\tposition: absolute;\n\tinset: 0 auto 0 1px;\n\twidth: 1px;\n}\n\n.tb-root .tb-hair {\n\tbackground: var(--_tb-hair);\n}\n\n/* What has been read stays softly lit; it brightens into the lamp; nothing past it. */\n.tb-root .tb-trail {\n\tbackground: linear-gradient(\n\t\tto bottom,\n\t\tvar(--_tb-read),\n\t\tvar(--_tb-read) calc(var(--tb-progress) - 180px),\n\t\tcolor-mix(in oklab, var(--_tb-glow) 90%, transparent) var(--tb-progress),\n\t\ttransparent var(--tb-progress)\n\t);\n}\n\n/* ── Markers: one tick per heading, lit once the lamp has passed ── */\n.tb-root .tb-tick {\n\tposition: absolute;\n\tleft: calc(var(--_tb-x) - 1px);\n\theight: 1px;\n\twidth: 12px;\n\tmargin-top: -0.5px;\n\tbackground: var(--_tb-hair);\n\ttransform-origin: left center;\n\ttransform: scaleX(0.5);\n\ttransition:\n\t\tbackground-color 0.5s var(--_tb-ease),\n\t\ttransform 0.5s var(--_tb-ease),\n\t\tbox-shadow 0.5s var(--_tb-ease);\n}\n\n.tb-root .tb-tick::before {\n\t/* The notch on the rail itself: a 5px dot the tick grows from. */\n\tcontent: \"\";\n\tposition: absolute;\n\tleft: -2px;\n\ttop: -2px;\n\twidth: 5px;\n\theight: 5px;\n\tborder-radius: 999px;\n\tbackground: var(--_tb-surface);\n\tbox-shadow: 0 0 0 1px var(--_tb-hair);\n\ttransition: inherit;\n}\n\n.tb-root .tb-tick[data-lit=\"true\"] {\n\tbackground: color-mix(in oklab, var(--_tb-glow) 80%, transparent);\n\ttransform: scaleX(1);\n\tbox-shadow: 0 0 6px color-mix(in oklab, var(--_tb-glow) 60%, transparent);\n}\n\n.tb-root .tb-tick[data-lit=\"true\"]::before {\n\tbackground: var(--_tb-hot);\n\tbox-shadow:\n\t\t0 0 0 1px color-mix(in oklab, var(--_tb-glow) 70%, transparent),\n\t\t0 0 8px color-mix(in oklab, var(--_tb-glow) 60%, transparent);\n}\n\n/* ── Lamp: a short hot segment on the rail, and its light spilling onto the lines being read ── */\n.tb-root .tb-head {\n\tposition: absolute;\n\ttop: 0;\n\tleft: var(--_tb-x);\n\twidth: 0;\n\theight: 0;\n\ttransform: translate3d(0, var(--tb-progress), 0);\n\topacity: 0;\n\ttransition: opacity 0.3s ease;\n}\n\n.tb-root .tb-head[data-ready=\"true\"] {\n\topacity: 1;\n}\n\n.tb-root .tb-lamp {\n\tposition: absolute;\n\tleft: -1.5px;\n\ttop: -14px;\n\twidth: 3px;\n\theight: 28px;\n\tborder-radius: 999px;\n\tbackground: linear-gradient(\n\t\tto bottom,\n\t\tcolor-mix(in oklab, var(--_tb-glow) 60%, transparent),\n\t\tvar(--_tb-hot) 35%,\n\t\tvar(--_tb-hot) 65%,\n\t\tcolor-mix(in oklab, var(--_tb-glow) 60%, transparent)\n\t);\n\tbox-shadow:\n\t\t0 0 5px color-mix(in oklab, var(--_tb-glow) 90%, transparent),\n\t\t0 0 18px 3px color-mix(in oklab, var(--_tb-glow) 60%, transparent);\n}\n\n.tb-root .tb-spill {\n\tposition: absolute;\n\tleft: 0;\n\ttop: -56px;\n\twidth: 220px;\n\theight: 112px;\n\tborder-radius: 999px;\n\tbackground: radial-gradient(\n\t\tfarthest-side at 0 50%,\n\t\tvar(--_tb-spill),\n\t\tvar(--_tb-spill-edge) 45%,\n\t\ttransparent\n\t);\n\tfilter: blur(8px);\n}\n\n/* ── Reduced motion: the lamp still reads, it just does not trail or fade ── */\n.tb-root[data-motion=\"reduced\"] .tb-tick,\n.tb-root[data-motion=\"reduced\"] .tb-tick::before,\n.tb-root[data-motion=\"reduced\"] .tb-head {\n\ttransition: none;\n}\n\n.tb-root[data-motion=\"reduced\"] .tb-spill {\n\tdisplay: none;\n}\n","type":"registry:ui","target":"@ui/fancy/components/tracing-beam/tracing-beam.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/tracing-beam?framework=react"}}