Spring

PreviousNext

A flexible, animated spring component that attaches a draggable element to its origin with a spring line.

Docs
animate-uiui

Preview

Loading preview…
registry/primitives/animate/spring/index.tsx
'use client';

import * as React from 'react';
import {
  motion,
  useMotionValue,
  useSpring as useMotionSpring,
  type SpringOptions,
  type HTMLMotionProps,
  type MotionValue,
} from 'motion/react';

import { useMotionValueState } from '@/hooks/use-motion-value-state';
import { getStrictContext } from '@/lib/get-strict-context';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';

type SpringPathConfig = {
  coilCount?: number;
  amplitudeMin?: number;
  amplitudeMax?: number;
  curveRatioMin?: number;
  curveRatioMax?: number;
  bezierOffset?: number;
};

function generateSpringPath(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  pathConfig: SpringPathConfig = {},
) {
  const {
    coilCount = 8,
    amplitudeMin = 8,
    amplitudeMax = 20,
    curveRatioMin = 0.5,
    curveRatioMax = 1,
    bezierOffset = 8,
  } = pathConfig;

  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: string[] = [];
  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(' ');
}

type SpringContextType = {
  dragElastic?: number;
  childRef: React.RefObject<HTMLDivElement | null>;
  springX: MotionValue<number>;
  springY: MotionValue<number>;
  x: MotionValue<number>;
  y: MotionValue<number>;
  isDragging: boolean;
  setIsDragging: (isDragging: boolean) => void;
  path: string;
};

const [LocalSpringProvider, useSpring] =
  getStrictContext<SpringContextType>('SpringContext');

type SpringProviderProps = {
  children: React.ReactNode;
  dragElastic?: number;
  pathConfig?: SpringPathConfig;
  transition?: SpringOptions;
};

function SpringProvider({
  dragElastic = 0.2,
  transition = { stiffness: 200, damping: 16 },
  pathConfig = {},
  ...props
}: SpringProviderProps) {
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  const springX = useMotionSpring(x, transition);
  const springY = useMotionSpring(y, transition);

  const sx = useMotionValueState(springX);
  const sy = useMotionValueState(springY);

  const childRef = React.useRef<HTMLDivElement>(null);

  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,
    pathConfig,
  );

  return (
    <LocalSpringProvider
      value={{
        springX,
        springY,
        x,
        y,
        isDragging,
        setIsDragging,
        dragElastic,
        childRef,
        path,
      }}
      {...props}
    />
  );
}

type SpringProps = React.SVGProps<SVGSVGElement>;

function Spring({ style, ...props }: SpringProps) {
  const { path } = useSpring();

  return (
    <svg
      width="100vw"
      height="100vh"
      style={{
        position: 'fixed',
        inset: 0,
        pointerEvents: 'none',
        ...style,
      }}
      {...props}
    >
      <path
        d={path}
        strokeLinecap="round"
        strokeLinejoin="round"
        stroke="currentColor"
        strokeWidth={2}
        fill="none"
      />
    </svg>
  );
}

type SpringElementProps = WithAsChild<
  Omit<HTMLMotionProps<'div'>, 'children'> & {
    children: React.ReactElement;
  }
>;

function SpringElement({
  ref,
  asChild = false,
  style,
  ...props
}: SpringElementProps) {
  const {
    childRef,
    dragElastic,
    isDragging,
    setIsDragging,
    springX,
    springY,
    x,
    y,
  } = useSpring();

  React.useImperativeHandle(ref, () => childRef.current as HTMLDivElement);

  const Component = asChild ? Slot : motion.div;

  return (
    <Component
      ref={childRef}
      style={{
        cursor: isDragging ? 'grabbing' : 'grab',
        x: springX,
        y: springY,
        ...style,
      }}
      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}
    />
  );
}

export {
  SpringProvider,
  Spring,
  SpringElement,
  useSpring,
  type SpringProviderProps,
  type SpringProps,
  type SpringElementProps,
  type SpringPathConfig,
  type SpringContextType,
};

Installation

npx shadcn@latest add @animate-ui/primitives-animate-spring

Usage

import { PrimitivesAnimateSpring } from "@/components/ui/primitives-animate-spring"
<PrimitivesAnimateSpring />