← Buttons

Wobbly Border Button

The button’s outline is drawn as a live line. Hover it and the line turns wavy and wobbles like jelly, ripples running round the edge, then calms back to a clean shape when you move away. On a phone a tap sets it wobbling for a moment.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-wobbly-border-button to a button. The script draws its outline, so it clears the button’s own border and background.

Options

Add any of these attributes to the same element.

data-colorColour of the outline and text. Default #6a0dad.
data-fillColour inside the outline. Default none.
data-amplitudeHow far the waves bulge, in px. Default 4.
data-radiusCorner radius in px. Default fully rounded.
data-autoplayIt wobbles on and off by itself while in view, until someone hovers it. Handy for demos.

Keyboard focus wobbles it too. The waves reach a few pixels outside the button, so leave it a little room. With reduced motion the outline stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Wobbly Border Button: the button's outline is drawn as a live line.
    // Hover it and the outline turns wavy and wobbles like jelly, the waves
    // rippling round the edge; move away and it calms back to a clean shape.
    // On touch screens a tap sets it wobbling for a moment. Keyboard focus
    // wobbles it too.
    // Usage: <button data-wobbly-border-button>Say hello</button>
    // Optional: data-color (outline and text colour, default #6a0dad),
    // data-fill (colour inside the outline, default none), data-amplitude
    // (how far the waves bulge, px, default 4), data-radius (corner radius,
    // px, default fully rounded), data-autoplay (it wobbles on and off by
    // itself while in view, until someone hovers it; handy for demos).
    var DEFAULTS = { color: '#6a0dad', fill: 'none', amplitude: 4, every: 1600 };
    var PAD = 10;

    var css =
      ':where(.wbb){font:inherit;font-weight:700;padding:.85em 1.8em;color:var(--wbb-c)}' +
      '.wbb{position:relative;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;gap:.4em;cursor:pointer;' +
      'border:0;background:none;white-space:nowrap;-webkit-tap-highlight-color:transparent}' +
      '.wbb:focus-visible{outline:none}' +
      '.wbb-svg{position:absolute;left:-' + PAD + 'px;top:-' + PAD + 'px;z-index:-1;max-width:none;overflow:visible;pointer-events:none}' +
      '.wbb-path{fill:var(--wbb-fill);stroke:var(--wbb-c);stroke-width:2;stroke-linejoin:round}' +
      '.wbb:focus-visible .wbb-path{stroke-width:3}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // A point s px along the outline of a w x h rounded rectangle (corner
    // radius r), clockwise from the top-left end of the top edge, with its
    // outward normal.
    function pointAt(s, w, h, r) {
      var a = w - 2 * r, b = h - 2 * r, q = Math.PI * r / 2;
      var segs = [a, q, b, q, a, q, b, q];
      for (var i = 0; i < 8; i++) {
        if (s <= segs[i] || i === 7) break;
        s -= segs[i];
      }
      var k = Math.min(1, segs[i] ? s / segs[i] : 0), ang;
      switch (i) {
        case 0: return { x: r + s, y: 0, nx: 0, ny: -1 };
        case 2: return { x: w, y: r + s, nx: 1, ny: 0 };
        case 4: return { x: w - r - s, y: h, nx: 0, ny: 1 };
        case 6: return { x: 0, y: h - r - s, nx: -1, ny: 0 };
      }
      // Corners: 1 top-right, 3 bottom-right, 5 bottom-left, 7 top-left.
      var c = [null, [w - r, r, -90], null, [w - r, h - r, 0], null, [r, h - r, 90], null, [r, r, 180]][i];
      ang = (c[2] + k * 90) * Math.PI / 180;
      return { x: c[0] + Math.cos(ang) * r, y: c[1] + Math.sin(ang) * r, nx: Math.cos(ang), ny: Math.sin(ang) };
    }

    function setup(el) {
      var d = el.dataset;
      var maxAmp = d.amplitude !== undefined && !isNaN(+d.amplitude) ? +d.amplitude : DEFAULTS.amplitude;
      el.classList.add('wbb');
      el.style.setProperty('--wbb-c', d.color || DEFAULTS.color);
      el.style.setProperty('--wbb-fill', d.fill || DEFAULTS.fill);
      var NS = 'http://www.w3.org/2000/svg';
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('class', 'wbb-svg');
      svg.setAttribute('aria-hidden', 'true');
      var path = document.createElementNS(NS, 'path');
      path.setAttribute('class', 'wbb-path');
      svg.appendChild(path);
      el.insertBefore(svg, el.firstChild);

      var w = 0, h = 0, r = 0, L = 0, n1 = 1, n2 = 1;
      var amp = 0, aim = 0, phase = 0, raf = 0, last = 0, visible = false;

      function measure() {
        w = el.offsetWidth; h = el.offsetHeight;
        var want = d.radius !== undefined ? +d.radius : h / 2;
        r = Math.max(0, Math.min(isNaN(want) ? h / 2 : want, w / 2, h / 2));
        L = 2 * (w - 2 * r) + 2 * (h - 2 * r) + 2 * Math.PI * r;
        // Whole numbers of waves, so the line meets itself without a seam.
        n1 = Math.max(3, Math.round(L / 46));
        n2 = Math.max(5, Math.round(L / 27));
        svg.setAttribute('width', w + PAD * 2);
        svg.setAttribute('height', h + PAD * 2);
        svg.style.width = w + PAD * 2 + 'px';
        svg.style.height = h + PAD * 2 + 'px';
        svg.setAttribute('viewBox', '0 0 ' + (w + PAD * 2) + ' ' + (h + PAD * 2));
        draw();
      }

      function draw() {
        if (!L) return;
        var n = Math.max(60, Math.round(L / 3)), out = [];
        for (var i = 0; i < n; i++) {
          var s = (i / n) * L, p = pointAt(s, w, h, r), u = (i / n) * Math.PI * 2;
          var off = amp * (0.62 * Math.sin(u * n1 + phase) + 0.38 * Math.sin(u * n2 - phase * 1.6));
          out.push((p.x + PAD + p.nx * off).toFixed(2) + ' ' + (p.y + PAD + p.ny * off).toFixed(2));
        }
        path.setAttribute('d', 'M' + out.join('L') + 'Z');
      }

      function frame(now) {
        var dt = Math.min(50, now - (last || now));
        last = now;
        phase += dt * 0.0062;
        amp += (aim - amp) * Math.min(1, dt * 0.009);
        draw();
        if (aim === 0 && amp < 0.03) { amp = 0; draw(); raf = 0; last = 0; return; }
        raf = requestAnimationFrame(frame);
      }

      function wobble(state) {
        aim = state && !reduced() ? maxAmp : 0;
        if (!raf && visible && (aim || amp)) raf = requestAnimationFrame(frame);
      }

      measure();
      if (window.ResizeObserver) new ResizeObserver(measure).observe(el);

      var userTouched = false, touchTimer = null;
      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        userTouched = true;
        wobble(true);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        wobble(false);
      });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        userTouched = true;
        clearTimeout(touchTimer);
        wobble(true);
        touchTimer = setTimeout(function () { wobble(false); }, 1300);
      });
      el.addEventListener('focus', function () { if (el.matches(':focus-visible')) wobble(true); });
      el.addEventListener('blur', function () { if (!el.matches(':hover')) wobble(false); });

      var auto = d.autoplay !== undefined && d.autoplay !== 'false' && !reduced();
      var timer = null;
      function loop() {
        if (userTouched) return;
        wobble(!aim);
        timer = setTimeout(loop, aim ? DEFAULTS.every : DEFAULTS.every * 0.8);
      }
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (!visible) { cancelAnimationFrame(raf); raf = 0; last = 0; return; }
        measure();
        wobble(aim > 0);
        if (auto) timer = setTimeout(loop, 600);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-wobbly-border-button]').forEach(function (el) {
        if (el._wbb) return;
        el._wbb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-wobbly-border-button>Say hello</button>

<!-- filled, with square-ish corners: -->
<button data-wobbly-border-button data-fill="#fde68a" data-color="#1f2937" data-radius="14">Hover me</button>