Conduit
An animated beam connecting two DOM nodes via refs — a dark groove path carrying a slug of molten metal that pours into the destination and flashes a rim-glow on receipt. Curved, reversible, resize-aware.
motionfree
"use client";
import { motion, type MotionStyle } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface ConduitProps extends Omit<React.ComponentPropsWithoutRef<"svg">, "children"> {
/** The positioned ancestor both `fromRef` and `toRef` live inside — the SVG overlay fills this. */
containerRef: React.RefObject<HTMLElement | null>;
/** Source node the beam departs from. */
fromRef: React.RefObject<HTMLElement | null>;
/** Destination node the beam pours into. */
toRef: React.RefObject<HTMLElement | null>;
/** Bend amount in px. Positive bows the path downward, negative upward. @default 0 */
curvature?: number;
/** Reverse the flow so the beam travels from `toRef` back to `fromRef`. @default false */
reverse?: boolean;
/** Seconds for one full slug travel + loop. @default 3 */
duration?: number;
/** Seconds before the first pass starts. @default 0 */
delay?: number;
/** Horizontal/vertical pixel offsets applied to the start point (on `fromRef`). */
startXOffset?: number;
startYOffset?: number;
/** Horizontal/vertical pixel offsets applied to the end point (on `toRef`). */
endXOffset?: number;
endYOffset?: number;
/** Static groove (channel) color. @default "#2a2a30" */
pathColor?: string;
/** Static groove opacity. @default 0.5 */
pathOpacity?: number;
/** Groove + slug stroke width in px. @default 2 */
pathWidth?: number;
/** Slug head/body/tail colors, hot to cool. @default ["#fff4e0", "#ff6b35", "#c1121f"] */
slugColors?: [string, string, string];
/** Flash a brief rim-glow on the receiving node each time the slug arrives. @default true */
glowOnReceive?: boolean;
}
interface PathState {
d: string;
endX: number;
endY: number;
startX: number;
startY: number;
}
function computePath(
container: HTMLElement,
from: HTMLElement,
to: HTMLElement,
curvature: number,
startXOffset: number,
startYOffset: number,
endXOffset: number,
endYOffset: number
): PathState {
const containerRect = container.getBoundingClientRect();
const fromRect = from.getBoundingClientRect();
const toRect = to.getBoundingClientRect();
const startX = fromRect.left - containerRect.left + fromRect.width / 2 + startXOffset;
const startY = fromRect.top - containerRect.top + fromRect.height / 2 + startYOffset;
const endX = toRect.left - containerRect.left + toRect.width / 2 + endXOffset;
const endY = toRect.top - containerRect.top + toRect.height / 2 + endYOffset;
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2 + curvature;
return {
d: `M ${startX} ${startY} Q ${midX} ${midY} ${endX} ${endY}`,
startX,
startY,
endX,
endY,
};
}
/**
* Conduit — an animated beam connecting two DOM nodes, like a tap channel
* from a furnace: a dark groove path carries a slug of molten metal
* (white-amber head, red-amber body, tapering tail) that pours into the
* destination node, flashing a brief rim-glow on receipt. Curved via
* `curvature`, reversible via `reverse`, and re-anchors on resize.
*
* Purely decorative — always `aria-hidden`. Under reduced motion the groove
* and a static slug render with no travel or flash.
*/
export function Conduit({
containerRef,
fromRef,
toRef,
curvature = 0,
reverse = false,
duration = 3,
delay = 0,
startXOffset = 0,
startYOffset = 0,
endXOffset = 0,
endYOffset = 0,
pathColor = "#2a2a30",
pathOpacity = 0.5,
pathWidth = 2,
slugColors = ["#fff4e0", "#ff6b35", "#c1121f"],
glowOnReceive = true,
className,
...props
}: ConduitProps) {
const reducedMotion = useReducedMotion();
const svgRef = React.useRef<SVGSVGElement>(null);
const [dims, setDims] = React.useState({ width: 0, height: 0 });
const [path, setPath] = React.useState<PathState | null>(null);
const reactId = React.useId();
const gradientId = `conduit-gradient-${reactId}`;
React.useEffect(() => {
const container = containerRef.current;
const from = fromRef.current;
const to = toRef.current;
if (!container || !from || !to) return;
const update = () => {
const rect = container.getBoundingClientRect();
setDims({ width: rect.width, height: rect.height });
setPath(computePath(container, from, to, curvature, startXOffset, startYOffset, endXOffset, endYOffset));
};
update();
const ro = new ResizeObserver(update);
ro.observe(container);
ro.observe(from);
ro.observe(to);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [containerRef, fromRef, toRef, curvature, startXOffset, startYOffset, endXOffset, endYOffset]);
if (!path) {
return (
<svg
ref={svgRef}
aria-hidden
data-crucible="conduit"
className={cn("pointer-events-none absolute inset-0", className)}
width={dims.width}
height={dims.height}
{...props}
/>
);
}
const arrivalPoint = reverse ? { x: path.startX, y: path.startY } : { x: path.endX, y: path.endY };
return (
<svg
ref={svgRef}
aria-hidden
data-crucible="conduit"
className={cn("pointer-events-none absolute inset-0 overflow-visible", className)}
width={dims.width}
height={dims.height}
{...props}
>
<defs>
{/* userSpaceOnUse: an objectBoundingBox gradient degenerates to nothing
on perfectly horizontal/vertical paths (zero-area box). Runs tail → head
along the direction of travel. */}
<linearGradient
id={gradientId}
gradientUnits="userSpaceOnUse"
x1={reverse ? path.endX : path.startX}
y1={reverse ? path.endY : path.startY}
x2={reverse ? path.startX : path.endX}
y2={reverse ? path.startY : path.endY}
>
<stop offset="0%" stopColor={slugColors[2]} stopOpacity={0} />
<stop offset="35%" stopColor={slugColors[2]} />
<stop offset="75%" stopColor={slugColors[1]} />
<stop offset="100%" stopColor={slugColors[0]} />
</linearGradient>
</defs>
{/* Static channel groove */}
<path d={path.d} fill="none" stroke={pathColor} strokeOpacity={pathOpacity} strokeWidth={pathWidth} strokeLinecap="round" />
{reducedMotion ? (
// Static slug resting mid-channel — no travel, no flash.
<motion.path
d={path.d}
fill="none"
stroke={slugColors[1]}
strokeWidth={pathWidth}
strokeLinecap="round"
style={{ pathLength: 0.3, pathOffset: 0.35, opacity: 0.85 } as MotionStyle}
/>
) : (
<>
<motion.path
d={path.d}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={pathWidth * 1.4}
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 ${pathWidth * 2}px ${slugColors[1]}aa)` }}
// pathLength must live in initial/animate (not as an attribute) or
// motion never emits the dasharray that makes the slug a segment.
initial={{ pathLength: 0.16, pathOffset: reverse ? 1 : 0 }}
animate={{ pathLength: 0.16, pathOffset: reverse ? [1, 0] : [0, 1] }}
transition={{ duration, delay, repeat: Infinity, ease: "linear" }}
/>
{glowOnReceive && (
<motion.circle
cx={arrivalPoint.x}
cy={arrivalPoint.y}
r={pathWidth * 2}
fill={slugColors[0]}
animate={{ opacity: [0, 0, 1, 0], scale: [0.6, 0.6, 1.6, 2.2] }}
transition={{
duration,
delay,
repeat: Infinity,
ease: "easeOut",
times: [0, 0.86, 0.94, 1],
}}
style={{ transformOrigin: `${arrivalPoint.x}px ${arrivalPoint.y}px` }}
/>
)}
</>
)}
</svg>
);
}
Installation
CLI
npx shadcn@latest add @crucible/conduitManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.