Error Shake
Submit a form with a field that isn’t right and the field shakes its head, a quick sideways shake that dies away, while its border turns red and the message slides in underneath. Fix it and the message goes as you type. Try it: that email is missing its domain.
How to use it
- Paste the snippet just before
</head>. - Add
data-error-shaketo a form. - Use the browser’s own checks on its fields:
required,type="email",pattern,minlengthand so on.
Options
Add any of these attributes to the same element.
data-color | Error colour. Default #dc2626. |
data-message | On a field: your own wording for its error. Default: the browser’s. |
data-autoplay | It shakes the first field every few seconds while in view. Handy for demos. |
Invalid submits are cancelled, and the fields get aria-invalid and a linked message. From code, animacious.shake(element) shakes anything.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Error Shake: submit a form with a field that isn't right and the field
// shakes its head, a quick sideways shake that dies away, while its border
// turns red and the error message slides in underneath. Fix it and the
// message goes as soon as it's valid. It uses the browser's own checks
// (required, type="email", pattern, minlength…), so there's nothing to set up.
// Usage:
// <form data-error-shake>
// <input type="email" required placeholder="[email protected]">
// <button>Subscribe</button>
// </form>
// Optional: data-color (error colour, default #dc2626), data-autoplay (it
// shakes the first field every few seconds while in view; handy for demos).
// On a field: data-message (your own wording; default: the browser's).
// Script use: animacious.shake(element) shakes anything.
var DEFAULTS = { color: '#dc2626', every: 3200 };
var count = 0;
var css =
'.esh-msg{margin:0.45em 0 0;font-size:0.85em;font-weight:500;line-height:1.3;color:var(--esh-color);text-align:left}' +
'.esh-msg[hidden]{display:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };
function shake(el) {
if (reduced()) return;
var w = Math.min(14, Math.max(6, el.offsetWidth * 0.04));
// Big at first, then smaller and smaller, like a head shaking no.
anime.animate(el, {
x: [{ to: -w }, { to: w * 0.8 }, { to: -w * 0.6 }, { to: w * 0.4 }, { to: -w * 0.2 }, { to: 0 }],
duration: 520,
ease: 'inOut(2)'
});
}
function setup(form) {
var d = form.dataset;
var color = d.color || DEFAULTS.color;
form.style.setProperty('--esh-color', color);
if (form.tagName === 'FORM') form.noValidate = true; // we show the errors instead
var fields = Array.from(form.querySelectorAll('input, textarea, select, [data-field]')).filter(function (f) {
return !/^(submit|button|reset|hidden|image)$/.test(f.type);
});
fields.forEach(function (f) {
var msg = document.createElement('p');
msg.className = 'esh-msg';
msg.id = 'esh-msg-' + ++count;
msg.hidden = true;
f.after(msg);
f._esh = { msg: msg, border: getComputedStyle(f).borderColor, shown: false };
if (f.matches('input, textarea, select')) {
f.setAttribute('aria-describedby', ((f.getAttribute('aria-describedby') || '') + ' ' + msg.id).trim());
// Once it has complained, it checks again as you type.
f.addEventListener('input', function () { if (f._esh.shown && f.checkValidity()) clear(f); });
}
});
function show(f) {
var s = f._esh;
s.msg.textContent = f.dataset.message || f.validationMessage || 'Please check this field.';
f.setAttribute('aria-invalid', 'true');
shake(f);
if (s.shown) return;
s.shown = true;
s.msg.hidden = false;
var dur = reduced() ? 0 : 1;
anime.animate(f, { borderColor: color, duration: 200 * dur });
anime.animate(s.msg, { opacity: [0, 1], y: ['-0.4em', '0em'], duration: 350 * dur, delay: 120 * dur, ease: 'out(3)' });
}
function clear(f) {
var s = f._esh;
if (!s.shown) return;
s.shown = false;
f.removeAttribute('aria-invalid');
var dur = reduced() ? 0 : 1;
anime.animate(f, { borderColor: s.border, duration: 250 * dur, onComplete: function () { if (!s.shown) f.style.borderColor = ''; } });
anime.animate(s.msg, { opacity: 0, duration: 200 * dur, onComplete: function () { if (!s.shown) s.msg.hidden = true; } });
}
form.addEventListener('submit', function (e) {
var bad = fields.filter(function (f) { return f.checkValidity && !f.checkValidity(); });
fields.forEach(function (f) { if (bad.indexOf(f) < 0) clear(f); });
if (!bad.length) return;
// Cancelled: your own submit handler can check e.defaultPrevented.
e.preventDefault();
bad.forEach(function (f, i) { setTimeout(function () { show(f); }, i * 80); });
bad[0].focus();
});
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced() && fields.length) {
var timer = null;
var f = fields[0];
var run = function () {
if (!form.matches(':hover') && !form.contains(document.activeElement)) {
show(f);
setTimeout(function () { clear(f); }, DEFAULTS.every * 0.55);
}
timer = setTimeout(run, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(run, 600);
}).observe(form);
}
}
function init() {
document.querySelectorAll('[data-error-shake]').forEach(function (form) {
if (form._esh) return;
form._esh = true;
setup(form);
});
}
window.animacious = window.animacious || {};
window.animacious.shake = shake;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><form data-error-shake>
<input type="email" required placeholder="[email protected]">
<button>Subscribe</button>
</form>