Elastic Line
A wobbly svg line with a spring cursor interaction.
FANCY COMPONENTS
Ready to use, fancy, animated React components & microinteractions for creative developers.
Source code
Create a hook for querying the cursor position:
import { useState, useEffect, RefObject } from "react";
export const useMousePosition = (containerRef?: RefObject<HTMLElement | SVGElement>) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const updatePosition = (x: number, y: number) => {
if (containerRef && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const relativeX = x - rect.left;
const relativeY = y - rect.top;
// Calculate relative position even when outside the container
setPosition({ x: relativeX, y: relativeY });
} else {
setPosition({ x, y });
}
};
const handleMouseMove = (ev: MouseEvent) => {
updatePosition(ev.clientX, ev.clientY);
};
const handleTouchMove = (ev: TouchEvent) => {
const touch = ev.touches[0];
updatePosition(touch.clientX, touch.clientY);
};
// Listen for both mouse and touch events
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("touchmove", handleTouchMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("touchmove", handleTouchMove);
};
}, [containerRef]);
return position;
};
And a hook for querying the dimensions of an element:
import { useState, useEffect, RefObject } from 'react';
interface Dimensions {
width: number;
height: number;
}
export function useDimensions(ref: RefObject<HTMLElement | SVGElement>): Dimensions {
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
useEffect(() => {
const updateDimensions = () => {
if (ref.current) {
const { width, height } = ref.current.getBoundingClientRect();
setDimensions({ width, height });
}
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, [ref]);
return dimensions;
}
For better readability, there is another hook for getting the elastic line's control point, and if the line is grabbed or not:
import { useState, useEffect } from "react";
import { useMousePosition } from "@/hooks/use-mouse-position";
import { useDimensions } from "@/hooks//use-dimensions";
interface ElasticLineEvents {
isGrabbed: boolean;
controlPoint: { x: number; y: number };
}
export function useElasticLineEvents(
containerRef: React.RefObject<SVGSVGElement>,
isVertical: boolean,
grabThreshold: number,
releaseThreshold: number
): ElasticLineEvents {
const mousePosition = useMousePosition(containerRef);
const dimensions = useDimensions(containerRef);
const [isGrabbed, setIsGrabbed] = useState(false);
const [controlPoint, setControlPoint] = useState({ x: dimensions.width / 2, y: dimensions.height / 2 });
useEffect(() => {
if (containerRef.current) {
const { width, height } = dimensions;
const x = mousePosition.x;
const y = mousePosition.y;
// Check if mouse is outside container bounds
const isOutsideBounds =
x < 0 ||
x > width ||
y < 0 ||
y > height;
if (isOutsideBounds) {
setIsGrabbed(false);
return;
}
let distance: number;
let newControlPoint: { x: number; y: number };
if (isVertical) {
const midX = width / 2;
distance = Math.abs(x - midX);
newControlPoint = {
x: midX + 2.2 * (x - midX),
y: y,
};
} else {
const midY = height / 2;
distance = Math.abs(y - midY);
newControlPoint = {
x: x,
y: midY + 2.2 * (y - midY),
};
}
setControlPoint(newControlPoint);
if (!isGrabbed && distance < grabThreshold) {
setIsGrabbed(true);
} else if (isGrabbed && distance > releaseThreshold) {
setIsGrabbed(false);
}
}
}, [mousePosition, isVertical, isGrabbed, grabThreshold, releaseThreshold]);
return { isGrabbed, controlPoint };
}
Then, copy and paste the component code into your project, and update your imports:
Understanding the component
This component is made with a simple svg quadratic curve, with 2+1 points. The start and end points of the curve positioned at the two edges of the parent container, either horizontally or vertically, depending on the isVertical
prop. This means, the line will always be centered in the container, and it will always fill up the entire container, so make sure to position your container properly.
The third point of the line is the control point, named Q
, which is positioned at the center of the container by default. When the cursor moves close to the line (within grabThreshold
), the control point will be controlled by the cursor's position. When the distance between them is greater than the releaseThreshold
prop, the control point is animated back to the center of the container, with the help of framer-motion's animate
function.
For better readability — the calculation of the control point's position, and the signal it's grabbed — done in a separate hook, called useElasticLineEvents
.
To achiave the elastic effect we use a springy transition by default, but feel free to experiment with other type of animations, easings, durations, etc.
The compoment also have an animateInTransition
prop, which is used when the line is initially rendered. If you want to skip this, just set the transiton's duration
to 0
.
Resources
Props
Prop | Type | Default | Description |
---|---|---|---|
isVertical | boolean | false | Whether the line is vertical or horizontal |
grabThreshold | number | 5 | The distance threshold for grabbing the line |
releaseThreshold | number | 100 | The distance threshold for releasing the line |
strokeWidth | number | 1 | The width of the line stroke |
transition | Transition | { type: "spring", stiffness: 400, damping: 5, delay: 0 } | The transition object of the line. Refer to framer-motion docs for more details |
animateInTransition | Transition | { type: "spring", stiffness: 400, damping: 5, delay: 0 } | The transition object of the line when it is initially rendered. Refer to framer-motion docs for more details |
className | string | - | Additional CSS classes for styling on the svg container |