Shake on Error
Submitting an invalid code makes the field shake side to side and show the error underneath, like a head saying no.
Create a React form field that shakes when validation fails. On submit, check the value against /^TWL-\d{4}$/i. If it fails, set aria-invalid="true", show the message "That code is not valid. Check the email we sent you." in an element referenced by aria-describedby with role="alert", and play a CSS keyframe on the field: translateX 0, -8px, 7px, -5px, 3px, 0 over 400ms with cubic-bezier(0.65, 0, 0.35, 1). Restart the shake on every failed submit by toggling a key or removing and re-adding the class on animationend. Keep focus in the field. If it passes, clear the error and show "You are in.". Under prefers-reduced-motion, skip the shake and only change the border colour and show the message.
Source
'use client'
import { useEffect, useId, useRef, useState } from 'react'
import s from './demo.module.css'
export default function ShakeError({ labels }: { labels: Record<string, string> }) {
const [status, setStatus] = useState<'idle' | 'error' | 'ok'>('idle')
const [attempt, setAttempt] = useState(0)
const [value, setValue] = useState('TWL-12')
const input = useRef<HTMLInputElement>(null)
const id = useId()
// the input remounts (key) to restart the shake, so focus the new element afterwards
useEffect(() => {
if (attempt) input.current?.focus()
}, [attempt])
const submit = (e: React.FormEvent) => {
e.preventDefault()
const ok = /^TWL-\d{4}$/i.test(value.trim())
setStatus(ok ? 'ok' : 'error')
if (!ok) setAttempt(attempt + 1)
}
return (
<form className={s.form} onSubmit={submit} noValidate>
<label htmlFor={id} className={s.label}>
{labels.label}
</label>
<div className={s.row}>
<input
key={attempt}
ref={input}
id={id}
className={s.input}
placeholder={labels.placeholder}
aria-invalid={status === 'error'}
aria-describedby={`${id}-msg`}
value={value}
onChange={(e) => setValue(e.target.value)}
autoComplete="off"
/>
<button type="submit" className={s.button}>
{labels.submit}
</button>
</div>
<p id={`${id}-msg`} className={s.msg} data-status={status} role="alert">
{status === 'error' ? labels.error : status === 'ok' ? labels.ok : ''}
</p>
</form>
)
}
.form { display: grid; gap: 8px; width: min(300px, 100%); }
.label { color: var(--stage-muted); font: 500 var(--text-sm) / 1 var(--font-body); }
.row { display: flex; gap: 8px; }
.input {
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: 10px;
outline: none;
background: var(--stage-bg);
color: var(--stage-ink);
font: 500 var(--text-base) / 1 var(--font-mono);
}
.input:focus { border-color: var(--stage-muted); }
.input:focus-visible { box-shadow: 0 0 0 3px var(--color-focus); }
.input[aria-invalid='true'] { border-color: var(--color-error); animation: shake 400ms var(--ease-in-out); }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(7px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(3px); }
}
.button {
height: 44px;
padding: 0 16px;
border: 0;
border-radius: 10px;
background: var(--stage-ink);
color: var(--stage-bg);
font: 600 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { background: var(--stage-accent); color: var(--stage-accent-ink); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.msg { min-height: 2.6em; margin: 0; font-size: var(--text-xs); line-height: 1.3; }
.msg[data-status='error'] { color: var(--color-error); }
.msg[data-status='ok'] { color: var(--stage-accent); }
@media (prefers-reduced-motion: reduce) {
.input[aria-invalid='true'] { animation: none; }
}