Form Error Shake
Form Error Shake
Validation error triggers a horizontal spring shake — a physical rejection metaphor. The oscillation uses Motion One's keyframe array with 8 x-values that dampen to zero.
Dependencies
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Form Error Shake — Motion Recipe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
</head>
<body class="bg-slate-950 text-slate-200 font-sans">
<div class="mx-auto max-w-3xl px-8 pt-20 pb-32">
<span class="mb-6 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Form Error Shake</span>
<h1 class="mb-4 text-4xl font-black leading-tight text-slate-100">Validation with<br>spring feedback.</h1>
<p class="mb-12 max-w-sm text-base leading-relaxed text-slate-400">Invalid submission triggers a horizontal shake — a physical rejection metaphor. The oscillation dampens like a spring, not a loop. Submit empty fields to see it.</p>
<!-- Demo 1: Login form -->
<div class="mb-16">
<p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Login</p>
<form id="login-form" class="flex flex-col gap-3 max-w-sm" novalidate>
<div id="email-field">
<label for="login-email" class="mb-1.5 block text-sm font-medium text-slate-300">Email</label>
<input id="login-email" type="email" placeholder="you@example.com" autocomplete="email"
class="w-full rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 transition-colors focus:border-violet-500 focus:outline-none"
aria-describedby="email-error">
<p id="email-error" class="mt-1.5 hidden text-xs text-red-400" role="alert" aria-live="assertive"></p>
</div>
<div id="password-field">
<label for="login-pw" class="mb-1.5 block text-sm font-medium text-slate-300">Password</label>
<input id="login-pw" type="password" placeholder="••••••••" autocomplete="current-password"
class="w-full rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 transition-colors focus:border-violet-500 focus:outline-none"
aria-describedby="pw-error">
<p id="pw-error" class="mt-1.5 hidden text-xs text-red-400" role="alert" aria-live="assertive"></p>
</div>
<button type="submit"
class="mt-2 rounded-xl bg-violet-600 py-3 font-semibold text-white transition-colors hover:bg-violet-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Sign in
</button>
</form>
</div>
<!-- Demo 2: Single field inline -->
<div>
<p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter — inline</p>
<form id="nl-form" class="flex gap-3 max-w-md items-start" novalidate>
<div class="flex-1">
<label for="nl-email" class="sr-only">Email address</label>
<input id="nl-email" type="email" placeholder="your@email.com"
class="w-full rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 transition-colors focus:border-violet-500 focus:outline-none"
aria-describedby="nl-error">
<p id="nl-error" class="mt-1.5 hidden text-xs text-red-400" role="alert" aria-live="assertive"></p>
</div>
<button type="submit"
class="shrink-0 rounded-xl bg-violet-600 px-5 py-3 font-semibold text-white text-sm transition-colors hover:bg-violet-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Subscribe
</button>
</form>
</div>
</div>
<script>
var { animate } = Motion;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function shake(el) {
if (reduced) {
// No motion — just flash border
el.classList.add('border-red-500');
setTimeout(function() { el.classList.remove('border-red-500'); }, 1000);
return;
}
// Dampened spring oscillation: overshoot → reduce → settle
animate(el,
{ x: [0, -10, 9, -7, 5, -3, 2, -1, 0] },
{ duration: 0.55, easing: 'ease-out' }
);
}
function markInvalid(input, errorEl, message) {
input.classList.add('border-red-500');
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
shake(input);
}
function markValid(input, errorEl) {
input.classList.remove('border-red-500');
input.removeAttribute('aria-invalid');
errorEl.classList.add('hidden');
}
// Login form
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
var email = document.getElementById('login-email');
var pw = document.getElementById('login-pw');
var emailErr = document.getElementById('email-error');
var pwErr = document.getElementById('pw-error');
var ok = true;
if (!email.value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
markInvalid(email, emailErr, 'Please enter a valid email address.');
ok = false;
} else {
markValid(email, emailErr);
}
if (!pw.value || pw.value.length < 6) {
// Stagger the second shake slightly
setTimeout(function() { markInvalid(pw, pwErr, 'Password must be at least 6 characters.'); }, ok ? 0 : 60);
ok = false;
} else {
markValid(pw, pwErr);
}
});
// Clear error on input
['login-email','login-pw'].forEach(function(id) {
var el = document.getElementById(id);
el.addEventListener('input', function() {
el.classList.remove('border-red-500');
el.removeAttribute('aria-invalid');
});
});
// Newsletter form
document.getElementById('nl-form').addEventListener('submit', function(e) {
e.preventDefault();
var email = document.getElementById('nl-email');
var err = document.getElementById('nl-error');
if (!email.value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
markInvalid(email, err, 'Enter a valid email.');
} else {
markValid(email, err);
// Success: animate button to green
var btn = e.target.querySelector('button');
btn.textContent = 'Done!';
btn.classList.replace('bg-violet-600','bg-emerald-600');
setTimeout(function() {
btn.textContent = 'Subscribe';
btn.classList.replace('bg-emerald-600','bg-violet-600');
email.value = '';
}, 1800);
}
});
document.getElementById('nl-email').addEventListener('input', function() {
this.classList.remove('border-red-500');
this.removeAttribute('aria-invalid');
});
</script>
</body>
</html>
The shake pattern is { x: [0, -10, 9, -7, 5, -3, 2, -1, 0] } — decreasing amplitude oscillation that reads as a spring naturally settling rather than a looping animation. easing: 'ease-out' applied globally ensures each keyframe transition decelerates.
Multiple invalid fields shake with a 60ms offset between them — enough to read as separate events rather than simultaneous jitter.
Accessibility: Each input has aria-describedby pointing to its error element. The error paragraph uses role="alert" with aria-live="assertive" so screen readers announce the message immediately on error. aria-invalid="true" is set on the input when validation fails and removed on fix.
prefers-reduced-motion replaces the shake with a brief border-red-500 flash — same information, no motion. The error message still appears and all aria-* attributes still update, so the interaction is fully accessible in reduced-motion mode.
When a field becomes valid again, aria-invalid is removed and the error element is hidden via classList.add('hidden') — which also removes it from the accessibility tree.