Elastic Divider Lines That Pluck Like Strings

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How is the bending line drawn?

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.

How does the spring work?

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.

Does every line run its own animation loop all the time?

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.

Can the lines be vertical?

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.

Why does the SVG use overflow-visible?

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.

More in Cursors

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.

Product

Who It's For

Features

Resources

Company

View Sitemap