Runnel
A vertical tracing beam that draws itself along your content as the user scrolls, its white-to-blue-violet stroke tipped with a glowing white head.
"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 ["#ffffff", "#818cf8"] (white -> blue-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 2 */
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] = ["#ffffff", "#818cf8"];
/** 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: a glowing white-to-blue-violet stroke tipped with a bright
* haloed head bead that flares while you actively scroll and settles as you
* stop, easing into place with a smooth catch-up. 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 = 2,
inset = 28,
style,
...props
}: RunnelProps) {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const pathRef = React.useRef<SVGPathElement>(null);
const glowPathRef = React.useRef<SVGPathElement>(null);
const headHaloRef = React.useRef<SVGCircleElement>(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 lineGlowId = `runnel-line-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;
const glowPath = glowPathRef.current;
if (!path || contentHeight <= 0) return;
const length = path.getTotalLength();
const strokes = glowPath ? [path, glowPath] : [path];
const halo = headHaloRef.current;
const glow = headGlowRef.current;
const head = headRef.current;
if (prefersReducedMotion) {
gsap.set(strokes, { strokeDasharray: length, strokeDashoffset: 0 });
const tip = path.getPointAtLength(length);
if (halo) gsap.set(halo, { attr: { cx: tip.x, cy: tip.y }, opacity: 0.25 });
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(strokes, { strokeDasharray: length, strokeDashoffset: length });
const startPoint = path.getPointAtLength(0);
// Head painter: position + intensity in one place. `boost` rises
// instantly with scroll velocity and decays smoothly once scrolling
// stops, so the head visibly flares while the user is in motion.
let headX = startPoint.x;
let headY = startPoint.y;
let headVisible = 0;
const boost = { value: 0 };
const baseGlowR = strokeWidth * 2.8;
const baseHaloR = strokeWidth * 5.5;
const paintHead = () => {
if (halo)
gsap.set(halo, {
attr: { cx: headX, cy: headY, r: baseHaloR * (1 + boost.value * 1.1) },
opacity: headVisible * (0.18 + boost.value * 0.55),
});
if (glow)
gsap.set(glow, {
attr: { cx: headX, cy: headY, r: baseGlowR * (1 + boost.value * 0.8) },
opacity: headVisible * (0.6 + boost.value * 0.4),
});
if (head) gsap.set(head, { attr: { cx: headX, cy: headY }, opacity: headVisible });
};
paintHead();
const tween = gsap.to(strokes, {
strokeDashoffset: 0,
ease: "none",
scrollTrigger: {
trigger: wrapperRef.current,
scroller: resolveScroller(scroller),
start,
end,
// Numeric scrub = smooth eased catch-up instead of a hard 1:1 lock.
scrub: 0.75,
invalidateOnRefresh: true,
onUpdate(self) {
const velocity = Math.min(Math.abs(self.getVelocity()) / 1200, 1);
if (velocity > boost.value) boost.value = velocity;
gsap.to(boost, {
value: 0,
duration: 0.8,
ease: "power2.out",
overwrite: true,
onUpdate: paintHead,
});
paintHead();
},
},
onUpdate() {
const offset = Number(gsap.getProperty(path, "strokeDashoffset"));
const drawn = Math.max(0, Math.min(length, length - offset));
const point = path.getPointAtLength(drawn);
headX = point.x;
headY = point.y;
headVisible = drawn > 0.5 ? 1 : 0;
paintHead();
},
});
return () => {
gsap.killTweensOf(boost);
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 overflow-visible",
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="-300%" y="-300%" width="700%" height="700%">
<feGaussianBlur stdDeviation={strokeWidth * 2.2} result="blur" />
</filter>
<filter id={lineGlowId} x="-400%" y="-5%" width="900%" height="110%">
<feGaussianBlur stdDeviation={strokeWidth * 1.5} result="blur" />
</filter>
</defs>
{/* Soft glow under-stroke — a blurred, wider copy of the traced line. */}
<path
ref={glowPathRef}
d={pathD}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth * 2.6}
strokeOpacity={0.55}
strokeLinecap="round"
filter={`url(#${lineGlowId})`}
/>
<path
ref={pathRef}
d={pathD}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Head bead: wide halo (flares with scroll velocity) + glow + white core. */}
<circle ref={headHaloRef} cx={0} cy={0} r={strokeWidth * 5.5} fill={colors[1]} filter={`url(#${glowId})`} opacity={0} />
<circle ref={headGlowRef} cx={0} cy={0} r={strokeWidth * 2.8} fill={colors[1]} filter={`url(#${glowId})`} opacity={0} />
<circle ref={headRef} cx={0} cy={0} r={strokeWidth * 1.2} 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/reactProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | Content the beam runs alongside. Its height (via ResizeObserver) drives the SVG's length. | |
| side | "left" | "right" | "left" | Which edge of the container the beam hugs. |
| colors | [string, string] | ["#ffffff", "#818cf8"] (white -> blue-violet) | Gradient stops for the stroke, tail to head. |
| start | string | "top 80%" | ScrollTrigger start, relative to the wrapper entering the scroller's viewport. |
| end | string | "bottom 60%" | ScrollTrigger end, relative to the wrapper leaving the scroller's viewport. |
| scroller | string | Element | React.RefObject<HTMLElement | null> | the window. Pass a ref or element to drive the beam from an internal overflow-y-auto container instead | The element ScrollTrigger measures scroll progress against. |
| strokeWidth | number | 2 | Stroke width in px. |
| inset | number | 28 | Width in px reserved for the SVG lane, and the padding applied to push content off the edge. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.