An Accessible Error Shake Animation That Respects Reduced Motion

A shake animation is a fast, pre-attentive way to draw the eye to a rejected field, but it becomes an accessibility liability the moment it ignores the user’s prefers-reduced-motion setting or fires without an accompanying accessible error messaging update; this page shows how to trigger a CSS shake from a single reportValidity() pass while degrading to a motionless, still-announced state for anyone who has asked the operating system to reduce motion.

When to Use This Reduced-Motion Shake Approach

Reach for a shake when a submit attempt fails and you want a momentary attention cue in addition to a persistent text error — never as the only signal. If your priority is routing the caret to the first bad field, read Managing Focus After Validation Failure instead; if you need a dismissible surface for server-side outcomes, see Designing Accessible Error Toast Notifications.

  • Use it for on-submit feedback, where a discrete failure event maps cleanly to a single animation trigger.
  • Avoid it for keystroke-by-keystroke validation, where repeated shaking is nauseating and noisy.
  • Pair it always with a visible message and an ARIA live update so the cue is not purely visual.
  • Honour prefers-reduced-motion: reduce by swapping motion for a static colour/border change.
Reduced-motion shake decision flow A submit fails validation, the code checks the reduced-motion preference, then plays either a shake or a static cue. One reportValidity() call drives the whole cue Invalid submit reportValidity() = false Check preference prefers-reduced-motion Shake or static plus live announcement
The media query gates the motion; the text announcement fires either way.

A matchMedia + animationend Shake Bound to reportValidity()

The baseline is the site standard: a <form novalidate> element whose validity is checked with one manual Constraint Validation API call on submit. When that call returns false, we add a class that triggers the shake, then remove it on animationend so it can replay on the next failure.

<form id="signup" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required aria-describedby="email-error" />
  <p id="email-error" class="field-error" role="alert" hidden></p>
  <button type="submit">Create account</button>
</form>
@keyframes field-shake {
  0%, 100% { transform: translateX(0); }
  20%, 60% { transform: translateX(-6px); }
  40%, 80% { transform: translateX(6px); }
}
.is-shaking {
  animation: field-shake 320ms ease-in-out;
}
/* Motion is opt-out: anyone requesting reduced motion never sees translateX. */
@media (prefers-reduced-motion: reduce) {
  .is-shaking {
    animation: none;
    outline: 2px solid #b91c1c; /* static, instantaneous substitute */
  }
}
const form = document.querySelector<HTMLFormElement>('#signup')!;
const email = document.querySelector<HTMLInputElement>('#email')!;
const errorEl = document.querySelector<HTMLParagraphElement>('#email-error')!;

// Query the OS preference once; matchMedia stays live if the user changes it.
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function shake(el: HTMLElement): void {
  // Removing then forcing reflow lets the same class re-trigger the keyframes.
  el.classList.remove('is-shaking');
  void el.offsetWidth; // reflow: flushes the class removal before re-adding
  el.classList.add('is-shaking');
  // With reduced motion, animationend never fires, so clean up on a timer too.
  const clear = () => el.classList.remove('is-shaking');
  el.addEventListener('animationend', clear, { once: true });
  if (reduceMotion.matches) window.setTimeout(clear, 1200);
}

form.addEventListener('submit', (event) => {
  // Single manual Constraint Validation pass — the house baseline.
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity(); // surfaces the native bubble at the first invalid field

    // Mirror the native message into our own live region for reliable SR output.
    errorEl.textContent = email.validationMessage;
    errorEl.hidden = false;
    shake(email); // visual cue is additive, never the sole signal
  }
});

// Clear the error state once the user corrects the field.
email.addEventListener('input', () => {
  if (email.checkValidity()) {
    errorEl.hidden = true;
    errorEl.textContent = '';
  }
});

Shake field-shake Keyframe & Trigger Reference

Option Type Default Purpose
field-shake duration CSS <time> 320ms Total animation length; keep under ~400ms so it reads as a flick, not a wobble.
Peak translateX CSS <length> 6px Horizontal displacement amplitude; 4–8px is legible without being violent.
animation-timing-function CSS keyword ease-in-out Softens the direction reversals so the motion is not jarring.
prefers-reduced-motion branch media query reduce Replaces animation with a static outline/border cue.
.is-shaking reflow void el.offsetWidth required Forces a reflow so the class can re-trigger on repeat failures.
Fallback timer number (ms) 1200 Clears the class when animationend cannot fire (motion disabled).
role="alert" region ARIA attribute on #email-error Announces the message to assistive tech independently of the shake.

Verification Steps

import { test, expect } from '@playwright/test';

test('shake is suppressed under reduced motion but error still shows', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/signup');
  await page.getByRole('button', { name: 'Create account' }).click();

  const field = page.locator('#email');
  await expect(field).toHaveClass(/is-shaking/); // class still applied
  const animName = await field.evaluate((el) => getComputedStyle(el).animationName);
  expect(animName).toBe('none'); // ...but keyframes are disabled
  await expect(page.locator('#email-error')).toBeVisible();
});

Edge Cases & Failure Modes

