You have probably clicked a disabled submit button and wondered why nothing happened. This button answers that question in a sillier way: until the form is valid, it runs away from your pointer. Fill in a proper email address and it walks back to its spot and behaves.
How it works
- Check the form. On every keystroke the script asks the browser whether the email field is valid with
checkValidity(). No regular expressions to maintain. - Flee from the pointer. When the pointer enters the button while the form is invalid, the script measures the direction from the pointer to the centre of the button and moves the button further along that line, by roughly its own width.
- Stay in the box. The button lives inside a zone. If a jump would leave the zone, it hops to a random spot on the other side instead, so it can never escape off the screen.
- Explain itself. Every escape also writes a short message into a live region, so the reason is read aloud by screen readers and visible to everyone else.
- Come home. The moment the email is valid, the button slides back to the middle and submits normally.
The movement itself is a CSS transition on transform with a slightly springy curve, so the button seems to dodge rather than teleport.
The code
<form class="nb" data-lab="naughty-button" novalidate>
<label for="nb-email">Your email</label>
<input id="nb-email" type="email" placeholder="you@example.com" autocomplete="off" required>
<div class="nb-zone">
<button class="nb-btn" type="submit">Send</button>
</div>
<p class="nb-msg" aria-live="polite"></p>
</form>.nb {
display: grid;
gap: 10px;
width: min(100%, 380px);
font-family: system-ui, sans-serif;
}
.nb label { font-weight: 600; font-size: 14px; color: #101a14; }
.nb input {
height: 46px;
padding: 0 14px;
border: 1px solid #c9cec4;
border-radius: 10px;
font: 400 16px/1 system-ui, sans-serif;
}
.nb input:focus { outline: 2px solid #133621; outline-offset: 2px; }
.nb-zone {
position: relative;
height: 130px;
display: flex;
align-items: center;
justify-content: center;
}
.nb-btn {
height: 46px;
padding: 0 28px;
border: 0;
border-radius: 999px;
background: #133621;
color: #f4d96c;
font: 600 15px/1 system-ui, sans-serif;
cursor: pointer;
transition: transform .35s cubic-bezier(.34, 1.56, .64, 1), opacity .2s;
}
.nb-btn[aria-disabled="true"] { opacity: .75; cursor: not-allowed; }
.nb-msg { min-height: 1.4em; margin: 0; font-size: 14px; color: #46534a; text-align: center; }
@media (prefers-reduced-motion: reduce) { .nb-btn { transition: none; } }(function () {
var form = document.querySelector('[data-lab="naughty-button"]');
if (!form) return;
var input = form.querySelector('input');
var btn = form.querySelector('.nb-btn');
var zone = form.querySelector('.nb-zone');
var msg = form.querySelector('.nb-msg');
var x = 0, y = 0;
function valid() { return input.value.trim() !== '' && input.checkValidity(); }
function place(nx, ny) {
x = nx; y = ny;
btn.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
}
// Run away from a point (the pointer, or the spot the reader pressed).
function flee(px, py) {
var z = zone.getBoundingClientRect();
var b = btn.getBoundingClientRect();
var dx = b.left + b.width / 2 - px;
var dy = b.top + b.height / 2 - py;
var len = Math.hypot(dx, dy) || 1;
var step = b.width * (0.8 + Math.random() * 0.5);
var nx = x + dx / len * step;
var ny = y + dy / len * step * 0.6;
var maxX = (z.width - b.width) / 2;
var maxY = (z.height - b.height) / 2;
// Cornered? Jump to a random spot on the other side.
if (Math.abs(nx) > maxX || Math.abs(ny) > maxY) {
nx = -Math.sign(nx || 1) * maxX * Math.random();
ny = (Math.random() - 0.5) * maxY * 2;
}
place(nx, ny);
msg.textContent = 'Not yet. Enter a valid email first.';
}
function update() {
if (valid()) {
btn.removeAttribute('aria-disabled');
place(0, 0);
msg.textContent = '';
} else {
btn.setAttribute('aria-disabled', 'true');
}
}
btn.addEventListener('pointerenter', function (e) {
if (!valid()) flee(e.clientX, e.clientY);
});
form.addEventListener('submit', function (e) {
e.preventDefault();
if (!valid()) {
var b = btn.getBoundingClientRect();
flee(b.left + b.width / 2 + (Math.random() - 0.5) * 20, b.bottom);
input.focus();
return;
}
msg.textContent = 'Sent. This demo does not store anything.';
});
input.addEventListener('input', update);
update();
})();When to use it, and when not to
This is a wink, not a pattern. It works on a playful page where a little friction is welcome: a contest entry, a joke page for April Fools, an easter egg in a portfolio. It gets a laugh precisely because people expect buttons to sit still.
Never put it on a form that matters to someone's day: payments, bookings, support requests or anything a customer fills in while already annoyed. Touch screens make it worse, because a tap is also the moment the button jumps. On those forms, keep the button in place and show a clear message next to the field that needs fixing.
Keyboard users are covered here. Pressing Enter on an invalid form makes the button hop, moves focus back to the email field and announces what is missing, so nobody is stuck.
Make it yours
- How far it runs: the
0.8 + Math.random() * 0.5factor sets the jump to between 0.8 and 1.3 button widths. - Arena size: make
.nb-zonetaller or wider to give it more room to roam. - Any rule: replace
valid()with your own check, such as a ticked box or a minimum message length.
Frequently asked questions
Is the button disabled for screen readers?
It uses aria-disabled="true" rather than the disabled attribute, so it stays focusable and can explain why it will not submit yet.
Does it work without JavaScript?
Yes. Without the script it is a normal form with a normal button, and the browser's own email validation still applies.
Why does it sometimes jump to the opposite side?
When it is cornered at the edge of its zone, it jumps somewhere random on the other side instead of leaving the box.


