A class timetable for a guitar school where the six dividers between rows behave like strings. When the pointer crosses a line it catches, the line bends to follow, and once pulled far enough it slips free and rings back to straight on a damped spring. Each line has a different thickness, from a heavy low string to a fine high one. Rows hold a number, class name, level, time, price and an enrol link. The preview plucks strings on its own until the pointer moves. On phones the time moves under the class name and the price column is hidden.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/physics/elastic-line)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
// One row per class. `gauge` is the thickness of the string drawn under the row, in pixels.
const classes = [
{ name: "First chords", level: "Beginners", when: "Mondays, 6.30pm", price: "120", gauge: 3.2 },
{ name: "Fingerstyle basics", level: "Beginners", when: "Tuesdays, 7pm", price: "140", gauge: 2.7 },
{ name: "Blues in twelve bars", level: "Improvers", when: "Wednesdays, 6.30pm", price: "140", gauge: 2.2 },
{ name: "Open tunings", level: "Improvers", when: "Thursdays, 7pm", price: "160", gauge: 1.7 },
{ name: "Set up your own guitar", level: "All levels", when: "Saturdays, 10am", price: "95", gauge: 1.3 },
{ name: "Songwriting circle", level: "All levels", when: "Sundays, 4pm", price: "80", gauge: 1 },
];
const HEIGHT = 44; // height of the hit area around each line
const GRAB = 6; // the pointer must come this close to the line to catch it
const RELEASE = 20; // pulled further than this, the line slips free
const STIFFNESS = 320; // spring strength: higher snaps back faster
const DAMPING = 5; // lower rings for longer
const DEMO_MS = 650; // gap between plucks while the preview plays on its own
type Pluck = (at: number, amount: number) => void;
function ElasticLine({ gauge, enabled, onReady }: { gauge: number; enabled: boolean; onReady: (pluck: Pluck) => void }) {
const wrapRef = useRef<HTMLDivElement>(null);
const pathRef = useRef<SVGPathElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const path = pathRef.current;
if (!wrap || !path) return;
const mid = HEIGHT / 2;
let width = wrap.clientWidth;
const s = { x: width / 2, y: 0, v: 0, grabbed: false };
const draw = () => path.setAttribute("d", `M0 ${mid} Q${s.x.toFixed(1)} ${(mid + s.y).toFixed(1)} ${width} ${mid}`);
draw();
const observer = new ResizeObserver(() => {
width = wrap.clientWidth;
draw();
});
observer.observe(wrap);
if (!enabled) return () => observer.disconnect();
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.032);
last = now;
if (!s.grabbed) {
// A damped spring pulls the control point back to the centre line.
for (let i = 0; i < 4; i++) {
s.v += (-STIFFNESS * s.y - DAMPING * s.v) * (dt / 4);
s.y += s.v * (dt / 4);
}
}
draw();
if (s.grabbed || Math.abs(s.y) > 0.05 || Math.abs(s.v) > 0.5) raf = requestAnimationFrame(loop);
else {
s.y = 0;
raf = 0;
draw();
}
};
const wake = () => {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(loop);
};
const onMove = (e: PointerEvent) => {
const r = wrap.getBoundingClientRect();
const x = e.clientX - r.left;
const dy = e.clientY - r.top - mid;
if (!s.grabbed && Math.abs(dy) < GRAB) s.grabbed = true;
else if (s.grabbed && Math.abs(dy) > RELEASE) s.grabbed = false;
if (s.grabbed) {
// The curve's peak is half the control point's offset, so pull it a little more than double.
s.x = x;
s.y = dy * 2.2;
s.v = 0;
wake();
}
};
const onLeave = () => {
s.grabbed = false;
};
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
wrap.addEventListener("pointercancel", onLeave);
onReady((at, amount) => {
if (s.grabbed) return;
s.x = width * at;
s.y = amount;
s.v = 0;
wake();
});
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
wrap.removeEventListener("pointercancel", onLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
return (
<div ref={wrapRef} aria-hidden="true" className="relative z-10 -my-2" style={{ height: HEIGHT }}>
<svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible">
<path ref={pathRef} fill="none" stroke="currentColor" strokeWidth={gauge} strokeLinecap="round" />
</svg>
</div>
);
}
export default function ElasticDividerLinesThatPluckLikeStrings() {
const reduce = useReducedMotion();
const plucks = useRef<Pluck[]>([]);
const [demo, setDemo] = useState(true);
useEffect(() => {
if (!demo || reduce) return;
let n = 0;
const id = window.setInterval(() => {
const i = [0, 3, 1, 5, 2, 4][n % classes.length];
// Pseudo-random position and direction, so the preview does not look mechanical.
const at = 0.2 + ((n * 0.37) % 0.6);
plucks.current[i]?.(at, (n % 2 ? -1 : 1) * (44 + (n % 3) * 14));
n++;
}, DEMO_MS);
return () => window.clearInterval(id);
}, [demo, reduce]);
return (
<section className="bg-[#f6f1e7] px-6 py-16 text-stone-900 sm:py-20" onPointerMove={() => demo && setDemo(false)}>
<div className="mx-auto max-w-5xl">
<div className="flex flex-wrap items-end justify-between gap-6">
<div>
<p className="font-mono text-xs uppercase tracking-[0.25em] text-amber-800">Fretwork School / Spring term</p>
<h2 className="mt-4 font-serif text-4xl leading-tight tracking-tight sm:text-6xl">Six classes, six strings.</h2>
</div>
<p className="max-w-xs text-base leading-relaxed text-stone-700">Run your pointer across a line to pluck it. Ten weeks, eight players to a room, guitars to borrow.</p>
</div>
<ul className="mt-10">
{classes.map((c, i) => (
<li key={c.name}>
<div className="grid grid-cols-[2.25rem_1fr_auto] items-center gap-x-4 py-2 sm:grid-cols-[3rem_1.4fr_1fr_5rem_auto]">
<span className="font-mono text-sm tabular-nums text-stone-600">{String(i + 1).padStart(2, "0")}</span>
<span>
<span className="block font-serif text-xl leading-snug sm:text-2xl">{c.name}</span>
<span className="block text-sm text-stone-600">
{c.level}
<span className="sm:hidden"> / {c.when}</span>
</span>
</span>
<span className="hidden text-sm text-stone-700 sm:block">{c.when}</span>
<span className="hidden font-mono text-sm tabular-nums sm:block">${c.price}</span>
<a href="#" aria-label={`Enrol in ${c.name}`} className="inline-flex h-10 items-center rounded-full border border-stone-900 px-4 text-sm font-semibold transition hover:bg-stone-900 hover:text-[#f6f1e7] focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f6f1e7]">
Enrol
</a>
</div>
<ElasticLine
gauge={c.gauge}
enabled={!reduce}
onReady={(p) => {
plucks.current[i] = p;
}}
/>
</li>
))}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/elastic-divider-lines-that-pluck-like-strings.json
Each line is an SVG path with one quadratic curve from the left edge to the right edge. Only the control point moves. Its horizontal position follows the pointer and its vertical offset is either the pointer's pull or the spring's current value. A quadratic curve peaks at half the control point's offset, so the pull is multiplied by 2.2 to make the line reach the pointer.
When the line is released, a small loop integrates a damped spring: acceleration is minus stiffness times offset minus damping times velocity. It runs in four sub-steps per frame for stability and stops itself when the line is nearly still.
No. A line requests frames only while it is grabbed or still ringing. At rest there is no loop, so a page with many dividers costs nothing until one is touched.
Yes. Swap the roles of x and y: draw the path from top to bottom, compare the pointer's horizontal distance to the middle, and let the control point move sideways.
The hit area is 44 pixels tall but a plucked string can swing further than that. Letting the SVG overflow means the curve is never clipped, while the layout height stays fixed.
Adapted from Elastic Line in Fancy Components, used under its open-source licence. Licences and credits
Related: Magnetic Button That Follows the Pointer, Self-Drawing Vertical Timeline with Pop-In Items, Pagination with Previous, Next and Page Size Select.
More cursor effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.