Drag an element with rubber-band overshoot and a springy return to origin, with an optional slingshot release.
npx shadcn@latest add @paragon/elastic-drag"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>
);
}