The class re-adds but never re-animates. Adding .is-shaking while it is already present is a no-op — the browser sees no state change. Forcing a reflow with void el.offsetWidth between the remove and add calls flushes the style so the keyframes restart. Without it, the second and subsequent failed submits shake nothing.

animationend never fires under reduced motion. When animation: none is in effect the event never dispatches, so a listener registered with { once: true } leaks the .is-shaking class forever. The setTimeout fallback guarantees cleanup regardless of whether motion is disabled at the OS level or per-field.

The shake is the only signal. If you drop the role="alert" region and rely on motion alone, non-sighted users and anyone with reduced motion get nothing. Always mirror the native validationMessage — or your own custom validity messages — into a live region so the cue degrades gracefully.

Driving the Shake From the Web Animations API Instead of a Class

The class-plus-reflow approach is the most portable, but the forced void el.offsetWidth reflow is a code smell: it works by exploiting a browser side effect rather than expressing intent. The Web Animations API (WAAPI) removes the reflow entirely because each Element.animate() call creates a fresh Animation object with its own timeline, so consecutive calls never collide the way a re-added class does. It also hands you a Promise (animation.finished) and lets you read the live motion preference at trigger time rather than binding to a stale value.

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

async function shakeField(el: HTMLElement): Promise<void> {
  // Reduced-motion users get an instantaneous outline, no keyframes at all.
  if (reduceMotion.matches) {
    el.style.outline = '2px solid #b91c1c';
    window.setTimeout(() => { el.style.outline = ''; }, 1200);
    return;
  }

  const animation = el.animate(
    [
      { transform: 'translateX(0)' },
      { transform: 'translateX(-6px)', offset: 0.2 },
      { transform: 'translateX(6px)', offset: 0.4 },
      { transform: 'translateX(-6px)', offset: 0.6 },
      { transform: 'translateX(6px)', offset: 0.8 },
      { transform: 'translateX(0)' },
    ],
    { duration: 320, easing: 'ease-in-out' },
  );

  // `finished` rejects if the animation is cancelled; swallow that quietly.
  try {
    await animation.finished;
  } catch {
    /* superseded by a newer shake — nothing to clean up */
  }
}

Because reduceMotion.matches is read inside the function, a user who flips the OS toggle between two submit attempts gets the correct behaviour on the very next failure — no change-event wiring required. If you do want the CSS outline fallback from the earlier example to update mid-session, attach reduceMotion.addEventListener('change', ...); the media query object stays live for the life of the document.

Shaking Every Invalid Field in One Pass

reportValidity() only bubbles the first invalid control, but on a long form you often want to flag all of them at once. Iterate form.elements, collect the failures, shake each, and reserve focus and the native bubble for the first. Stagger the animations slightly so the group reads as a wave rather than a single jarring lurch — and skip the stagger entirely under reduced motion, where there is nothing to stagger.

function shakeAllInvalid(form: HTMLFormElement): void {
  const invalid = Array.from(form.elements).filter(
    (el): el is HTMLInputElement =>
      el instanceof HTMLInputElement && !el.checkValidity(),
  );

  invalid.forEach((field, index) => {
    // Stagger by 60ms per field for a cascade; 0 when motion is reduced.
    const delay = reduceMotion.matches ? 0 : index * 60;
    window.setTimeout(() => shakeField(field), delay);
  });

  invalid[0]?.focus(); // keyboard users land on the first problem
}

Keep the per-field text errors as the authoritative record: the cascade tells a sighted user where to look, while each field’s role="alert" region tells every user what is wrong. Never let the number of shakes stand in for the number of messages — a screen-reader user perceives none of the motion and must get the full accounting from the live regions alone.

Frequently Asked Questions

Should the shake fire on every keystroke or only on submit?

On submit. A shake maps to a discrete rejection event, and firing it per keystroke is disorienting and quickly annoying. If you need live keystroke feedback, use a debounced border-colour change instead and reserve the shake for the final reportValidity() pass.

Does prefers-reduced-motion mean I remove all feedback?

No. The setting asks you to remove motion, not information. Swap the translate animation for an instantaneous, non-animated cue such as an outline or border colour, and keep the text error and live-region announcement exactly as they are.

Why mirror validationMessage into a role="alert" region instead of trusting the native bubble?

The native validation bubble is transient and its screen-reader behaviour varies across browsers. A persistent role="alert" region gives you a reliable, consistently announced message that stays visible while the user corrects the field.

Is the Web Animations API a safe replacement for the CSS class approach?

Yes, for any browser released in the last several years. Element.animate() and the finished promise are broadly supported, and the API sidesteps the void el.offsetWidth reflow trick because every call spawns an independent Animation. Note that WAAPI does not automatically honour prefers-reduced-motion for you — you still branch on matchMedia(...).matches yourself before calling animate().

How many fields can I shake at once before it feels chaotic?

Shaking three or four fields in a short staggered cascade reads as a deliberate wave; beyond that the screen becomes noisy and the motion competes with itself. On very long forms, prefer shaking only the first invalid field, moving focus to it, and surfacing an error summary at the top of the form so the user has a scannable list rather than a flurry of motion.

← Back to Visual Feedback & Micro-interactions