Slingshot
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Pull the button back like a slingshot and let go to launch it.
Code
"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { useRef, useState } from "react";
import { Surface } from "../ui";
export default function Slingshot() {
const x = useMotionValue(0);
const y = useMotionValue(0);
const scale = useMotionValue(1);
const opacity = useMotionValue(1);
const drag = useRef<{ id: number; sx: number; sy: number; max: number } | null>(null);
const [shots, setShots] = useState(0);
const [pulling, setPulling] = useState(false);
const [busy, setBusy] = useState(false);
const rotate = useTransform(() => {
const vx = -x.get();
const vy = -y.get();
if (Math.hypot(vx, vy) < 2) return 0;
return (Math.atan2(vy, vx) * 180) / Math.PI + 45;
});
const stretch = useTransform(() => Math.hypot(x.get(), y.get()));
const bandWidth = useTransform(stretch, [0, 140], [3, 1.2]);
const aimX = useTransform(() => -x.get() * 1.6);
const aimY = useTransform(() => -y.get() * 1.6);
const aimOpacity = useTransform(stretch, [10, 60], [0, 0.5]);
const onDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (busy) return;
e.currentTarget.setPointerCapture(e.pointerId);
const host = e.currentTarget.closest("[data-sling]") as HTMLElement | null;
const rect = host?.getBoundingClientRect();
const max = rect ? Math.min(rect.width, rect.height) * 0.3 : 100;
drag.current = { id: e.pointerId, sx: e.clientX, sy: e.clientY, max };
setPulling(true);
animate(scale, 0.92, { type: "spring", stiffness: 500, damping: 30 });
};
const onMove = (e: React.PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const dx = e.clientX - d.sx;
const dy = e.clientY - d.sy;
const dist = Math.hypot(dx, dy);
if (dist === 0) return;
const r = d.max * (1 - Math.exp(-dist / d.max));
x.set((dx / dist) * r);
y.set((dy / dist) * r);
};
const onUp = (e: React.PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
setPulling(false);
const r = Math.hypot(x.get(), y.get());
animate(scale, 1, { type: "spring", stiffness: 500, damping: 30 });
if (r < 18) {
animate(x, 0, { type: "spring", stiffness: 600, damping: 18 });
animate(y, 0, { type: "spring", stiffness: 600, damping: 18 });
return;
}
setBusy(true);
const power = r / d.max;
const tx = (-x.get() / r) * d.max * 9 * power;
const ty = (-y.get() / r) * d.max * 9 * power;
const dur = 0.55 - power * 0.2;
animate(x, tx, { duration: dur, ease: [0.2, 0.7, 0.3, 1] });
animate(y, ty, { duration: dur, ease: [0.2, 0.7, 0.3, 1] });
animate(opacity, 0, { duration: dur, ease: "easeIn" }).then(() => {
x.set(0);
y.set(0);
scale.set(0.2);
setShots((s) => s + 1);
animate(opacity, 1, { duration: 0.2 });
animate(scale, 1, { type: "spring", stiffness: 420, damping: 14 }).then(() => setBusy(false));
});
};
return (
<Surface>
<div data-sling className="absolute inset-0">
{/* the anchor ring */}
<div className="absolute left-1/2 top-1/2 h-[5.2em] w-[5.2em] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#e3e2dd] shadow-[inset_0_2px_6px_rgba(0,0,0,0.08)]" />
<svg className="pointer-events-none absolute left-1/2 top-1/2 overflow-visible" width="1" height="1">
<motion.line
x1={0}
y1={0}
x2={aimX}
y2={aimY}
stroke="#151515"
strokeDasharray="2 7"
strokeLinecap="round"
strokeWidth={2}
style={{ opacity: pulling ? aimOpacity : 0 }}
/>
<motion.line x1={-18} y1={0} x2={x} y2={y} stroke="#151515" strokeLinecap="round" style={{ strokeWidth: bandWidth, opacity: pulling ? 0.85 : 0 }} />
<motion.line x1={18} y1={0} x2={x} y2={y} stroke="#151515" strokeLinecap="round" style={{ strokeWidth: bandWidth, opacity: pulling ? 0.85 : 0 }} />
</svg>
<motion.button
type="button"
aria-label="Pull back and release"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
style={{ x, y, scale, opacity }}
className="absolute left-1/2 top-1/2 -ml-[2.1em] -mt-[2.1em] grid h-[4.2em] w-[4.2em] cursor-grab touch-none place-items-center rounded-full bg-[#151515] text-white shadow-[0_8px_20px_-6px_rgba(0,0,0,0.45)] active:cursor-grabbing"
>
<motion.svg style={{ rotate }} viewBox="0 0 24 24" className="h-[1.6em] w-[1.6em]" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7M8 7h9v9" />
</motion.svg>
</motion.button>
</div>
<p className="pointer-events-none absolute bottom-[6%] left-0 right-0 text-center font-mono text-[0.7em] tracking-wide text-[#8a8984]">
{shots === 0 ? "pull back · let go" : `launched ${shots}×`}
</p>
</Surface>
);
}
Prompt
Build "Slingshot", a button animation. What it does: Pull the button back like a slingshot and let go to launch it. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).