Effects & Borders

Elastic Drag

Drag an element with rubber-band overshoot and a springy return to origin, with an optional slingshot release.

Install

npx shadcn@latest add @paragon/elastic-drag

elastic-drag.tsx

"use client";

import * as React from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  animate,
  type PanInfo,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * ElasticDrag — an element you drag with rubber-band overshoot and a springy
 * return to origin. Past a threshold the drag resists (rubber-banding); on
 * release it springs home, or, in `slingshot` mode, fires off in the drag
 * direction before snapping back. An optional tether line renders the pull.
 *
 * Built on motion/react gestures. Under reduced motion the element still drags
 * but returns instantly without overshoot. Keyboard focusable with a visible
 * ring; arrow keys nudge it (and it springs back).
 */

export interface ElasticDragProps extends React.ComponentProps<"div"> {
  /** Return spring stiffness. */
  stiffness?: number;
  /** How stretchy the rubber-band feels past the origin (0–1, higher = looser). */
  elasticity?: number;
  /** Fire off in the drag direction on release before returning. */
  slingshot?: boolean;
  /** Draw a tether line from origin to the element while dragging. */
  tether?: boolean;
  /** Tether / accent color. */
  color?: string;
  children: React.ReactNode;
}

export function ElasticDrag({
  stiffness = 300,
  elasticity = 0.55,
  slingshot = false,
  tether = true,
  color = "var(--color-primary)",
  className,
  children,
  ...props
}: ElasticDragProps) {
  const reduce = useReducedMotion();
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const [dragging, setDragging] = React.useState(false);

  const returnSpring = {
    type: "spring" as const,
    stiffness,
    damping: reduce ? 40 : 22,
    mass: 0.9,
  };

  const home = () => {
    animate(x, 0, reduce ? { duration: 0 } : returnSpring);
    animate(y, 0, reduce ? { duration: 0 } : returnSpring);
  };

  const onDragEnd = (_e: unknown, info: PanInfo) => {
    setDragging(false);
    if (reduce || !slingshot) {
      home();
      return;
    }
    const { x: vx, y: vy } = info.velocity;
    const speed = Math.hypot(vx, vy);
    const boost = Math.min(1, speed / 2000);
    const overshootX = x.get() + (vx / (speed || 1)) * 120 * boost;
    const overshootY = y.get() + (vy / (speed || 1)) * 120 * boost;
    animate(x, [x.get(), overshootX, 0], {
      duration: 0.7,
      times: [0, 0.32, 1],
      ease: [0.22, 1, 0.36, 1],
    });
    animate(y, [y.get(), overshootY, 0], {
      duration: 0.7,
      times: [0, 0.32, 1],
      ease: [0.22, 1, 0.36, 1],
    });
  };

  // Tether geometry
  const lineX2 = useTransform(x, (v) => v);
  const lineY2 = useTransform(y, (v) => v);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const map: Record<string, [number, number]> = {
      ArrowUp: [0, -24],
      ArrowDown: [0, 24],
      ArrowLeft: [-24, 0],
      ArrowRight: [24, 0],
    };
    const d = map[e.key];
    if (!d) return;
    e.preventDefault();
    animate(x, d[0], returnSpring).then(() => home());
    animate(y, d[1], returnSpring).then(() => home());
  };

  return (
    <div
      data-slot="elastic-drag"
      className={cn(
        "relative grid min-h-48 place-items-center overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      {tether && (
        <svg
          aria-hidden
          className="pointer-events-none absolute inset-0 size-full"
          style={{ opacity: dragging ? 1 : 0, transition: "opacity 150ms" }}
        >
          <g style={{ transform: "translate(50%, 50%)" }}>
            <motion.line
              x1={0}
              y1={0}
              x2={lineX2}
              y2={lineY2}
              stroke={color}
              strokeWidth={2}
              strokeLinecap="round"
              strokeDasharray="2 6"
              opacity={0.5}
            />
          </g>
        </svg>
      )}

      <motion.button
        type="button"
        drag
        dragElastic={elasticity}
        dragConstraints={{ left: -140, right: 140, top: -100, bottom: 100 }}
        dragMomentum={false}
        onDragStart={() => setDragging(true)}
        onDragEnd={onDragEnd}
        onKeyDown={onKeyDown}
        style={{ x, y }}
        whileTap={{ scale: 0.96, cursor: "grabbing" }}
        className="relative z-10 cursor-grab touch-none select-none rounded-2xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
      >
        {children}
      </motion.button>
    </div>
  );
}