Runaway Button
A button that won’t be caught. As the pointer gets close, it hops to a new spot inside its box, away from the pointer, so it can never quite be clicked. Made for playful “No” buttons.
Pineapple on pizza?
How to use it
- Paste the snippet just before
</head>. - Add
data-runaway-buttonto the button that should run away. - It stays inside its parent element. To give it more room, add
data-runaway-areato a bigger element round it.
Options
Add any of these attributes to the same element.
data-distance | How close the pointer can get before the button runs, in px. Default 60. |
data-tries | Give up and stay put after this many dodges. Default 0, never give up. |
data-duration | How long each hop takes. Default 450 ms. |
data-autoplay | A pretend pointer chases the button while a real one isn’t in the area. Handy for demos. |
On touch screens the button slips away as you tap it. It only runs from pointers, so keyboard users can still tab to it and press it. With reduced motion turned on, it jumps to its new spot instead of gliding.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Runaway Button: a button that dodges the pointer. As the pointer gets
// close it hops to a new spot inside its area, away from the pointer, so
// it can't be clicked. Made for playful "No" buttons.
// Usage: <div class="area">
// <button>Yes</button>
// <button data-runaway-button>No</button>
// </div>
// The button stays inside its parent element, or inside the closest
// [data-runaway-area] if there is one.
// Optional: data-distance (how close the pointer may get before it runs,
// px, default 60), data-tries (give up and stay put after this many
// dodges; default 0, never), data-duration (length of a hop, ms, default
// 450), data-autoplay (a pretend pointer chases it while a real one isn't
// in the area; handy for demos).
// Keyboard users can still focus and press it: it only runs from pointers.
var DEFAULTS = { distance: 60, tries: 0, duration: 450 };
var css =
'.rnb{position:relative;z-index:2;will-change:transform;touch-action:manipulation}' +
'.rnb-area{position:relative}' +
'.rnb-hand{position:absolute;left:0;top:0;z-index:3;width:22px;height:22px;pointer-events:none;opacity:0;' +
'transition:opacity .3s;filter:drop-shadow(0 2px 3px rgba(0,0,0,.3))}' +
'.rnb-hand.rnb-show{opacity:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var HAND = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 2 L4 19 L8.6 14.8 L11.6 21.4 L14.6 20 L11.7 13.6 L18 13.2 Z" fill="#fff" stroke="#111" stroke-width="1.6" stroke-linejoin="round"/></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(btn) {
var d = btn.dataset;
var near = num(d.distance, DEFAULTS.distance);
var tries = num(d.tries, DEFAULTS.tries);
var dur = num(d.duration, DEFAULTS.duration);
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
var still = reduced();
var area = btn.closest('[data-runaway-area]') || btn.parentElement;
btn.classList.add('rnb');
area.classList.add('rnb-area');
var off = { x: 0, y: 0 }, dodges = 0, lastHop = 0, anim = null, inside = false, visible = false;
function place(x, y, fast) {
off.x = x; off.y = y;
if (anim) anim.pause();
if (typeof anime === 'undefined') {
btn.style.transform = 'translate(' + x + 'px,' + y + 'px)';
return;
}
if (still || fast) { anime.utils.set(btn, { x: x, y: y }); return; }
anim = anime.animate(btn, { x: x, y: y, duration: dur, ease: 'out(4)' });
}
// Pick a new spot inside the area, as far from the pointer as we can.
function flee(px, py) {
if (tries && dodges >= tries) return false;
var now = Date.now();
if (now - lastHop < dur * 0.5) return true;
lastHop = now;
dodges++;
var a = area.getBoundingClientRect(), b = btn.getBoundingClientRect();
var pad = 8;
// Where the button sits with no offset.
var homeX = b.left - off.x, homeY = b.top - off.y;
var minX = a.left + pad - homeX, maxX = a.right - pad - b.width - homeX;
var minY = a.top + pad - homeY, maxY = a.bottom - pad - b.height - homeY;
if (maxX < minX) maxX = minX = (minX + maxX) / 2;
if (maxY < minY) maxY = minY = (minY + maxY) / 2;
var best = null, bestScore = -1;
for (var i = 0; i < 16; i++) {
var x = minX + Math.random() * (maxX - minX);
var y = minY + Math.random() * (maxY - minY);
var cx = homeX + x + b.width / 2, cy = homeY + y + b.height / 2;
var dp = Math.hypot(cx - px, cy - py);
var hop = Math.hypot(x - off.x, y - off.y);
// Far from the pointer, but not a jump clean across the area every time.
var score = Math.min(dp, near * 3 + b.width) + Math.random() * 20 - Math.max(0, hop - a.width * 0.6) * 0.3;
if (score > bestScore) { bestScore = score; best = { x: x, y: y }; }
}
place(Math.round(best.x), Math.round(best.y));
return true;
}
function tooClose(px, py) {
var b = btn.getBoundingClientRect();
var dx = Math.max(b.left - px, 0, px - b.right);
var dy = Math.max(b.top - py, 0, py - b.bottom);
return Math.hypot(dx, dy) < near;
}
area.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
inside = true;
if (tooClose(e.clientX, e.clientY)) flee(e.clientX, e.clientY);
});
area.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') inside = true; });
area.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') inside = false; });
// Touch: it slips away from your finger as you go to tap it.
btn.addEventListener('pointerdown', function (e) {
if (flee(e.clientX, e.clientY)) { btn._rnbBlock = Date.now(); e.preventDefault(); }
});
btn.addEventListener('click', function (e) {
if (btn._rnbBlock && Date.now() - btn._rnbBlock < 800) { e.preventDefault(); e.stopImmediatePropagation(); }
}, true);
var reset = 0;
new ResizeObserver(function () {
clearTimeout(reset);
reset = setTimeout(function () { place(0, 0, true); }, 100);
}).observe(area);
// Autoplay: a pretend pointer creeps up on the button again and again.
if (auto && !still && typeof anime !== 'undefined') {
var hand = document.createElement('span');
hand.className = 'rnb-hand';
hand.setAttribute('aria-hidden', 'true');
hand.innerHTML = HAND;
area.appendChild(hand);
var hp = { x: 0, y: 0 }, chase = null;
var setHand = function () { hand.style.transform = 'translate(' + hp.x + 'px,' + hp.y + 'px)'; };
var step = function () {
if (!visible || inside || (tries && dodges >= tries)) {
hand.classList.remove('rnb-show');
setTimeout(step, 600);
return;
}
var a = area.getBoundingClientRect(), b = btn.getBoundingClientRect();
if (!hand.classList.contains('rnb-show')) {
hp.x = a.width * (Math.random() < 0.5 ? 0.15 : 0.85);
hp.y = a.height * 0.85;
setHand();
hand.classList.add('rnb-show');
}
// Aim a little past the middle of the button.
var tx = b.left - a.left + b.width * (0.3 + Math.random() * 0.4);
var ty = b.top - a.top + b.height * 0.6;
chase = anime.animate(hp, {
x: tx, y: ty, duration: 900 + Math.random() * 500, ease: 'inOut(2)',
onUpdate: function () {
setHand();
var r = area.getBoundingClientRect();
var ax = r.left + hp.x, ay = r.top + hp.y;
if (tooClose(ax, ay) && flee(ax, ay)) { chase.pause(); setTimeout(step, 380 + Math.random() * 300); }
},
onComplete: function () { setTimeout(step, 300); },
});
};
new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; }).observe(area);
setTimeout(step, 700);
}
}
function init() {
document.querySelectorAll('[data-runaway-button]').forEach(function (el) {
if (el._rnb) return;
el._rnb = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="question" data-runaway-area>
<p>Pineapple on pizza?</p>
<button>Yes, please</button>
<button data-runaway-button>No</button>
</div>
<!-- lets itself be caught after five dodges: -->
<button data-runaway-button data-tries="5">No</button>