← Back to gallery
CSS

Error Shake Feedback

Three one-shot validation feedback patterns paired with aria-live hints — a canonical horizontal jitter, a motion-free border-pulse halo for reduced-motion users, and a vertical drop-bounce on the cross axis.

shakeerrorform-feedbackreduced-motionaccessibility

Feedback / validation / reduced-motion aware

Error shake feedback

Three one-shot validation feedback patterns — a canonical horizontal jitter, a motion-free border pulse for reduced-motion users, and a vertical bump on the cross axis. All pair with an aria-live hint so the message reads with or without motion.

Canonical translateX shake · invalid on submit

Horizontal Jitter

A short horizontal back-and-forth jitter — the canonical "this is wrong" transform. Amplitude stays modest so the motion reads as a correction nudge.

  • translateX
  • one-shot
  • invalid-state

Motion-free box-shadow flash · a11y-safe

Border Pulse

No transform — the field stays still while a red box-shadow halo pulses outward twice. Conveys "invalid" with zero motion, so reduced-motion users get full feedback parity.

  • box-shadow
  • no-transform
  • reduced-motion

translateY drop-bounce · cross-axis variant

Vertical Bump

A short upward dip followed by a downward bounce on the Y axis. The cross-axis motion reads as a head-shake "no, look again" — useful when horizontal jitter is already in use elsewhere on the screen.

  • translateY
  • drop-bounce
  • cross-axis

Feedback inspector

Horizontal Jitter

click submit

  • translateX
  • one-shot
  • invalid-state

A short horizontal back-and-forth jitter — the canonical "this is wrong" transform. Amplitude stays modest so the motion reads as a correction nudge rather than a jolt. Play exactly once per validation trigger; sustained shake reads as a visual stutter and hurts readability.

Helped you ship something? 🐟 Send my cat a churu

.error-field[data-state='shaking'] {
  animation: errorJitter 360ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}

@keyframes errorJitter {
  10%, 90% { transform: translateX(-2.4px); }
  20%, 80% { transform: translateX(4.8px); }
  30%, 50%, 70% { transform: translateX(-8px); }
  40%, 60% { transform: translateX(8px); }
}

@media (prefers-reduced-motion: reduce) {
  .error-field[data-state='shaking'] {
    animation: none;
    outline: 2px solid rgba(239, 68, 68, 0.95);
    outline-offset: 2px;
  }
}

How to make this

An error shake feedback pattern plays a short transform jitter once, pairs it with aria-invalid text, and swaps to a static outline for reduced motion.

