← Hover & cursor

Plucked String Line

A divider line that behaves like a guitar string. When the pointer crosses it, the line catches and bends along with the pointer, and when pulled too far it slips free and wobbles back to still.

Chapter one

Run your pointer across the line and it catches, bends and twangs back. On a phone, drag a finger across it or tap near it.

A thicker string, with more give

How to use it

  1. Paste the snippet just before </head>.
  2. Use <div data-plucked-string-line></div> in place of an <hr>.

Options

Add any of these attributes to the same element.

data-colorLine colour. Default the text colour.
data-widthLine thickness, in px. Default 2.
data-pullHow far the line bends before it lets go, in px. Default 36.
data-autoplayA pretend fingertip plucks the line now and then while no real pointer is about. Handy for demos.

The bend spills over the content above and below the line without pushing it about. It’s marked as a separator for screen readers. On touch screens, drag a finger across it or tap near it to pluck it. With reduced motion turned on, it stays straight.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Plucked String Line: a divider line that behaves like a guitar string.
    // When the pointer crosses it, the line catches and bends with the
    // pointer; pull too far and it snaps free and wobbles back to still.
    // Usage: <div data-plucked-string-line></div>  (use it in place of an <hr>)
    // Optional: data-color (default currentColor), data-width (line
    // thickness, px, default 2), data-pull (how far it bends before letting
    // go, px, default 36), data-autoplay (a pretend fingertip plucks it now
    // and then while no real pointer is about; handy for demos).
    // The bend spills over the content around the line without moving it.
    // On touch screens, drag a finger across it, or tap near it to pluck.
    var DEFAULTS = { width: 2, pull: 36 };

    var css =
      '.psg{position:relative;display:block;height:24px;margin:0;touch-action:pan-y;-webkit-tap-highlight-color:transparent}' +
      '.psg-svg{position:absolute;left:0;top:50%;width:100%;height:120px;margin-top:-60px;overflow:visible;pointer-events:none}' +
      '.psg-tip{position:absolute;left:0;top:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;pointer-events:none;' +
      'background:currentColor;opacity:0;transition:opacity .25s}' +
      '.psg-tip.psg-show{opacity:.35}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var lw = num(d.width, DEFAULTS.width);
      var pull = num(d.pull, DEFAULTS.pull);
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var still = reduced();
      el.classList.add('psg');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'separator');

      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('class', 'psg-svg');
      svg.setAttribute('aria-hidden', 'true');
      var path = document.createElementNS(NS, 'path');
      path.setAttribute('fill', 'none');
      path.setAttribute('stroke', d.color || 'currentColor');
      path.setAttribute('stroke-width', lw);
      path.setAttribute('stroke-linecap', 'round');
      svg.appendChild(path);
      el.appendChild(svg);

      var tip = document.createElement('span');
      tip.className = 'psg-tip';
      tip.setAttribute('aria-hidden', 'true');
      if (d.color) tip.style.color = d.color;
      el.appendChild(tip);

      var w = 0;
      // The string: where it's bent (x), how far (y), its speed, and whether held.
      var s = { x: 0, y: 0, v: 0, held: false };
      var prev = null, visible = false, raf = 0, near = false;

      function shape() {
        w = el.clientWidth;
        svg.setAttribute('viewBox', '0 -60 ' + Math.max(1, w) + ' 120');
        var half = lw / 2;
        var x = Math.max(w * 0.06, Math.min(w * 0.94, s.x));
        var y = Math.max(-58, Math.min(58, s.y));
        // A held string kinks at the finger; a free one rounds into a curve.
        if (s.held) {
          var k = 0.35;
          path.setAttribute('d', 'M' + half + ' 0 Q' + (x * (1 - k)).toFixed(1) + ' ' + (y * 0.9).toFixed(1) + ' ' + x.toFixed(1) + ' ' + y.toFixed(1) +
            ' Q' + (x + (w - x) * k).toFixed(1) + ' ' + (y * 0.9).toFixed(1) + ' ' + (w - half) + ' 0');
        } else {
          path.setAttribute('d', 'M' + half + ' 0 Q' + x.toFixed(1) + ' ' + (2 * y).toFixed(1) + ' ' + (w - half) + ' 0');
        }
      }

      function frame() {
        raf = 0;
        if (!s.held) {
          if (still) { s.y = 0; s.v = 0; }
          else {
            // A stiff, lightly damped spring: a quick, fading wobble.
            s.v = (s.v - s.y * 0.32) * 0.93;
            s.y += s.v;
          }
        }
        shape();
        if (s.held || Math.abs(s.y) + Math.abs(s.v) > 0.05) raf = requestAnimationFrame(frame);
        else { s.y = 0; s.v = 0; shape(); }
      }
      function wake() { if (!raf) raf = requestAnimationFrame(frame); }

      function release() {
        if (!s.held) return;
        s.held = false;
        s.v = 0;
        wake();
      }

      // Follow a pointer at (px, py) relative to the line.
      function feel(px, py) {
        var inRange = px > 0 && px < w;
        if (s.held) {
          if (!inRange || Math.abs(py) > pull) { release(); }
          else { s.x = px; s.y = py; wake(); }
        } else if (inRange && prev && prev.x > 0 && prev.x < w &&
                   ((prev.y < 0) !== (py < 0) || Math.abs(py) < 1) && Math.abs(py - prev.y) < pull * 2) {
          // Crossed the line: catch it.
          s.held = true; s.x = px; s.y = py; wake();
        }
        prev = { x: px, y: py };
      }

      function local(e) {
        var b = el.getBoundingClientRect();
        return { x: e.clientX - b.left, y: e.clientY - (b.top + b.height / 2) };
      }
      function move(e) {
        if (!visible) return;
        if (e.pointerType !== 'touch') near = true;
        var p = local(e);
        feel(p.x, p.y);
        if (e.pointerType !== 'touch') lastReal = Date.now();
      }
      var lastReal = 0;
      document.addEventListener('pointermove', move, { passive: true });
      document.addEventListener('pointerup', function () { if (s.held) release(); prev = null; });
      // A tap near the line plucks it where you tapped.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType === 'mouse') return;
        var p = local(e);
        pluck(p.x, p.y < 0 ? -pull * 0.7 : pull * 0.7);
      });
      document.addEventListener('pointerleave', function () { release(); prev = null; });

      function pluck(x, y) {
        if (still) return;
        s.held = false; s.x = x; s.y = y; s.v = 0;
        wake();
      }

      // Autoplay: a pretend fingertip drags across the line and lets go.
      function autoPluck() {
        var wait = 1800 + Math.random() * 2200;
        if (visible && Date.now() - lastReal > 3000 && !s.held && typeof anime !== 'undefined') {
          var x = w * (0.2 + Math.random() * 0.6);
          var dir = Math.random() < 0.5 ? -1 : 1;
          var f = { y: -dir * 22 };
          var gone = false;
          tip.style.transform = 'translate(' + x + 'px,' + f.y + 'px)';
          tip.classList.add('psg-show');
          prev = { x: x, y: f.y };
          anime.animate(f, {
            y: dir * (pull + 10), duration: 900, ease: 'inOut(2)',
            onUpdate: function () {
              tip.style.transform = 'translate(' + x.toFixed(1) + 'px,' + f.y.toFixed(1) + 'px)';
              if (!gone) feel(x, f.y);
              if (s.held === false && Math.abs(f.y) > pull) gone = true;
            },
            onComplete: function () { tip.classList.remove('psg-show'); prev = null; },
          });
        }
        setTimeout(autoPluck, wait);
      }

      shape();
      new ResizeObserver(function () { shape(); }).observe(el);
      new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; }).observe(el);
      if (auto && !still) setTimeout(autoPluck, 500 + Math.random() * 1500);
    }

    function init() {
      document.querySelectorAll('[data-plucked-string-line]').forEach(function (el) {
        if (el._psg) return;
        el._psg = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-plucked-string-line></div>

<!-- a thick pink string with more give: -->
<div data-plucked-string-line data-color="#ec4899" data-width="3" data-pull="48"></div>