Baktiyar Ablaikhan

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).