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
| Variable | Default | Notes |
|---|---|---|
--shake-distance | 6px | sourced from --p12-shake-distance |
--shake-overshoot | 4px | sourced from --p12-shake-overshoot |
--shake-dur-a | 80ms | sourced from --p12-shake-dur-a |
--shake-dur-b | 60ms | sourced from --p12-shake-dur-b |
--shake-ease | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p12-shake-ease |
--revert-hold | 3000ms | sourced from --p12-revert-hold |
--revert-dur | 280ms | sourced from --p12-revert-dur |
Credit
Adapted from Error State Shake on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.