Runnel
A vertical tracing beam that draws itself along your content as the user scrolls, with a glowing amber-to-violet head riding the tip.
"use client";
import * as React from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, useGSAP);
}
export interface RunnelProps extends React.ComponentPropsWithoutRef<"div"> {
/** Content the beam runs alongside. Its height (via ResizeObserver) drives the SVG's length. */
children: React.ReactNode;
/** Which edge of the container the beam hugs. @default "left" */
side?: "left" | "right";
/** Gradient stops for the stroke, tail to head. @default ["#f59e0b", "#8b5cf6"] (amber -> violet) */
colors?: [string, string];
/** ScrollTrigger `start`, relative to the wrapper entering the scroller's viewport. @default "top 80%" */
start?: string;
/** ScrollTrigger `end`, relative to the wrapper leaving the scroller's viewport. @default "bottom 60%" */
end?: string;
/**
* The element ScrollTrigger measures scroll progress against. Defaults to the window.
* Pass a ref or element to drive the beam from an internal `overflow-y-auto` container instead.
*/
scroller?: string | Element | React.RefObject<HTMLElement | null>;
/** Stroke width in px. @default 3 */
strokeWidth?: number;
/** Width in px reserved for the SVG lane, and the padding applied to push content off the edge. @default 28 */
inset?: number;
}
const DEFAULT_COLORS: [string, string] = ["#f59e0b", "#8b5cf6"];
/** Deterministic, gently wavy vertical path — no randomness, so server/client markup always match. */
function buildPathD(height: number, width: number): string {
if (height <= 0) return "";
const segments = Math.max(3, Math.round(height / 220));
const cx = width / 2;
const amplitude = Math.min(width / 2 - 3, 6);
const points: { x: number; y: number }[] = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
points.push({
x: cx + Math.sin(t * Math.PI * 2.2 + 0.6) * amplitude,
y: t * height,
});
}
let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] ?? points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] ?? p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return d;
}
function resolveScroller(
scroller: RunnelProps["scroller"]
): string | Element | undefined {
if (!scroller) return undefined;
if (typeof scroller === "string") return scroller;
if (scroller instanceof Element) return scroller;
return scroller.current ?? undefined;
}
/**
* Runnel — a vertical tracing beam that draws itself along your content as the
* user scrolls, with a glowing head riding the tip. Wrap any long-form content
* (article, timeline, changelog) and the line measures itself to match.
*
* SSR-safe: the path is deterministic and renders empty until content height is
* measured client-side. Honors prefers-reduced-motion by rendering the line
* fully drawn and static, with no scroll-driven animation.
*/
export function Runnel({
children,
className,
side = "left",
colors = DEFAULT_COLORS,
start = "top 80%",
end = "bottom 60%",
scroller,
strokeWidth = 3,
inset = 28,
style,
...props
}: RunnelProps) {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const pathRef = React.useRef<SVGPathElement>(null);
const headGlowRef = React.useRef<SVGCircleElement>(null);
const headRef = React.useRef<SVGCircleElement>(null);
const [contentHeight, setContentHeight] = React.useState(0);
const prefersReducedMotion = useReducedMotion();
const reactId = React.useId();
const gradientId = `runnel-gradient-${reactId}`;
const glowId = `runnel-glow-${reactId}`;
const pathD = React.useMemo(
() => buildPathD(contentHeight, inset),
[contentHeight, inset]
);
React.useEffect(() => {
const node = contentRef.current;
if (!node) return;
setContentHeight(node.scrollHeight);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
setContentHeight(entry?.contentRect.height ?? node.scrollHeight);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
useGSAP(
() => {
const path = pathRef.current;
if (!path || contentHeight <= 0) return;
const length = path.getTotalLength();
const glow = headGlowRef.current;
const head = headRef.current;
if (prefersReducedMotion) {
gsap.set(path, { strokeDasharray: length, strokeDashoffset: 0 });
const tip = path.getPointAtLength(length);
if (glow) gsap.set(glow, { attr: { cx: tip.x, cy: tip.y }, opacity: 0.9 });
if (head) gsap.set(head, { attr: { cx: tip.x, cy: tip.y }, opacity: 1 });
return;
}
gsap.set(path, { strokeDasharray: length, strokeDashoffset: length });
const startPoint = path.getPointAtLength(0);
if (glow) gsap.set(glow, { attr: { cx: startPoint.x, cy: startPoint.y }, opacity: 0 });
if (head) gsap.set(head, { attr: { cx: startPoint.x, cy: startPoint.y }, opacity: 0 });
const tween = gsap.to(path, {
strokeDashoffset: 0,
ease: "none",
scrollTrigger: {
trigger: wrapperRef.current,
scroller: resolveScroller(scroller),
start,
end,
scrub: true,
invalidateOnRefresh: true,
},
onUpdate() {
const offset = Number(gsap.getProperty(path, "strokeDashoffset"));
const drawn = Math.max(0, Math.min(length, length - offset));
const point = path.getPointAtLength(drawn);
const visible = drawn > 0.5 ? 1 : 0;
if (glow) gsap.set(glow, { attr: { cx: point.x, cy: point.y }, opacity: visible * 0.9 });
if (head) gsap.set(head, { attr: { cx: point.x, cy: point.y }, opacity: visible });
},
});
return () => {
tween.scrollTrigger?.kill();
tween.kill();
};
},
{
scope: wrapperRef,
dependencies: [contentHeight, side, start, end, strokeWidth, inset, prefersReducedMotion, scroller],
revertOnUpdate: true,
}
);
return (
<div
ref={wrapperRef}
data-crucible="runnel"
className={cn("relative", className)}
style={style}
{...props}
>
<svg
aria-hidden
focusable="false"
className={cn("pointer-events-none absolute top-0", side === "right" ? "right-0" : "left-0")}
width={inset}
height={contentHeight}
viewBox={`0 0 ${inset} ${Math.max(contentHeight, 1)}`}
preserveAspectRatio="none"
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={colors[0]} />
<stop offset="100%" stopColor={colors[1]} />
</linearGradient>
<filter id={glowId} x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation={strokeWidth * 1.6} result="blur" />
</filter>
</defs>
<path
ref={pathRef}
d={pathD}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<circle ref={headGlowRef} r={strokeWidth * 2.6} fill={colors[1]} filter={`url(#${glowId})`} opacity={0} />
<circle ref={headRef} r={strokeWidth * 0.9} fill="#fff" opacity={0} />
</svg>
<div
ref={contentRef}
style={{ [side === "right" ? "paddingRight" : "paddingLeft"]: inset }}
>
{children}
</div>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/runnelManual — install dependencies, then copy the source
npm install gsap @gsap/reactHonors prefers-reduced-motion with a designed static fallback, and passes className through.