Buttons
SlingShot
The button hangs in a wooden catapult. Pull it back and let go: the bands snap and a stone flies the other way. A plain click or Enter does a short shot.
Download
- React
- Tailwind CSS v4
- motion
components/ui/sling-shot.tsx
"use client";
import { useRef, useState, type ReactNode } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type SlingShotProps = {
children: ReactNode;
className?: string;
/** Runs on a click, Enter, Space, or a pull that is released. */
onClick?: () => void;
/** Colour of the wooden fork. */
wood?: string;
/** Colour of the rubber bands. */
band?: string;
/** Colour of the stone that flies off. */
stone?: string;
};
type Stone = { id: number; x: number; y: number };
const W = 240;
const H = 150;
const REST = { x: W / 2, y: 62 };
const LEFT = { x: 22, y: 18 };
const RIGHT = { x: W - 22, y: 18 };
const MIN_PULL = 24;
/* SlingShot: the button hangs in a catapult. Pull it back and let go, the bands snap and a stone flies. A plain click does a short shot. */
export function SlingShot({
children,
className = "rounded-full bg-[#f4b740] px-7 py-3 text-[15px] font-semibold text-[#2a1b05] shadow-[0_6px_14px_rgba(0,0,0,0.45)]",
onClick,
wood = "#7a4a26",
band = "#c8423b",
stone = "#f4e7c9",
}: SlingShotProps) {
const reduce = useReducedMotion();
const x = useMotionValue(0);
const y = useMotionValue(0);
const [stones, setStones] = useState<Stone[]>([]);
const next = useRef(0);
const dragged = useRef(false);
const leftBand = useTransform([x, y], ([dx, dy]: number[]) => `M${LEFT.x} ${LEFT.y} L${REST.x + dx} ${REST.y + dy}`);
const rightBand = useTransform([x, y], ([dx, dy]: number[]) => `M${RIGHT.x} ${RIGHT.y} L${REST.x + dx} ${REST.y + dy}`);
const thickness = useTransform([x, y], ([dx, dy]: number[]) => Math.max(2.5, 6 - Math.hypot(dx, dy) / 20));
const fire = (dx: number, dy: number) => {
onClick?.();
if (reduce) return;
const size = Math.hypot(dx, dy) || 1;
const reach = 260;
setStones((s) => [...s, { id: next.current++, x: (-dx / size) * reach, y: (-dy / size) * reach }]);
};
return (
<span className="relative inline-block" style={{ width: W, height: H }}>
<svg aria-hidden viewBox={`0 0 ${W} ${H}`} className="pointer-events-none absolute inset-0 size-full overflow-visible">
<path
d={`M${W / 2} ${H - 2} L${W / 2} 104 L${LEFT.x} ${LEFT.y} M${W / 2} 104 L${RIGHT.x} ${RIGHT.y}`}
fill="none"
stroke={wood}
strokeWidth={13}
strokeLinecap="round"
strokeLinejoin="round"
/>
<motion.path d={leftBand} fill="none" stroke={band} strokeLinecap="round" style={{ strokeWidth: thickness }} />
<motion.path d={rightBand} fill="none" stroke={band} strokeLinecap="round" style={{ strokeWidth: thickness }} />
</svg>
{stones.map((s) => (
<motion.span
key={s.id}
aria-hidden
className="pointer-events-none absolute size-3.5 rounded-full"
style={{ left: REST.x - 7, top: REST.y - 7, background: stone }}
initial={{ x: 0, y: 0, opacity: 1 }}
animate={{ x: s.x, y: s.y, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
onAnimationComplete={() => setStones((all) => all.filter((o) => o.id !== s.id))}
/>
))}
<span className="absolute w-max -translate-x-1/2 -translate-y-1/2" style={{ left: REST.x, top: REST.y }}>
<motion.button
type="button"
drag={!reduce}
dragSnapToOrigin
dragConstraints={{ left: -70, right: 70, top: -10, bottom: 70 }}
dragElastic={0.12}
dragTransition={{ bounceStiffness: 900, bounceDamping: 16 }}
onDragStart={() => {
dragged.current = true;
}}
onDragEnd={() => {
const dx = x.get();
const dy = y.get();
if (Math.hypot(dx, dy) >= MIN_PULL) fire(dx, dy);
}}
onClick={() => {
// The browser sends a click after a drag too. The drag already decided.
if (dragged.current) {
dragged.current = false;
return;
}
fire(0, 30);
if (reduce) return;
animate(y, [0, 30, 0], { duration: 0.34, times: [0, 0.3, 1], ease: "easeOut" });
}}
className={`relative block cursor-grab touch-none whitespace-nowrap active:cursor-grabbing ${className}`}
style={{ x, y }}
>
{children}
</motion.button>
</span>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/sling-shot.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/sling-shot-demo.tsx
"use client"; import { useState } from "react"; import { SlingShot } from "@/components/ui/sling-shot"; export default function SlingShotExample() { const [shots, setShots] = useState(0); return ( <div className="grid h-full place-items-center content-center gap-3 bg-[radial-gradient(circle_at_50%_30%,#2a2230,#0d0c14_70%)]"> <SlingShot onClick={() => setShots((s) => s + 1)}>Pull me back</SlingShot> <p className="text-[13px] text-white/60" aria-live="polite">Shots fired: {shots}</p> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Button label. |
| onClick | () => void | — | Runs on a click, Enter, Space, or a released pull of 24px or more. |
| wood | string | "#7a4a26" | Colour of the fork. |
| band | string | "#c8423b" | Colour of the rubber bands. |
| stone | string | "#f4e7c9" | Colour of the stone. |
| className | string | amber pill | Replaces the button look. The fork stays 240 by 150. |