html
1<label class="error-shake-demo">  <span>Email</span>  <span class="error-shake-demo__field" data-state="shaking">4    <input value="name@" aria-invalid="true" aria-describedby="email-error">  </span>6  <span id="email-error" class="error-shake-demo__hint" role="status">    Email looks invalid. Include an @ and domain.  </span></label> <style>.error-shake-demo {  display: grid;  gap: .45rem;  width: min(100%, 22rem);  color: #e2e8f0;  font: 600 .9rem/1.25 ui-sans-serif, system-ui;}.error-shake-demo__field {  display: block;  border: 1px solid rgba(248, 113, 113, .7);  border-radius: 10px;  background: rgba(15, 23, 42, .72);}25.error-shake-demo__field[data-state="shaking"] {26  animation: error-shake-recipe-jitter 360ms cubic-bezier(.36,.07,.19,.97) both;}.error-shake-demo input {  width: 100%;  border: 0;  padding: .65rem .75rem;  color: #f8fafc;  background: transparent;  font: inherit;}.error-shake-demo__hint {  min-block-size: 1.2em;  color: #f87171;  font-size: .78rem;}41@keyframes error-shake-recipe-jitter {  10%, 90% { transform: translateX(-2px); }  20%, 80% { transform: translateX(5px); }  30%, 50%, 70% { transform: translateX(-8px); }  40%, 60% { transform: translateX(8px); }}@media (prefers-reduced-motion: reduce) {  .error-shake-demo__field[data-state="shaking"] {49    animation: none;    outline: 2px solid #f87171;    outline-offset: 2px;  }}</style>

Annotated snippet

  1. Line 1The label keeps the field name, input, and feedback text in one small validation unit. The shake is attached to the field wrapper, not the whole form.
  2. Line 4aria-invalid marks the value as failed validation, while aria-describedby points to the specific error copy instead of relying on color or motion.
    PitfallIs shaking enough for accessibility?

    No. Pair the motion with aria-invalid, connected error text, and a visible color or outline state. Motion should reinforce the failure, not be the only signal.

  3. Line 6role="status" makes the changed feedback text polite. The message can update when validation fails without interrupting the current screen reader task.
    PitfallIs shaking enough for accessibility?

    No. Pair the motion with aria-invalid, connected error text, and a visible color or outline state. Motion should reinforce the failure, not be the only signal.

  4. Line 25A transient data-state is enough for the one-shot motion. Remove the state after the animation ends so the next failed submit can replay the shake.

    Looping the shake keeps shouting after the user already read the error; firing it once per failed submit and resting lets the static error state carry the rest.

    PitfallShould an error shake loop?

    No. Play the shake once when validation fails, then leave the field in a stable invalid state. A looping shake makes the form harder to read and keeps drawing attention after the user already knows what changed.

  5. Line 26The easing gives the movement a quick snap and settle. Keep the duration short so the field is available again immediately.
    PitfallHow strong should the shake be?

    Use small horizontal offsets and a short duration. Large offsets can feel punitive, especially on dense forms where the field may collide visually with neighboring controls.

  6. Line 49Reduced motion removes the jitter and replaces it with a static outline. The validation failure stays visible without relying on vestibular motion.
    PitfallWhat should reduced motion users see?

    Replace the shake with a static invalid style such as an outline, border, or message update. The form must remain clear even when animation is disabled.

  7. Line 41The comparison isolates the property choice. Transform keeps the invalid field from participating in layout while the shake plays.

    Both look like the same shake — but left changes the layout box every frame (cells around it can shove) while transform stays on the composite layer.

    PitfallWhy animate transform instead of left or margin?

    Transform moves the painted field without changing document flow. Animating left, margin, or padding can trigger layout work and may shove nearby content during the error state.

Advanced

Spring-decay amplitude — let the shake settle, not flail at full strength to the end

Same 1.6s cycle, same translateX one-shot, same ±10px peak amplitude — only the amplitude curve through the keyframe stops differs. BEFORE keeps the burst at ±8/±8/±8 the whole way through, so the field hits the same wall every beat (a hard buzz). AFTER decays the amplitude on each successive peak (±10 → ±8 → ±6 → ±4 → ±3 → ±2 → 0), so the field clearly loses energy and settles into rest like a real spring. Reads less like a computer alarm, more like a physical correction.

View explanation and full code28 lines

The base shake uses equal-amplitude translateX bursts (±2px → ±5px → ±8px → ±8px → ±5px → ±2px). It reads correctly, but every peak hits the same hard wall — closer to a computer jitter than a real spring releasing energy. Real-world physics: a pushed object oscillates with decaying amplitude as friction bleeds energy away. Same data-state trigger, same translateX property, same 360ms duration — what changes is the amplitude curve at each stop: the first peaks are large, each subsequent peak smaller, and the field eases to rest. Reads more like a spring snapping back than a hard buzz.

Append these rules inside the <style> block from the base snippet above.

css
/* Advanced: spring-decay shake — extends the base recipe.   Same data-state="shaking" trigger, same one-shot animation, same   translateX property. The keyframe amplitudes now decay through the   cycle (10px → 8px → 6px → 4px → 2px → 0) so the field settles   into rest instead of slamming at full strength all the way through.   The cubic-bezier easing is replaced by a softer ease-out so each   sub-peak loses a bit more energy on the way out. */.error-shake-demo__field[data-state="shaking"] {  animation: error-shake-recipe-decay 460ms cubic-bezier(.22, .68, .36, 1) both;}@keyframes error-shake-recipe-decay {  0%   { transform: translateX(0); }  12%  { transform: translateX(-10px); }  24%  { transform: translateX(8px); }  36%  { transform: translateX(-6px); }  50%  { transform: translateX(4px); }  64%  { transform: translateX(-3px); }  78%  { transform: translateX(2px); }  92%  { transform: translateX(-1px); }  100% { transform: translateX(0); }}@media (prefers-reduced-motion: reduce) {  .error-shake-demo__field[data-state="shaking"] {    animation: none;    outline: 2px solid #f87171;    outline-offset: 2px;  }}

Notes

Overview

Form-validation error shake is a tiny keyframe animation (translate-X back and forth a few pixels) triggered when an input fails validation. The pattern adds the .is-shaking class on submit, force-reflows the element with void el.offsetWidth, then re-adds the class so the keyframe restarts even on rapid resubmits. An aria-live="polite" region announces the validation message in parallel.

When to use it

Reach for shake feedback on inline form validation, permission-denied taps, “wrong password” affordances — any rejection that the user needs to notice without leaving their current context. Skip it for any error that requires an action beyond “fix the input” (network errors, server errors); those need a toast or a modal with explicit next steps.

How it works

A 5-frame @keyframes rule named shake translates the element along the X axis with diminishing amplitude (e.g. 0% / 25% / 50% / 75% / 100% at 0px / -8px / 6px / -4px / 0px) over ~320ms. Adding the class kicks it off; the trick is restarting it on the same element — CSS animations do not replay when re-applied to a node that already has the class. The canonical fix: remove the class, read void el.offsetWidth to force a synchronous reflow, then re-add the class. The browser now sees a fresh animation start and the keyframe replays from frame 0.

Production gotchas

Forgetting the reflow trick is the most common bug — the user mashes submit, the error message updates, but the input never shakes again. Tying the shake to animationend with class removal is fine, but racing it against rapid resubmits means the class can be removed before the next add triggers reflow; use requestAnimationFrame bracketing if shakes feel inconsistent. Keep the amplitude under ~8px — large translations are uncomfortable on tightly-packed forms and can collide with adjacent layout.

Accessibility

Shake is a decorative reinforcement — the error announcement itself must come from aria-live="polite" or role="alert" with the actual validation message. Under prefers-reduced-motion: reduce swap the translate keyframe for a brief border-color flash (red 80% opacity for 200ms then back) so the error still reads without the lateral motion that can trigger vestibular disorders.

References

Implementation depth

Shake feedback should be a secondary cue after a clear error message. The animation can draw the eye to the invalid field, but text and aria-describedby need to explain what the user must fix.

Keep the distance small and the duration short. Large shake motion feels punitive and can be uncomfortable; reduced motion should use color, border, and message changes without lateral movement.