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.
aria-invalid, an alert and a WAAPI shake.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:
.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 swings a constant 8px over 600ms — good for a playful nudge, heavier than an error needs — and the validation shake card 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 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:
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:
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() 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:
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:
<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-describedbyon 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 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 explains why: alerts announce changes to content, and an element that appears with its message already inside is often not announced at all.novalidateturns off the browser’s own error bubbles, whilecheckValidity()still applies thetype="email"andrequiredrules.
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, 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:
@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.