Conduit
An animated beam connecting two DOM nodes via refs — a dark groove path carrying a blue-to-white slug of light that travels into the destination and flashes a white 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, bright to dim. @default ["#ffffff", "#93c5fd", "#3b82f6"] */
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;
/** Half the larger dimension of each anchored node — sizes the arrival rim-glow. */
startR: number;
endR: 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,
startR: Math.max(fromRect.width, fromRect.height) / 2,
endR: Math.max(toRect.width, toRect.height) / 2,
};
}
/**
* Conduit — an animated beam connecting two DOM nodes, like a signal on a
* wire: a dark groove path carries a slug of light (white head, blue body,
* tail tapering to transparent) that travels into the destination node,
* flashing a brief white 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 = ["#ffffff", "#93c5fd", "#3b82f6"],
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}`;
const bloomId = `conduit-bloom-${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 };
// Rim-glow radius tracks the receiving node's size so the bloom reads as a
// halo around the node (typically layered above the beam), not a dot on it.
const arrivalR = (reverse ? path.startR : path.endR) || pathWidth * 4;
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="42%" stopColor={slugColors[2]} stopOpacity={0.6} />
<stop offset="80%" stopColor={slugColors[1]} />
<stop offset="100%" stopColor={slugColors[0]} />
</linearGradient>
<filter id={bloomId} x="-150%" y="-150%" width="400%" height="400%">
<feGaussianBlur stdDeviation={Math.max(arrivalR * 0.35, 4)} />
</filter>
</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"
// pathLength/pathOffset must live in initial/animate (not style) or
// motion never emits the dasharray that makes the slug a segment.
initial={{ pathLength: 0.3, pathOffset: 0.35 }}
animate={{ pathLength: 0.3, pathOffset: 0.35 }}
style={{ 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 * 3}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 && (
<>
{/* Node-sized rim bloom — visible as a halo even when the
receiving node is layered above the beam overlay. */}
<motion.circle
cx={arrivalPoint.x}
cy={arrivalPoint.y}
r={arrivalR * 1.05}
fill={slugColors[1]}
filter={`url(#${bloomId})`}
animate={{ opacity: [0, 0, 0.45, 0], scale: [0.9, 0.9, 1.1, 1.25] }}
transition={{
duration,
delay,
repeat: Infinity,
ease: "easeOut",
times: [0, 0.86, 0.93, 1],
}}
style={{ transformOrigin: `${arrivalPoint.x}px ${arrivalPoint.y}px` }}
/>
{/* Bright point flash at the exact arrival point. */}
<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 motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| containerRef | React.RefObject<HTMLElement | null> | The positioned ancestor both fromRef and toRef live inside — the SVG overlay fills this. | |
| fromRef | React.RefObject<HTMLElement | null> | Source node the beam departs from. | |
| toRef | React.RefObject<HTMLElement | null> | Destination node the beam pours into. | |
| curvature | number | 0 | Bend amount in px. Positive bows the path downward, negative upward. |
| reverse | boolean | false | Reverse the flow so the beam travels from toRef back to fromRef. |
| duration | number | 3 | Seconds for one full slug travel + loop. |
| delay | number | 0 | Seconds before the first pass starts. |
| startXOffset | number | Horizontal/vertical pixel offsets applied to the start point (on fromRef). | |
| startYOffset | number | ||
| endXOffset | number | Horizontal/vertical pixel offsets applied to the end point (on toRef). | |
| endYOffset | number | ||
| pathColor | string | "#2a2a30" | Static groove (channel) color. |
| pathOpacity | number | 0.5 | Static groove opacity. |
| pathWidth | number | 2 | Groove + slug stroke width in px. |
| slugColors | [string, string, string] | ["#ffffff", "#93c5fd", "#3b82f6"] | Slug head/body/tail colors, bright to dim. |
| glowOnReceive | boolean | true | Flash a brief rim-glow on the receiving node each time the slug arrives. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"svg">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.