Spring Element
A spring-element component.
Loading...
import { SpringElement } from "@/components/core/spring-element";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export default function SpringElementExample() {
return (
<SpringElement>
<Avatar className="size-20">
<AvatarImage draggable={false} src="/favicon.ico" />
<AvatarFallback>NUI</AvatarFallback>
</Avatar>
</SpringElement>
);
}
Installation
Install the following dependencies:
npm install motionCopy and paste the following code into your project:
"use client";
import * as React from "react";
import {
type HTMLMotionProps,
type MotionValue,
motion,
useMotionValue,
useSpring,
} from "motion/react";
import { cn } from "@/lib/utils";
const generateSpringPath = (
x1: number,
y1: number,
x2: number,
y2: number,
springConfig: {
coilCount?: number;
amplitudeMin?: number;
amplitudeMax?: number;
curveRatioMin?: number;
curveRatioMax?: number;
bezierOffset?: number;
} = {}
) => {
const {
coilCount = 8,
amplitudeMin = 8,
amplitudeMax = 20,
curveRatioMin = 0.5,
curveRatioMax = 1,
bezierOffset = 8,
} = springConfig;
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 2) return `M${x1},${y1}`;
const d = dist / coilCount;
const h = Math.max(0.8, 1 - (dist - 40) / 200);
const amplitude = Math.max(
amplitudeMin,
Math.min(amplitudeMax, amplitudeMax * h)
);
const curveRatio =
dist <= 40
? curveRatioMax
: dist <= 120
? curveRatioMax - ((dist - 40) / 80) * (curveRatioMax - curveRatioMin)
: curveRatioMin;
const ux = dx / dist,
uy = dy / dist;
const perpX = -uy,
perpY = ux;
const path = [];
for (let i = 0; i < coilCount; i++) {
const sx = x1 + ux * (i * d);
const sy = y1 + uy * (i * d);
const ex = x1 + ux * ((i + 1) * d);
const ey = y1 + uy * ((i + 1) * d);
const mx = x1 + ux * ((i + 0.5) * d) + perpX * amplitude;
const my = y1 + uy * ((i + 0.5) * d) + perpY * amplitude;
const c1x = sx + d * curveRatio * ux;
const c1y = sy + d * curveRatio * uy;
const c2x = mx + ux * bezierOffset;
const c2y = my + uy * bezierOffset;
const c3x = mx - ux * bezierOffset;
const c3y = my - uy * bezierOffset;
const c4x = ex - d * curveRatio * ux;
const c4y = ey - d * curveRatio * uy;
if (i === 0) path.push(`M${sx},${sy}`);
else path.push(`L${sx},${sy}`);
path.push(`C${c1x},${c1y} ${c2x},${c2y} ${mx},${my}`);
path.push(`C${c3x},${c3y} ${c4x},${c4y} ${ex},${ey}`);
}
return path.join(" ");
};
function useMotionValueValue(mv: MotionValue<number>) {
return React.useSyncExternalStore(
(callback) => {
const unsub = mv.on("change", callback);
return unsub;
},
() => mv.get(),
() => mv.get()
);
}
type SpringAvatarProps = {
children: React.ReactElement;
className?: string;
springClassName?: string;
dragElastic?: number;
springConfig?: { stiffness?: number; damping?: number };
springPathConfig?: {
coilCount?: number;
amplitudeMin?: number;
amplitudeMax?: number;
curveRatioMin?: number;
curveRatioMax?: number;
bezierOffset?: number;
};
} & HTMLMotionProps<"div">;
function SpringElement({
ref,
children,
className,
springClassName,
dragElastic = 0.2,
springConfig = { stiffness: 200, damping: 16 },
springPathConfig = {},
...props
}: SpringAvatarProps) {
const x = useMotionValue(0);
const y = useMotionValue(0);
const springX = useSpring(x, {
stiffness: springConfig.stiffness,
damping: springConfig.damping,
});
const springY = useSpring(y, {
stiffness: springConfig.stiffness,
damping: springConfig.damping,
});
const sx = useMotionValueValue(springX);
const sy = useMotionValueValue(springY);
const childRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => childRef.current as HTMLDivElement);
const [center, setCenter] = React.useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = React.useState(false);
React.useLayoutEffect(() => {
function update() {
if (childRef.current) {
const rect = childRef.current.getBoundingClientRect();
setCenter({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
}
update();
window.addEventListener("resize", update);
window.addEventListener("scroll", update, true);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("scroll", update, true);
};
}, []);
React.useEffect(() => {
if (isDragging) {
document.body.style.cursor = "grabbing";
} else {
document.body.style.cursor = "default";
}
}, [isDragging]);
const path = generateSpringPath(
center.x,
center.y,
center.x + sx,
center.y + sy,
springPathConfig
);
return (
<>
<svg
width="100vw"
height="100vh"
className="fixed inset-0 w-screen h-screen pointer-events-none z-40"
>
<path
d={path}
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"stroke-2 stroke-neutral-900 dark:stroke-neutral-100 fill-none",
springClassName
)}
/>
</svg>
<motion.div
ref={childRef}
className={cn(
"z-50",
isDragging ? "cursor-grabbing" : "cursor-grab",
className
)}
style={{
x: springX,
y: springY,
}}
drag
dragElastic={dragElastic}
dragMomentum={false}
onDragStart={() => {
setIsDragging(true);
}}
onDrag={(_, info) => {
x.set(info.offset.x);
y.set(info.offset.y);
}}
onDragEnd={() => {
x.set(0);
y.set(0);
setIsDragging(false);
}}
{...props}
>
{children}
</motion.div>
</>
);
}
export { SpringElement };
Update the import paths to match your project setup.
Usage
import { SpringElement } from "@/components/core/spring-element";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
export default function SpringElementExample() {
return (
<SpringElement>
<Avatar className="size-20">
<AvatarImage draggable={false} src="/favicon.ico" />
<AvatarFallback>NUI</AvatarFallback>
</Avatar>
</SpringElement>
);
}
Props
| Prop | Type | Default |
|---|---|---|
children | ReactElement | - |
className? | string | - |
springClassName? | string | - |
dragElastic? | number | 0.2 |
springConfig? | object | { stiffness: 200, damping: 16 } |
springPathConfig? | object | {} |