Back to Code Bytes
4 min read
Error State Shake

Description

Error State Shake delivers form validation feedback in two parts. First, the input field shakes left and right with a multi-segment keyframe: two full-distance swings followed by a smaller overshoot, so the animation decelerates naturally rather than snapping back. Second, the border color transitions to red and a message fades in beneath the field. After a hold timer long enough to read the message, both the border and the message revert to neutral over the same duration. The shake can be replayed on repeated triggers without flickering the error state because the shake class is managed by remounting the shaking element, keeping it orthogonal to the error-color state.

Example

Please enter a valid email.

CSS

:root {
  --shake-distance: 6px;
  --shake-overshoot: 4px;
  --shake-dur-a: 80ms;
  --shake-dur-b: 60ms;
  --shake-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --revert-hold: 3000ms;
  --revert-dur: 280ms;
}

/* Border-color tween. Define your input's default / focused
   / error border-color in your own component CSS — this rule
   only owns the interpolation. Use a constant border-width
   across states so the tween never shifts inner content. */
.t-input {
  transition: border-color 150ms ease-out;
  will-change: transform;
}
.t-input.is-error {
  /* Error border auto-reverts on the hold timer, so the
     fade-out uses the slower revert duration (matches the
     message fade). */
  transition: border-color var(--revert-dur, 280ms) ease-out;
}

/* Error message reveal. Visibility is delayed by --revert-dur
   on hide so the message stays painted for the full opacity
   fade-out. Entering .is-error drops the delay to 0 so the
   message becomes visible immediately. */
.t-error-msg {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity var(--revert-dur, 280ms) ease-out,
    visibility 0s linear var(--revert-dur, 280ms);
}
.t-input-wrap.is-error .t-error-msg {
  opacity: 1;
  visibility: visible;
  transition:
    opacity var(--revert-dur, 280ms) ease-out,
    visibility 0s linear 0s;
}

/* Multi-segment keyframe with per-stop easing so each leg
   of the shake follows its own cubic-bezier independently.
   %-stops are cumulative durations as a fraction of the
   total (80, 60, 80, 60 = 280ms): 28.57%, 57.14%, 78.57%,
   100%. Recompute if any segment duration changes. */
.t-input.is-shaking {
  animation: t-input-shake calc(var(--shake-dur-a) * 2 + var(--shake-dur-b) * 2)
    linear;
}
@keyframes t-input-shake {
  0% {
    transform: translateX(0);
    animation-timing-function: var(--shake-ease);
  }
  28.57% {
    transform: translateX(var(--shake-distance));
    animation-timing-function: var(--shake-ease);
  }
  57.14% {
    transform: translateX(calc(var(--shake-distance) * -1));
    animation-timing-function: var(--shake-ease);
  }
  78.57% {
    transform: translateX(var(--shake-overshoot));
    animation-timing-function: var(--shake-ease);
  }
  100% {
    transform: translateX(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .t-input {
    animation: none !important;
    transform: none !important;
  }
}

React

import { useEffect, useRef, useState } from "react";
import "./error-state-shake.css"; // paste the CSS above

export function ErrorStateShake() {
  const [error, setError] = useState(false);
  const [shakeKey, setShakeKey] = useState(0);
  const revert = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(revert.current), []);
  const trigger = () => {
    setError(true);
    setShakeKey((k) => k + 1);
    const cs = getComputedStyle(document.documentElement);
    const ms = (n: string, fb: number) =>
      parseFloat(cs.getPropertyValue(n)) || fb;
    const shakeMs = ms("--shake-dur-a", 80) * 2 + ms("--shake-dur-b", 60) * 2;
    window.clearTimeout(revert.current);
    revert.current = window.setTimeout(
      () => setError(false),
      shakeMs + ms("--revert-hold", 3000),
    );
  };
  return (
    <div>
      <div className={"t-input-wrap" + (error ? " is-error" : "")}>
        <div
          key={shakeKey}
          className={"t-input" + (error ? " is-error is-shaking" : "")}
        >
          <input
            type="text"
            defaultValue="not-an-email"
            aria-label="Email address"
          />
        </div>
        <p className="t-error-msg">Please enter a valid email.</p>
      </div>
      <button onClick={trigger}>Trigger error</button>
    </div>
  );
}

Variables

VariableDefaultNotes
--shake-distance6pxsourced from --p12-shake-distance
--shake-overshoot4pxsourced from --p12-shake-overshoot
--shake-dur-a80mssourced from --p12-shake-dur-a
--shake-dur-b60mssourced from --p12-shake-dur-b
--shake-easecubic-bezier(0.22, 1, 0.36, 1)sourced from --p12-shake-ease
--revert-hold3000mssourced from --p12-revert-hold
--revert-dur280mssourced from --p12-revert-dur

Credit

Adapted from Error State Shake on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.