← Buttons

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

  1. Paste the snippet just before </head>.
  2. Add data-error-shake to a form.
  3. Use the browser’s own checks on its fields: required, type="email", pattern, minlength and so on.

Options

Add any of these attributes to the same element.

data-colorError colour. Default #dc2626.
data-messageOn a field: your own wording for its error. Default: the browser’s.
data-autoplayIt 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.

1 · Snippet
<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>
2 · Your HTML
<form data-error-shake>
  <input type="email" required placeholder="[email protected]">
  <button>Subscribe</button>
</form>