Input shake on error
An invalid email makes the field shake and shows an inline error; a valid one draws a tick inside the field.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useId, useRef, useState } from "react";
import styles from "./demo.module.css";
type Status = "idle" | "error" | "success";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
export default function Demo() {
const id = useId();
const field = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<Status>("idle");
const submit = (event: React.FormEvent) => {
event.preventDefault();
const value = input.current?.value.trim() ?? "";
if (EMAIL.test(value)) {
setStatus("success");
return;
}
setStatus("error");
input.current?.focus();
// Restart the shake even when the field is already in the error state.
const el = field.current;
if (el) {
el.classList.remove(styles.shake);
void el.offsetWidth;
el.classList.add(styles.shake);
}
};
return (
<div className={styles.root}>
<form className={styles.form} onSubmit={submit} noValidate>
<label htmlFor={`${id}-email`} className={styles.label}>
Email for booking updates
</label>
<div ref={field} className={`${styles.field} ${styles[status]}`} onAnimationEnd={(e) => e.currentTarget.classList.remove(styles.shake)}>
<input
ref={input}
id={`${id}-email`}
name="email"
type="email"
autoComplete="email"
placeholder="maya@example.com"
defaultValue="maya@villa"
aria-invalid={status === "error"}
aria-describedby={`${id}-msg`}
onChange={() => status !== "idle" && setStatus("idle")}
/>
<svg className={styles.tick} viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="9" />
<path pathLength="1" d="M6 10.5 8.8 13 14 7.5" />
</svg>
</div>
<p id={`${id}-msg`} className={`${styles.msg} ${styles[status]}`} aria-live="polite">
{status === "error" && (
<>
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="7" />
<path d="M8 4.5v4.2M8 11.2v.1" />
</svg>
Enter a full email, like maya@example.com
</>
)}
{status === "success" && "Saved. We will email you when Wed 14 opens."}
{status === "idle" && "We only send booking changes."}
</p>
<button type="submit" className={styles.button}>
Notify me
</button>
</form>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.form { display: grid; gap: 8px; width: min(340px, 100%); }
.label { font-size: 14px; font-weight: 600; }
.field { position: relative; }
.field input { width: 100%; min-height: 48px; padding: 0 44px 0 14px; border: 1.5px solid var(--line); border-radius: 12px; background: var(--white); font: inherit; font-size: 16px; color: var(--ink); outline: none; transition: border-color .2s, box-shadow .2s; }
.field input:focus { border-color: var(--ink); box-shadow: 0 0 0 3px var(--surface); }
.field.error input { border-color: var(--ink); box-shadow: 0 0 0 3px var(--sand); }
.field.success input { border-color: var(--accent-text); box-shadow: 0 0 0 3px var(--accent-pale); }
.shake { animation: shake .42s var(--out); }
@keyframes shake {
15% { translate: -7px 0; }
35% { translate: 6px 0; }
55% { translate: -4px 0; }
75% { translate: 2px 0; }
}
.tick { position: absolute; right: 14px; top: 50%; width: 22px; height: 22px; translate: 0 -50%; opacity: 0; scale: .6; transition: opacity .2s, scale .35s var(--out); }
.tick circle { fill: var(--accent-text); }
.tick path { fill: none; stroke: var(--white); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .35s var(--out) .12s; }
.success .tick { opacity: 1; scale: 1; }
.success .tick path { stroke-dashoffset: 0; }
.msg { display: flex; align-items: center; gap: 6px; min-height: 22px; font-size: 13px; line-height: 1.4; color: var(--muted); }
.msg.error { color: var(--ink); font-weight: 500; animation: rise .3s var(--out); }
.msg.success { color: var(--accent-text); font-weight: 500; animation: rise .3s var(--out); }
.msg svg { width: 16px; height: 16px; flex: none; }
.msg svg circle { fill: var(--ink); }
.msg svg path { stroke: var(--white); stroke-width: 1.8; stroke-linecap: round; }
@keyframes rise { from { opacity: 0; translate: 0 -4px; } }
.button { justify-self: start; min-height: 44px; margin-top: 4px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--ink); color: var(--white); font-weight: 600; font-size: 15px; cursor: pointer; transition: background-color .18s, scale .18s var(--out); }
.button:hover { background: var(--ink-hover); }
.button:active { scale: .97; }
@media (prefers-reduced-motion: reduce) {
.shake, .msg.error, .msg.success { animation: none; }
.tick, .tick path { transition: none; }
}
Use it for
- Short forms with one or two fields, such as a sign up or a notify me box
- Submit time validation where the error needs to be noticed
Avoid
- Shaking on every keystroke, which feels like scolding
- Relying on the shake alone: always show the message
How it works
On submit the value is checked with a simple pattern. An invalid result sets the error state and restarts a short translate keyframe on the field wrapper (remove class, read offsetWidth, add class) so it shakes even on a second try. A valid result reveals an SVG tick whose stroke draws in with stroke-dashoffset. Typing again resets to the idle state.
Accessibility
The input has a visible label, aria-invalid in the error state and aria-describedby pointing at the message, which is a polite live region with an icon and text. Focus returns to the field on error. Reduced motion removes the shake but keeps the message.