Tailwind UI Pattern Registry for humans and agents

form validation error shake spring feedback accessible motion-one aria motion-one

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.

Motion One submitblur
Live Preview

Dependencies

Motion v11.11.13
HTML
<!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.