Docs
Elastic Line

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


PropTypeDefaultDescription
isVerticalbooleanfalseWhether the line is vertical or horizontal
grabThresholdnumber5The distance threshold for grabbing the line
releaseThresholdnumber100The distance threshold for releasing the line
strokeWidthnumber1The width of the line stroke
transitionTransition{ type: "spring", stiffness: 400, damping: 5, delay: 0 }The transition object of the line. Refer to framer-motion docs for more details
animateInTransitionTransition{ 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
classNamestring-Additional CSS classes for styling on the svg container