Interactive
RopeDrag
A brass bell on a beam with a jute pull rope and a wooden toggle. The rope has real weight: pull it and it swings, tug it hard and the bell rings, brush past and it stirs. Enter tugs, arrows swing it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/rope-drag.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type RopeDragProps = {
/** Called on every ring, with the running count. */
onRing?: (count: number) => void;
/** Sound the bell. Browsers stay silent until the first press or key. */
sound?: boolean;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const W = 360;
const H = 336;
const PX = 180; // Bell pivot.
const PY = 36;
const DROP = 82; // Pivot to the eye the rope hangs from.
const NODES = 17;
const LINK = 10;
const STRETCH = 1.14; // The rope gives this much before it stops.
const RING_AT = 1.08;
const RE_ARM = 1.02;
/* Smooth line through the nodes, plus the slanted lay of the strands along it. */
function rope(xs: ArrayLike<number>, ys: ArrayLike<number>) {
let line = `M${xs[0].toFixed(1)} ${ys[0].toFixed(1)}`;
for (let i = 1; i < NODES - 1; i++) {
const mx = (xs[i] + xs[i + 1]) / 2;
const my = (ys[i] + ys[i + 1]) / 2;
line += `Q${xs[i].toFixed(1)} ${ys[i].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
}
line += `L${xs[NODES - 1].toFixed(1)} ${ys[NODES - 1].toFixed(1)}`;
let lay = "";
for (let i = 0; i < NODES - 1; i++) {
const dx = xs[i + 1] - xs[i];
const dy = ys[i + 1] - ys[i];
const d = Math.hypot(dx, dy) || 1;
const tx = dx / d;
const ty = dy / d;
for (let s = 0.17; s < 1; s += 0.33) {
const x = xs[i] + dx * s;
const y = ys[i] + dy * s;
lay += `M${(x - ty * 4.6 - tx * 2).toFixed(1)} ${(y + tx * 4.6 - ty * 2).toFixed(1)}L${(x + ty * 4.6 + tx * 2).toFixed(1)} ${(y - tx * 4.6 + ty * 2).toFixed(1)}`;
}
}
return { line, lay };
}
const REST_X = Array.from({ length: NODES }, () => PX);
const REST_Y = Array.from({ length: NODES }, (_, i) => PY + DROP + i * LINK);
const REST = rope(REST_X, REST_Y);
function bellTone(audio: AudioContext, loud: number) {
const now = audio.currentTime;
[
[1, 0.5, 2.6],
[2, 0.28, 1.9],
[2.41, 0.2, 1.5],
[3.01, 0.14, 1],
[4.17, 0.08, 0.6],
].forEach(([ratio, level, fade]) => {
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.frequency.value = 410 * ratio;
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(level * loud, now + 0.006);
gain.gain.exponentialRampToValueAtTime(0.0001, now + fade);
osc.connect(gain).connect(audio.destination);
osc.start(now);
osc.stop(now + fade + 0.02);
});
}
type Engine = {
grab: (x: number, y: number) => void;
drag: (x: number, y: number) => void;
drop: () => void;
brush: (x: number, y: number) => void;
tug: () => void;
sway: (dir: number) => void;
unlock: () => void;
};
/* RopeDrag: a brass bell on a beam with a jute pull rope and a wooden toggle. The rope has real weight: pull it and it swings, tug it hard and the bell rings, brush past and it stirs. */
export function RopeDrag({ onRing, sound = true, label = "Bell rope", className = "" }: RopeDragProps) {
const reduce = useReducedMotion();
const svg = useRef<SVGSVGElement>(null);
const base = useRef<SVGPathElement>(null);
const light = useRef<SVGPathElement>(null);
const lay = useRef<SVGPathElement>(null);
const hit = useRef<SVGPathElement>(null);
const toggle = useRef<SVGGElement>(null);
const bell = useRef<SVGGElement>(null);
const arcs = useRef<SVGGElement>(null);
const engine = useRef<Engine | null>(null);
const callback = useRef(onRing);
const dragging = useRef(false);
const [count, setCount] = useState(0);
useEffect(() => {
callback.current = onRing;
}, [onRing]);
useEffect(() => {
const x = Float32Array.from(REST_X);
const y = Float32Array.from(REST_Y);
const px = x.slice();
const py = y.slice();
const damp = reduce ? 0.55 : 0.992;
let angle = 0;
let spin = 0;
let held = -1;
let aimX = 0;
let aimY = 0;
let armed = true;
let side = 1;
let rings = 0;
let raf = 0;
let then = 0;
let store = 0;
let audio: AudioContext | undefined;
const ring = (dir: number) => {
rings += 1;
if (!reduce) spin += dir * -2.3;
if (audio) bellTone(audio, 0.34);
arcs.current?.animate(
[
{ opacity: 0.9, transform: "scale(0.92)" },
{ opacity: 0, transform: "scale(1.28)" },
],
{ duration: 800, easing: "ease-out" },
);
setCount(rings);
callback.current?.(rings);
};
const step = () => {
const topX = PX - Math.sin(angle) * DROP;
const topY = PY + Math.cos(angle) * DROP;
let pull = 0;
let lean = 0;
if (held >= 0) {
const dx = aimX - topX;
const dy = aimY - topY;
const dist = Math.hypot(dx, dy) || 1;
const reach = held * LINK;
pull = dist / reach;
lean = dx / dist;
const limit = Math.min(1, (reach * STRETCH) / dist);
const hx = topX + dx * limit;
const hy = topY + dy * limit;
x[held] += (hx - x[held]) * 0.5;
y[held] += (hy - y[held]) * 0.5;
if (pull >= RING_AT && armed) {
armed = false;
side = Math.abs(dx) > 6 ? Math.sign(dx) : -side;
ring(side);
}
if (pull < RE_ARM) armed = true;
}
if (!reduce) {
const force = -38 * angle - 2.6 * spin - (pull > 1 ? lean * 26 * Math.min(1, (pull - 1) / 0.12) : 0);
spin += force / 60;
angle += spin / 60;
}
for (let i = 1; i < NODES; i++) {
if (i === held) {
px[i] = x[i];
py[i] = y[i];
continue;
}
const vx = (x[i] - px[i]) * damp;
const vy = (y[i] - py[i]) * damp;
px[i] = x[i];
py[i] = y[i];
x[i] += vx;
y[i] += vy + 0.34;
}
for (let pass = 0; pass < 22; pass++) {
x[0] = topX;
y[0] = topY;
for (let i = 0; i < NODES - 1; i++) {
const j = i + 1;
const dx = x[j] - x[i];
const dy = y[j] - y[i];
const d = Math.hypot(dx, dy) || 0.001;
const push = (d - LINK) / d;
const a = i === 0 || i === held ? 0 : j === held ? 1 : 0.5;
const b = j === held ? 0 : i === 0 || i === held ? 1 : 0.5;
x[i] += dx * push * a;
y[i] += dy * push * a;
x[j] -= dx * push * b;
y[j] -= dy * push * b;
}
}
};
const paint = () => {
const { line, lay: strands } = rope(x, y);
base.current?.setAttribute("d", line);
light.current?.setAttribute("d", line);
hit.current?.setAttribute("d", line);
lay.current?.setAttribute("d", strands);
const e = NODES - 1;
const turn = (Math.atan2(y[e] - y[e - 2], x[e] - x[e - 2]) * 180) / Math.PI - 90;
toggle.current?.setAttribute("transform", `translate(${x[e].toFixed(1)} ${y[e].toFixed(1)}) rotate(${turn.toFixed(1)})`);
bell.current?.setAttribute("transform", `rotate(${((angle * 180) / Math.PI).toFixed(2)} ${PX} ${PY})`);
};
const tick = (now: number) => {
store += Math.min(50, now - then);
then = now;
while (store >= 1000 / 60) {
step();
store -= 1000 / 60;
}
paint();
let motion = Math.abs(spin) * DROP + Math.abs(angle) * DROP;
for (let i = 1; i < NODES; i++) motion += Math.abs(x[i] - px[i]) + Math.abs(y[i] - py[i]) * 0.2 + Math.abs(x[i] - x[0]) * 0.03;
raf = held >= 0 || motion > 0.45 ? requestAnimationFrame(tick) : 0;
};
const wake = () => {
if (raf) return;
then = performance.now();
raf = requestAnimationFrame(tick);
};
engine.current = {
grab(gx, gy) {
let best = 3;
let far = Infinity;
for (let i = 3; i < NODES; i++) {
const d = Math.hypot(x[i] - gx, y[i] - gy);
if (d < far) {
far = d;
best = i;
}
}
held = best;
aimX = gx;
aimY = gy;
wake();
},
drag(gx, gy) {
aimX = gx;
aimY = gy;
},
drop() {
held = -1;
armed = true;
wake();
},
brush(gx, gy) {
let touched = false;
for (let i = 2; i < NODES; i++) {
const d = gx - x[i];
if (Math.abs(d) < 15 && Math.abs(gy - y[i]) < 15) {
x[i] -= Math.sign(d || 1) * 0.9;
touched = true;
}
}
if (touched) wake();
},
tug() {
for (let i = 8; i < NODES; i++) y[i] += ((i - 7) / (NODES - 8)) * 16;
side = -side;
ring(side);
wake();
},
sway(dir) {
for (let i = 4; i < NODES; i++) x[i] += dir * ((i - 3) / (NODES - 4)) * 10;
wake();
},
unlock() {
if (!sound) return;
audio ??= new AudioContext();
void audio.resume();
},
};
return () => {
cancelAnimationFrame(raf);
engine.current = null;
void audio?.close();
};
}, [reduce, sound]);
const local = (e: { clientX: number; clientY: number }) => {
const box = svg.current!.getBoundingClientRect();
return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H };
};
/* Window listeners, so the rope stays in hand past the edge of the box. */
const onDown = (e: React.PointerEvent) => {
const { x, y } = local(e);
engine.current?.unlock();
engine.current?.grab(x, y);
dragging.current = true;
const move = (ev: PointerEvent) => {
const at = local(ev);
engine.current?.drag(at.x, at.y);
};
const up = () => {
dragging.current = false;
engine.current?.drop();
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
engine.current?.unlock();
engine.current?.tug();
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
engine.current?.sway(e.key === "ArrowRight" ? 1 : -1);
}
};
return (
<svg
ref={svg}
viewBox={`0 0 ${W} ${H}`}
role="group"
aria-label={label}
className={`block h-auto touch-none select-none ${className}`}
onPointerMove={(e) => {
if (dragging.current) return;
const { x, y } = local(e);
engine.current?.brush(x, y);
}}
>
<defs>
<linearGradient id="rope-brass" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#6f4a12" />
<stop offset="0.28" stopColor="#d3a241" />
<stop offset="0.42" stopColor="#fbe7a6" />
<stop offset="0.62" stopColor="#c08f30" />
<stop offset="1" stopColor="#5e3d0e" />
</linearGradient>
<linearGradient id="rope-beam" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5a3417" />
<stop offset="0.55" stopColor="#7b4a22" />
<stop offset="1" stopColor="#2e1808" />
</linearGradient>
<linearGradient id="rope-wood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#c98d4e" />
<stop offset="0.5" stopColor="#a26531" />
<stop offset="1" stopColor="#5c3213" />
</linearGradient>
</defs>
{/* Ring lines, drawn behind the bell. */}
<g ref={arcs} opacity="0" fill="none" stroke="#f3d98c" strokeWidth="2.4" strokeLinecap="round" style={{ transformOrigin: `${PX}px ${PY + 52}px` }}>
<path d="M108 66Q94 88 106 116M90 54Q70 88 88 128" />
<path d="M252 66Q266 88 254 116M270 54Q290 88 272 128" />
</g>
{/* The rope hangs from the bell's eye, so it rides with the swing. */}
<path ref={base} d={REST.line} fill="none" stroke="#a9834b" strokeWidth="9.4" strokeLinecap="round" />
<path ref={lay} d={REST.lay} fill="none" stroke="#5f4322" strokeWidth="1.5" strokeLinecap="round" />
<path ref={light} d={REST.line} fill="none" stroke="#efd5a0" strokeOpacity="0.28" strokeWidth="2.6" strokeLinecap="round" transform="translate(-1.8 0)" />
<g ref={toggle} transform={`translate(${PX} ${REST_Y[NODES - 1]})`}>
<circle r="8.5" fill="#8d6a3a" />
<path d="M-7 -6 7 6M-8 0 8 0M-6 6 7 -6" stroke="#5f4322" strokeWidth="1.4" />
<rect x="-33" y="6" width="66" height="17" rx="8.5" fill="url(#rope-wood)" stroke="#3b1e08" strokeWidth="0.8" />
<rect x="-33" y="12" width="66" height="1.4" fill="#3b1e08" fillOpacity="0.4" />
<rect x="-3" y="6" width="6" height="17" fill="#8d6a3a" />
<rect
x="-40"
y="-12"
width="80"
height="40"
rx="10"
fill="transparent"
role="button"
tabIndex={0}
aria-label="Ring the bell. Arrow keys swing the rope."
className="cursor-grab active:cursor-grabbing"
onPointerDown={onDown}
onMouseDown={(e) => e.preventDefault()}
onKeyDown={onKey}
/>
</g>
{/* A wide, unseen stroke to take the grab. */}
<path ref={hit} d={REST.line} fill="none" stroke="transparent" strokeWidth="34" pointerEvents="stroke" className="cursor-grab active:cursor-grabbing" onPointerDown={onDown} />
{/* Bell swings on its pivot. */}
<g ref={bell}>
<rect x={PX - 7} y={PY - 12} width="14" height="20" rx="6" fill="url(#rope-brass)" stroke="#4d3009" strokeWidth="0.8" />
<path
d={`M${PX - 12} ${PY + 6}C${PX - 34} ${PY + 12} ${PX - 36} ${PY + 44} ${PX - 40} ${PY + 62}C${PX - 42} ${PY + 72} ${PX - 52} ${PY + 76} ${PX - 56} ${PY + 80}L${PX + 56} ${PY + 80}C${PX + 52} ${PY + 76} ${PX + 42} ${PY + 72} ${PX + 40} ${PY + 62}C${PX + 36} ${PY + 44} ${PX + 34} ${PY + 12} ${PX + 12} ${PY + 6}Z`}
fill="url(#rope-brass)"
stroke="#4d3009"
strokeWidth="0.9"
/>
<path d={`M${PX - 38} ${PY + 56}Q${PX} ${PY + 66} ${PX + 38} ${PY + 56}`} fill="none" stroke="#5e3d0e" strokeWidth="2.2" />
<path d={`M${PX - 36} ${PY + 50}Q${PX} ${PY + 60} ${PX + 36} ${PY + 50}`} fill="none" stroke="#fbe7a6" strokeOpacity="0.55" strokeWidth="1.2" />
<path d={`M${PX - 31} ${PY + 26}Q${PX} ${PY + 33} ${PX + 31} ${PY + 26}`} fill="none" stroke="#5e3d0e" strokeOpacity="0.7" strokeWidth="1.6" />
<ellipse cx={PX} cy={PY + 80} rx="56" ry="4.2" fill="#2d1b05" />
<circle cx={PX} cy={PY + DROP - 1} r="5" fill="#4d3009" stroke="#d3a241" strokeWidth="1.6" />
</g>
{/* Beam and bracket stay still in front. */}
<rect x="0" y="0" width={W} height="24" fill="url(#rope-beam)" />
<path d="M0 8H360M0 15C120 13 240 17 360 15" stroke="#2e1808" strokeOpacity="0.5" strokeWidth="1" fill="none" />
<path d={`M${PX - 20} 22h40l-8 18h-24z`} fill="#2d2118" stroke="#0e0905" strokeWidth="0.8" />
<circle cx={PX} cy={PY} r="3.6" fill="#d3a241" />
<text x="-100" y="-100" fontSize="1" aria-live="polite" role="status">
{count ? `Bell rung ${count} ${count === 1 ? "time" : "times"}` : ""}
</text>
</svg>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/rope-drag.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/rope-drag-demo.tsx
import { RopeDrag } from "@/components/ui/rope-drag"; export default function RopeDragExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[linear-gradient(#23302c,#0e1513)] p-0"> <RopeDrag className="w-[min(104cqh,92cqw,440px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| onRing | (count: number) => void | - | Called on every ring, with the running count. |
| sound | boolean | true | Browsers stay silent until the first press or key. |
| label | string | "Bell rope" | Read out by screen readers. |
| className | string | "w-80" | Set the width here. Everything scales with it. |