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
- Paste the snippet just before
</head>. - Add
data-wobbly-border-buttonto 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-color | Colour of the outline and text. Default #6a0dad. |
data-fill | Colour inside the outline. Default none. |
data-amplitude | How far the waves bulge, in px. Default 4. |
data-radius | Corner radius in px. Default fully rounded. |
data-autoplay | It 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.
<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><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>