---
title: "CSS Shake Animation for Errors (Accessible Version)"
description: "A CSS shake animation for form errors: decaying translateX keyframes, a clean restart with element.animate(), plus ARIA and reduced-motion support."
url: https://frontendhelpers.com/css-shake-animation
updated: 2026-10-12
---

# CSS shake animation for accessible errors

A shake that decays like a real head-shake, restarts cleanly on every failed attempt, and never leaves screen reader or reduced-motion users without the message.

CSS recipe · By [AurumCodeWorks](https://frontendhelpers.com/about) · 7 min read · Updated 12 October 2026

## Why a shake says “no”

A short horizontal shake borrows the head-shake most people read as “no”. On a login form or a single field it lands instantly: that didn’t work, try again. Because it happens exactly where the user is already looking, it draws attention without a modal, a banner or a scroll jump.

It is also easy to overuse. A shake makes sense when the user has just acted and that action failed: a rejected password, a one-time code that doesn’t match, an email address that plainly isn’t one. It does not suit errors the user didn’t cause (a network timeout deserves a calm message, not a scolding wobble), long forms with many invalid fields at once (ten shaking inputs are noise), or validation on every keystroke, which punishes people for being halfway through typing. Our rule: one shake, on submit, on the thing that needs fixing.

## Live demos

Three versions of the same idea. Use the buttons to replay them. If your device has reduced motion switched on, the motion demos deliberately pulse an outline instead of moving — that is the behaviour this recipe recommends.

Incorrect PIN

**Basic shake** — pure CSS keyframes, restarted by removing and re-adding the class.

**Accessible invalid field** — submit it empty: text error, `aria-invalid`, an alert and a WAAPI shake.

**Reduced-motion alternative** — the same error with movement on the left and an outline pulse on the right.

## The keyframes: a shake that decays

A shake that swings the same distance every time looks mechanical, like a phone buzzing on a table. Something knocked sideways loses energy with each swing, so this recipe shrinks every swing until the element settles back at zero:

```css
.sh-shake {
  animation: sh-shake 400ms ease-in-out both;
}
@keyframes sh-shake {
  0%, 100% { transform: translateX(0); }
  15% { transform: translateX(-8px); }
  30% { transform: translateX(6px); }
  45% { transform: translateX(-4px); }
  60% { transform: translateX(2px); }
  75% { transform: translateX(-1px); }
}
```

| Keyframe | Time at 400ms | translateX | What it does |
| --- | --- | --- | --- |
| 0%  | 0ms | 0   | At rest |
| 15% | 60ms | −8px | First and strongest swing |
| 30% | 120ms | 6px | Swings back, three-quarters as far |
| 45% | 180ms | −4px | Half the opening swing |
| 60% | 240ms | 2px | Quarter of the opening swing |
| 75% | 300ms | −1px | A barely visible tremor |
| 100% | 400ms | 0   | A 100ms glide back to rest |

The swings alternate direction and fall 8, 6, 4, 2, 1. The first steps lose a fixed 2px; the last one halves, so the tail fades out rather than stopping on a visible jolt. Each swing gets 60ms, but the final segment gets 100ms, and that slower return is what makes the ending feel settled instead of abrupt.

Eight pixels is plenty on a 40–48px tall input. It is clearly visible without dragging the field out from under a pointer or a screen magnifier’s viewport. Past about 12px the field looks as if it is sliding around.

For comparison, the [shake in our attention library](https://frontendhelpers.com/css-attention-animations#anim-shake) swings a constant 8px over 600ms — good for a playful nudge, heavier than an error needs — and the [validation shake card](https://frontendhelpers.com/css-form-animations#anim-field-error) runs 400ms with one smaller final swing. This recipe refines the second into the version we would put on a login form.

## Why transform, not left or margin

Every step animates `transform: translateX()`. A transform moves the element’s painted pixels without changing its box in the layout, so the label above, the button beside it and the error message below never shift. There is no layout shift, and nothing is recalculated on each frame. Browsers can usually run transform animations on the compositor, so the shake stays smooth even while the main thread is busy validating or sending a request.

Animating `left`, `margin-left` or `padding` instead forces layout on every frame and can nudge neighbouring elements; our guide to [CSS animation performance](https://frontendhelpers.com/css-animation-performance) explains the rendering pipeline in detail.

## Duration and easing

Aim for 300–500ms. Below 300ms the swings blur into a flicker; above 500ms the shake starts to feel like a tantrum, and it delays the moment the user can read the message and start fixing the problem. At 400ms each swing lasts about 60ms, quick enough to read as a single gesture.

In CSS keyframes, `animation-timing-function` applies to each segment between keyframes, not to the timeline as a whole. So `ease-in-out` slows every swing as it approaches its turning point, as a pendulum does, which reads as natural movement. `linear` produces a harsh zigzag. The JavaScript version below sets the same easing on each keyframe to match.

## Restarting the shake on repeated errors

The awkward part of a CSS shake is the second failure. If the class is still on the element, adding it again changes nothing, so the browser has no reason to run the animation again. There are three fixes.

### 1\. Remove, reflow, re-add

Remove the class, force the browser to process that change by reading a layout property, then add the class back. The first demo uses exactly this:

```js
const box = document.getElementById('sh-basic');
document.getElementById('sh-basic-replay').addEventListener('click', () => {
  box.classList.remove('sh-shake');
  void box.offsetWidth; // force a reflow so the browser sees the removal
  box.classList.add('sh-shake');
});
```

It works everywhere. The cost is a synchronous layout per click, trivial for one element, and a line that looks like a mistake without its comment.

### 2\. Clean up on animationend

Remove the class as soon as the animation finishes, so the element is always ready for the next error:

```js
box.addEventListener('animationend', () => {
  box.classList.remove('sh-shake');
});
// on each failed attempt:
box.classList.add('sh-shake');
```

It reads more cleanly, but `animationend` only fires when an animation completes. If the animation is aborted — the element is removed, or the class is taken off early — the event never arrives, and a second error during the first shake simply gets lost.

### 3\. The Web Animations API (the cleanest)

[MDN’s reference for `Element.animate()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate) describes it as a shortcut that creates an animation, applies it to the element, plays it and returns an `Animation` object. Every call starts a fresh run, so there is no class to reset and no reflow to force. Keep a reference, cancel the previous run, and start again:

```js
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const step = (px, offset) => ({ transform: `translateX(${px}px)`, offset, easing: 'ease-in-out' });
const SHAKE = [step(0, 0), step(-8, 0.15), step(6, 0.3), step(-4, 0.45), step(2, 0.6), step(-1, 0.75), step(0, 1)];
const FLASH = [{ outlineColor: 'rgba(248, 113, 113, 0)' }, { outlineColor: 'rgba(248, 113, 113, 1)', offset: 0.3 }, { outlineColor: 'rgba(248, 113, 113, 0)' }];

function signal(el, motion = !reduce.matches) {
  el.shAnim?.cancel();
  el.shAnim = motion ? el.animate(SHAKE, 400) : el.animate(FLASH, 900);
}
```

Two details. `animationend` belongs to CSS animations, so a script-created animation will not fire it; await `animation.finished` if you need to know when it is over. And a CSS `@media` rule cannot switch off an animation started from script, which is why `signal()` checks `prefers-reduced-motion` itself. `Element.animate()` has been Baseline widely available since 2020.

## Accessibility: the shake is decoration

A screen reader user hears nothing when a field moves 8px. Someone who glanced away misses it, and someone with reduced motion enabled never sees it. The shake can only ever be a supplement: the error itself must exist as text, tied to the field and announced. Here is the markup from the second demo:

```html
<form class="sh-form" id="sh-form" novalidate>
  <label for="sh-email">Email address</label>
  <div class="sh-row">
    <input class="sh-input" type="email" id="sh-email" name="email" autocomplete="email" required aria-describedby="sh-email-error" />
    <button type="submit" class="sh-submit">Submit</button>
  </div>
  <p class="sh-error" id="sh-email-error" role="alert"></p>
</form>
```

-   **A real label and visible text.** The message says what is wrong and how to fix it — “Enter an email address in the format name@example.com”, not “Invalid”.
-   **`aria-describedby`** on the input points at the message, so a screen reader reads it whenever focus returns to the field.
-   **`aria-invalid="true"`** is set only after a failed submit. [MDN’s aria-invalid guidance](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid) warns against marking an empty required field invalid before the user tries to submit, because they may still be filling it in.
-   **`role="alert"`** sits on a container that is present, and empty, from page load. Putting text into it triggers an assertive announcement. [MDN’s alert role page](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/alert_role) explains why: alerts announce changes to content, and an element that appears with its message already inside is often not announced at all.
-   **`novalidate`** turns off the browser’s own error bubbles, while `checkValidity()` still applies the `type="email"` and `required` rules.

```js
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const ok = email.checkValidity();
  error.textContent = ok ? '' : 'Enter an email address in the format name@example.com';
  if (ok) { email.removeAttribute('aria-invalid'); return; }
  email.setAttribute('aria-invalid', 'true');
  signal(email);
});
```

The last requirement is a cue that doesn’t depend on colour. A red border alone fails anyone who struggles to tell red from grey or green, so the demo also doubles the border’s thickness with an inset shadow and puts a round “!” badge before the message. The badge is generated with `content: "!" / ""`, which gives it empty alternative text, so screen readers don’t say “exclamation mark” before every error.

One field, one alert, is right. On a longer form, announce a single summary such as “3 errors, listed below” rather than firing an alert per field, and shake only the submit button, or nothing at all.

## Reduced motion: swap movement for colour

People with vestibular disorders can experience dizziness, nausea or headaches from motion they didn’t expect. Operating systems offer a “reduce motion” setting, and CSS reads it through the `prefers-reduced-motion` media query. [WCAG 2.2 Success Criterion 2.3.3, Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html), requires that motion animation triggered by an interaction can be disabled unless the animation is essential. It is a Level AAA criterion, so it sits above the AA level that most accessibility laws and contracts reference. Still, a shake is never essential, and the same Understanding document lists the reduced-motion media query as a sufficient technique.

Don’t simply delete the feedback, though — replace it. WCAG’s definition of motion animation does not cover colour changes, so an outline that pulses red conveys the same “look here” without anything moving:

```css
@keyframes sh-flash {
  from { outline-color: rgba(248, 113, 113, 0); }
  30%  { outline-color: rgba(248, 113, 113, 1); }
  to   { outline-color: rgba(248, 113, 113, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .sh-shake { animation-name: sh-flash; animation-duration: 900ms; }
}
```

The element needs `outline: 2px solid` with a transparent colour at rest, so there is an outline for the keyframes to colour in. The pulse runs longer than the shake because a gentle colour change needs more time to register than movement does. In the JavaScript version the same decision happens inside `signal()`: when the media query matches, it animates `outline-color` instead of `transform`. The third demo shows both side by side.

Either way, the persistent error state — thicker border, badge and message — is identical for every user, because that is the part carrying the information.

## Frequently asked questions

### How do I make a shake animation in CSS?

Write @keyframes that move the element left and right with transform: translateX(), shrinking each swing (for example -8px, 6px, -4px, 2px, -1px) and ending at 0. Apply it with animation: shake 400ms ease-in-out, and add the class only when an error occurs.

### How do I replay a shake animation every time a form fails?

Adding a class that is already present does nothing. Either remove the class, read offsetWidth to force a reflow and add it again, remove the class on animationend, or use element.animate() from the Web Animations API, which starts a fresh animation on every call.

### Is a shake animation accessible?

Only as decoration. Screen reader users cannot perceive it, so the error must also appear as text linked to the field with aria-describedby, the field needs aria-invalid="true", and the message should be announced through role="alert" or a live region.

### Should a shake animation respect prefers-reduced-motion?

Yes. Motion can trigger dizziness or nausea in people with vestibular disorders. When prefers-reduced-motion is set to reduce, keep the error styling but swap the movement for a colour change, such as an outline that pulses red without moving.

## Keep going

- [Attention animations](https://frontendhelpers.com/css-attention-animations): Shake, wobble, pulse and other effects that point the eye at one element.
 
- [Form animations](https://frontendhelpers.com/css-form-animations): Focus glows, floating labels, toggles and validation feedback for real inputs.
 
- [Accessible website guide](https://frontendhelpers.com/accessible-website-guide): The practical checklist for keyboard, screen reader and motion accessibility.
