9 CSS Shake Animation Examples for Form Errors
Type a wrong password and the box shakes before you read why — that's a css shake animation, code that makes a form say "try again" with no
01 shake-error-animation-shake-horizontal
02 shake-error-animation-wiggle-rotate
03 shake-error-animation-jello
04 shake-error-animation-error-border-flash
05 shake-error-animation-head-shake-input
07 shake-error-animation-user-invalid-shake
08 shake-error-animation-shake-once-reset
09 shake-error-animation-reduced-motion-outline
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Horizontal shake
- 02 Wiggle rotate
- 03 Jello wobble
- 04 Error border flash
- 05 Input head-shake
- 06 Attention icon bounce
- 07 Shake via :user-invalid
- 08 One-shot shake reset pattern
- 09 Outline fallback for reduced motion
The order below follows where inside a form the failure actually shows up, not popularity. 01 through 03 shake the value someone just typed wrong — an email field, an unchecked box, a button that failed to save. 04 backs off to a quieter signal that flashes instead of moving. 05 and 06 cover heavier failures: a mismatched password, an icon meant to catch the eye on its own. 07 hands the whole job to the browser with no JavaScript at all, 08 deals with the same field failing twice in a row, and 09 makes sure someone who has turned off motion still gets the same warning. When the motion needs to point at what is right instead of what is wrong, the text highlight animations carry that opposite signal, and turning an error color into light is the job of the CSS glow effects.
01Horizontal shake
What happens on screen
Leave an email field holding a bad address, and the whole input shakes left and right four times before settling while its border turns red.

<main class="stage">
<div class="sh is-demo">
<label class="sh__label" for="sh-email">Email</label>
<input class="sh__input" id="sh-email" type="email" value="wrong@mail" aria-invalid="true" readonly>
<p class="sh__msg">Not a valid email format</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 500ms; // 흔들림 한 번의 길이
$easing: $ease-out;
$color: $error; // 오류 테두리·문구 색
@include stage($stage-ink);
.sh { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.sh__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-cream; }
.sh__input {
box-sizing: border-box;
width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid $color;
background: rgba(255, 255, 255, .06);
color: $subject-cream;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.sh__msg { margin: 0; color: $error-soft; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
// 실제 동작: 폼 검증에 실패해 .sh--invalid 가 붙으면 한 번만 흔들린다 (JS 없이 클래스 하나로 충분)
@keyframes sh-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-7px); }
4% { transform: translateX(7px); }
6% { transform: translateX(-7px); }
8% { transform: translateX(7px); }
10% { transform: translateX(-5px); }
12% { transform: translateX(5px); }
14% { transform: translateX(-3px); }
16% { transform: translateX(3px); }
18% { transform: translateX(-1px); }
20% { transform: translateX(1px); }
}
.sh__input.sh--invalid { animation: sh-shake $duration $easing; }
// 데모 루프: 클릭 전까지 계속 흔들려 무엇이 움직이는지 보여준다
.sh.is-demo .sh__input { animation: sh-shake $duration $easing infinite; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sh__input.sh--invalid,
.sh.is-demo .sh__input { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sh {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.sh__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.sh__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #c2361a;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.sh__msg {
margin: 0;
color: #ffe0d8;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes sh-shake {
0%, 22%, 100% {
transform: translateX(0);
}
2% {
transform: translateX(-7px);
}
4% {
transform: translateX(7px);
}
6% {
transform: translateX(-7px);
}
8% {
transform: translateX(7px);
}
10% {
transform: translateX(-5px);
}
12% {
transform: translateX(5px);
}
14% {
transform: translateX(-3px);
}
16% {
transform: translateX(3px);
}
18% {
transform: translateX(-1px);
}
20% {
transform: translateX(1px);
}
}
.sh__input.sh--invalid {
animation: sh-shake 500ms ease-out;
}
.sh.is-demo .sh__input {
animation: sh-shake 500ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.sh__input.sh--invalid,
.sh.is-demo .sh__input {
animation: none !important;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShakeHorizontal.module.scss";
export type ShakeHorizontalProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ShakeHorizontal({ duration = 500, easing = "ease-out", color = "#c2361a" }: ShakeHorizontalProps) {
const [invalid, setInvalid] = useState(true);
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.sh} style={vars}>
<label className={styles.label} htmlFor="sh-email">이메일</label>
<input
key={run}
id="sh-email"
className={`${styles.input} ${invalid ? styles.shake : ""}`}
type="email"
defaultValue="wrong@mail"
readOnly
onBlur={() => { setInvalid(true); setRun((n) => n + 1); }}
/>
<p className={styles.msg}>올바른 이메일 형식이 아닙니다</p>
</div>
);
}01 · Horizontal shake — Measured area 0.494% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
One translateX keyframe with shrinking amplitude does the entire thing, with no animation library involved.
@keyframes sh-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-7px); }
4% { transform: translateX(7px); }
6% { transform: translateX(-7px); }
8% { transform: translateX(7px); }
10% { transform: translateX(-5px); }
12% { transform: translateX(5px); }
14% { transform: translateX(-3px); }
16% { transform: translateX(3px); }
18% { transform: translateX(-1px); }
20% { transform: translateX(1px); }
}
.sh__input.sh--invalid { animation: sh-shake $duration $easing; }
02Wiggle rotate
What it looks like
Try to submit a form without checking the terms box, and the whole checkbox row tips left and right as if it's shaking its head.

<main class="stage">
<div class="wg is-demo">
<label class="wg__row" for="wg-agree">
<input class="wg__checkbox" type="checkbox" id="wg-agree">
<span class="wg__text">I agree to the Terms of Service</span>
</label>
<p class="wg__msg">You must agree to continue</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 600ms; // 비틀거림 한 번의 길이
$easing: $ease-out;
$color: $error;
@include stage($stage-yellow);
.wg { width: min(280px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.wg__row {
display: flex;
align-items: center;
gap: $sp-2;
transform-origin: left center; // 규칙8: 왼쪽 끝축 흔들림 회전
cursor: pointer;
}
.wg__checkbox { width: 18px; height: 18px; accent-color: $color; }
.wg__text { font-weight: 500; font-size: 13px; line-height: 1.3; font-family: inherit; color: $subject-ink; }
.wg__msg { margin: 0; display: inline-block; padding: $sp-1 $sp-2; border-radius: $r-xs; background: $stage-paper; color: $color; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
@keyframes wg-wiggle {
0%, 100% { transform: rotate(0); }
15% { transform: rotate(-4deg); }
30% { transform: rotate(4deg); }
45% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
75% { transform: rotate(-1.5deg); }
90% { transform: rotate(1.5deg); }
}
// 실제 동작: 체크 없이 제출하면 .wg--wiggle 이 한 번 붙는다
.wg__row.wg--wiggle { animation: wg-wiggle $duration $easing; }
// 데모 루프
.wg.is-demo .wg__row { animation: wg-wiggle $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.wg__row.wg--wiggle,
.wg.is-demo .wg__row { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wg {
width: min(280px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.wg__row {
display: flex;
align-items: center;
gap: 8px;
transform-origin: left center;
cursor: pointer;
}
.wg__checkbox {
width: 18px;
height: 18px;
accent-color: #c2361a;
}
.wg__text {
font-weight: 500;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.wg__msg {
margin: 0;
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
background: #ffffff;
color: #c2361a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes wg-wiggle {
0%, 100% {
transform: rotate(0);
}
15% {
transform: rotate(-4deg);
}
30% {
transform: rotate(4deg);
}
45% {
transform: rotate(-3deg);
}
60% {
transform: rotate(3deg);
}
75% {
transform: rotate(-1.5deg);
}
90% {
transform: rotate(1.5deg);
}
}
.wg__row.wg--wiggle {
animation: wg-wiggle 600ms ease-out;
}
.wg.is-demo .wg__row {
animation: wg-wiggle 600ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.wg__row.wg--wiggle,
.wg.is-demo .wg__row {
animation: none !important;
}
}import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./WiggleRotate.module.scss";
export type WiggleRotateProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WiggleRotate({ duration = 600, easing = "ease-out", color = "#c2361a" }: WiggleRotateProps) {
const [checked, setChecked] = useState(false);
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const submit = () => {
if (!checked) setRun((n) => n + 1);
};
return (
<div className={styles.wg} style={vars}>
<label key={run} className={`${styles.row} ${!checked ? styles.wiggle : ""}`} htmlFor="wg-agree">
<input id="wg-agree" type="checkbox" checked={checked} onChange={(e: ChangeEvent<HTMLInputElement>) => setChecked(e.target.checked)} />
<span className={styles.text}>이용약관에 동의합니다</span>
</label>
<button type="button" className={styles.btn} onClick={submit}>제출</button>
<p className={styles.msg}>계속하려면 약관에 동의해야 합니다</p>
</div>
);
}02 · Wiggle rotate — Measured area 1.942% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
Only the rotate angle changes across the keyframe, so the checkbox shape itself never bends.
.wg__row { transform-origin: left center; }
@keyframes wg-wiggle {
0%, 100% { transform: rotate(0); }
15% { transform: rotate(-4deg); }
30% { transform: rotate(4deg); }
45% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
75% { transform: rotate(-1.5deg); }
90% { transform: rotate(1.5deg); }
}
.wg__row.wg--wiggle { animation: wg-wiggle $duration $easing; }
03Jello wobble
Where a css button shake animation fits
A save button that comes back with an error wobbles like jello and settles back down by the end.

<main class="stage">
<div class="je is-demo">
<button class="je__btn" type="button">Save</button>
<p class="je__msg">Couldn't save. Try again.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 800ms; // 출렁임이 잦아드는 데 걸리는 시간
$easing: $ease-out;
$color: $error;
@include stage($stage-orange);
.je { width: min(240px, 86vw); display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.je__btn {
box-sizing: border-box;
padding: $sp-2 $sp-6;
border: 0;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
transform-origin: center;
}
.je__msg { margin: 0; color: $subject-ink; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; text-align: center; }
@keyframes je-jello {
0%, 100% { transform: none; }
15% { transform: skewX(-9deg) skewY(-9deg); }
30% { transform: skewX(7deg) skewY(7deg); }
45% { transform: skewX(-5deg) skewY(-5deg); }
60% { transform: skewX(3deg) skewY(3deg); }
75% { transform: skewX(-2deg) skewY(-2deg); }
90% { transform: skewX(1deg) skewY(1deg); }
}
// 실제 동작: 저장 실패 응답을 받으면 .je--jello 가 한 번 붙는다
.je__btn.je--jello { animation: je-jello $duration $easing; }
// 데모 루프
.je.is-demo .je__btn { animation: je-jello $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.je__btn.je--jello,
.je.is-demo .je__btn { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.je {
width: min(240px, 86vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.je__btn {
box-sizing: border-box;
padding: 8px 24px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
transform-origin: center;
}
.je__msg {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
text-align: center;
}
@keyframes je-jello {
0%, 100% {
transform: none;
}
15% {
transform: skewX(-9deg) skewY(-9deg);
}
30% {
transform: skewX(7deg) skewY(7deg);
}
45% {
transform: skewX(-5deg) skewY(-5deg);
}
60% {
transform: skewX(3deg) skewY(3deg);
}
75% {
transform: skewX(-2deg) skewY(-2deg);
}
90% {
transform: skewX(1deg) skewY(1deg);
}
}
.je__btn.je--jello {
animation: je-jello 800ms ease-out;
}
.je.is-demo .je__btn {
animation: je-jello 800ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.je__btn.je--jello,
.je.is-demo .je__btn {
animation: none !important;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./JelloWobble.module.scss";
export type JelloWobbleProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function JelloWobble({ duration = 800, easing = "ease-out", color = "#c2361a" }: JelloWobbleProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.je} style={vars}>
<button key={run} type="button" className={`${styles.btn} ${styles.jello}`} onClick={() => setRun((n) => n + 1)}>
저장
</button>
<p className={styles.msg}>저장하지 못했습니다. 다시 시도하세요</p>
</div>
);
}03 · Jello wobble — Measured area 1.069% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
skewX and skewY move together inside the same keyframe, distorting the button along two diagonals at once before it calms.
@keyframes je-jello {
0%, 100% { transform: none; }
15% { transform: skewX(-9deg) skewY(-9deg); }
30% { transform: skewX(7deg) skewY(7deg); }
45% { transform: skewX(-5deg) skewY(-5deg); }
60% { transform: skewX(3deg) skewY(3deg); }
75% { transform: skewX(-2deg) skewY(-2deg); }
90% { transform: skewX(1deg) skewY(1deg); }
}
.je__btn.je--jello { animation: je-jello $duration $easing; }
04Error border flash
What you see
A card-number field never moves at all — instead, a red ring layered behind it flashes twice through opacity while the input itself stays perfectly still.

<main class="stage">
<div class="bf is-demo">
<div class="bf__field">
<input class="bf__input" type="text" placeholder="Card number" value="1234">
<span class="bf__ring" aria-hidden="true"></span>
</div>
<p class="bf__msg">Check your card number</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 500ms; // 링이 두 번 깜빡이는 시간
$easing: ease-in-out;
$color: $error;
@include stage($stage-paper);
.bf { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.bf__field { position: relative; }
.bf__input {
box-sizing: border-box;
width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid rgba(23, 20, 26, .18);
background: #fff;
color: $subject-ink;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.bf__ring {
position: absolute;
inset: -$sp-1;
border: 2px solid $color;
border-radius: $r-sm;
opacity: 0;
pointer-events: none;
}
.bf__msg { margin: 0; color: $color; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
@keyframes bf-flash {
0%, 100% { opacity: 0; }
15% { opacity: 1; }
30% { opacity: 0; }
45% { opacity: 1; }
60% { opacity: 0; }
}
// 실제 동작: 요청이 실패하면 .bf--flash 가 한 번 붙어 두 번 깜빡인다
.bf__ring.bf--flash { animation: bf-flash $duration $easing; }
// 데모 루프
.bf.is-demo .bf__ring { animation: bf-flash $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.bf__ring.bf--flash,
.bf.is-demo .bf__ring { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bf {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.bf__field {
position: relative;
}
.bf__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid rgba(23, 20, 26, 0.18);
background: #fff;
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.bf__ring {
position: absolute;
inset: -4px;
border: 2px solid #c2361a;
border-radius: 8px;
opacity: 0;
pointer-events: none;
}
.bf__msg {
margin: 0;
color: #c2361a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes bf-flash {
0%, 100% {
opacity: 0;
}
15% {
opacity: 1;
}
30% {
opacity: 0;
}
45% {
opacity: 1;
}
60% {
opacity: 0;
}
}
.bf__ring.bf--flash {
animation: bf-flash 500ms ease-in-out;
}
.bf.is-demo .bf__ring {
animation: bf-flash 500ms ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bf__ring.bf--flash,
.bf.is-demo .bf__ring {
animation: none !important;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ErrorBorderFlash.module.scss";
export type ErrorBorderFlashProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ErrorBorderFlash({ duration = 500, easing = "ease-in-out", color = "#c2361a" }: ErrorBorderFlashProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.bf} style={vars}>
<div className={styles.field}>
<input className={styles.input} type="text" placeholder="카드 번호" defaultValue="1234" onFocus={() => setRun((n) => n + 1)} />
<span key={run} className={`${styles.ring} ${styles.flash}`} aria-hidden="true" />
</div>
<p className={styles.msg}>카드 번호를 확인하세요</p>
</div>
);
}04 · Error border flash — Measured area 0.869% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
Keeping the field still stops the fields next to it in the row from looking like they shook too.
.bf__ring {
position: absolute;
inset: -4px;
border: 2px solid $color;
opacity: 0;
}
@keyframes bf-flash {
0%, 100% { opacity: 0; }
15% { opacity: 1; }
30% { opacity: 0; }
45% { opacity: 1; }
60% { opacity: 0; }
}
.bf__ring.bf--flash { animation: bf-flash $duration $easing; }
05Input head-shake
Where it fits
When a confirm-password field doesn't match, the input tilts in 3D like someone shaking their head no, mixing translateX and rotateY inside one keyframe.

<main class="stage">
<div class="hs is-demo">
<label class="hs__label" for="hs-pw">Confirm password</label>
<input class="hs__input" id="hs-pw" type="password" value="wrongpass" readonly>
<p class="hs__msg">Passwords don't match</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 900ms; // 고개를 젓는 전체 길이
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.hs { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; perspective: 500px; }
.hs__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-cream; }
.hs__input {
box-sizing: border-box;
width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid $color;
background: rgba(255, 255, 255, .06);
color: $subject-cream;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.hs__msg { margin: 0; color: $error-soft; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
@keyframes hs-shake {
0% { transform: translateX(0); }
6.5% { transform: translateX(-6px) rotateY(-9deg); }
18.5% { transform: translateX(5px) rotateY(7deg); }
31.5% { transform: translateX(-3px) rotateY(-5deg); }
43.5% { transform: translateX(2px) rotateY(3deg); }
50%, 100% { transform: translateX(0); }
}
// 실제 동작: 비밀번호 확인이 어긋나면 .hs--shake 가 한 번 붙는다
.hs__input.hs--shake { animation: hs-shake $duration $easing; }
// 데모 루프
.hs.is-demo .hs__input { animation: hs-shake $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.hs__input.hs--shake,
.hs.is-demo .hs__input { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
perspective: 500px;
}
.hs__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.hs__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #c2361a;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.hs__msg {
margin: 0;
color: #ffe0d8;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes hs-shake {
0% {
transform: translateX(0);
}
6.5% {
transform: translateX(-6px) rotateY(-9deg);
}
18.5% {
transform: translateX(5px) rotateY(7deg);
}
31.5% {
transform: translateX(-3px) rotateY(-5deg);
}
43.5% {
transform: translateX(2px) rotateY(3deg);
}
50%, 100% {
transform: translateX(0);
}
}
.hs__input.hs--shake {
animation: hs-shake 900ms ease-out;
}
.hs.is-demo .hs__input {
animation: hs-shake 900ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hs__input.hs--shake,
.hs.is-demo .hs__input {
animation: none !important;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeadShakeInput.module.scss";
export type HeadShakeInputProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HeadShakeInput({ duration = 900, easing = "ease-out", color = "#c2361a" }: HeadShakeInputProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.hs} style={vars}>
<label className={styles.label} htmlFor="hs-pw">비밀번호 확인</label>
<input
key={run}
id="hs-pw"
className={`${styles.input} ${styles.shake}`}
type="password"
defaultValue="wrongpass"
readOnly
onFocus={() => setRun((n) => n + 1)}
/>
<p className={styles.msg}>비밀번호가 일치하지 않습니다</p>
</div>
);
}05 · Input head-shake — Measured area 2.372% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
That 3D tilt only reads correctly once its parent element carries a perspective.
.hs { perspective: 500px; }
@keyframes hs-shake {
0% { transform: translateX(0); }
6.5% { transform: translateX(-6px) rotateY(-9deg); }
18.5% { transform: translateX(5px) rotateY(7deg); }
31.5% { transform: translateX(-3px) rotateY(-5deg); }
43.5% { transform: translateX(2px) rotateY(3deg); }
50%, 100% { transform: translateX(0); }
}
.hs__input.hs--shake { animation: hs-shake $duration $easing; }
06Attention icon bounce
What moves
A round warning icon hops up and lands beside a form error, mixing scale and translateY in the same keyframe so the motion reads as springy rather than mechanical.
<main class="stage">
<div class="ib is-demo">
<span class="ib__icon" aria-hidden="true">!</span>
<p class="ib__msg">This field is required</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 700ms; // 아이콘이 튀어 올랐다 착지하는 시간
$easing: $ease-pop;
$color: $error;
@include stage($stage-yellow);
.ib { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.ib__icon {
width: $sp-8; height: $sp-8;
border-radius: 50%;
background: $color;
color: #fff;
display: grid; place-items: center;
font-weight: 700;
font-size: 16px;
line-height: 1;
font-family: inherit;
transform-origin: center;
}
.ib__msg { margin: 0; color: $subject-ink; font-weight: 500; font-size: 12px; line-height: 1.3; font-family: inherit; }
@keyframes ib-bounce {
0%, 100% { transform: translateY(0) scale(1); }
20% { transform: translateY(-10px) scale(1.15); }
40% { transform: translateY(0) scale(.95); }
55% { transform: translateY(-4px) scale(1.05); }
70% { transform: translateY(0) scale(1); }
}
// 실제 동작: 오류가 뜨면 .ib--bounce 가 한 번 붙는다
.ib__icon.ib--bounce { animation: ib-bounce $duration $easing; }
// 데모 루프
.ib.is-demo .ib__icon { animation: ib-bounce $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.ib__icon.ib--bounce,
.ib.is-demo .ib__icon { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ib {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ib__icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: #c2361a;
color: #fff;
display: grid;
place-items: center;
font-weight: 700;
font-size: 16px;
line-height: 1;
font-family: inherit;
transform-origin: center;
}
.ib__msg {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
font-family: inherit;
}
@keyframes ib-bounce {
0%, 100% {
transform: translateY(0) scale(1);
}
20% {
transform: translateY(-10px) scale(1.15);
}
40% {
transform: translateY(0) scale(0.95);
}
55% {
transform: translateY(-4px) scale(1.05);
}
70% {
transform: translateY(0) scale(1);
}
}
.ib__icon.ib--bounce {
animation: ib-bounce 700ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ib.is-demo .ib__icon {
animation: ib-bounce 700ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.ib__icon.ib--bounce,
.ib.is-demo .ib__icon {
animation: none !important;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AttentionIconBounce.module.scss";
export type AttentionIconBounceProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function AttentionIconBounce({ duration = 700, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#c2361a" }: AttentionIconBounceProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ib} style={vars} onClick={() => setRun((n) => n + 1)}>
<span key={run} className={`${styles.icon} ${styles.bounce}`} aria-hidden="true">!</span>
<p className={styles.msg}>필수 항목입니다</p>
</div>
);
}06 · Attention icon bounce — Measured area 0.726% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Because the icon only decorates the message, it carries aria-hidden and the real warning stays in the text next to it.
@keyframes ib-bounce {
0%, 100% { transform: translateY(0) scale(1); }
20% { transform: translateY(-10px) scale(1.15); }
40% { transform: translateY(0) scale(.95); }
55% { transform: translateY(-4px) scale(1.05); }
70% { transform: translateY(0) scale(1); }
}
.ib__icon.ib--bounce { animation: ib-bounce $duration $easing; }
07Shake via :user-invalid
The moment it fires
With zero lines of JavaScript, the input:user-invalid pseudo-class shakes an email field the moment the browser marks a touched value invalid, never before.

<main class="stage">
<div class="ui is-demo">
<label class="ui__label" for="ui-email">Email</label>
<input class="ui__input" id="ui-email" type="email" required pattern=".+@.+\..+" value="broken">
<p class="ui__msg">Enter a valid email address</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 500ms; // 흔들림 한 번의 길이
$easing: $ease-out;
$color: $error;
@include stage($stage-orange);
.ui { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.ui__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-ink; }
.ui__input {
box-sizing: border-box;
width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid rgba(23, 20, 26, .2);
background: #fff;
color: $subject-ink;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.ui__msg { margin: 0; color: $subject-ink; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
@keyframes ui-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-7px); }
4% { transform: translateX(7px); }
6% { transform: translateX(-7px); }
8% { transform: translateX(7px); }
10% { transform: translateX(-5px); }
12% { transform: translateX(5px); }
14% { transform: translateX(-3px); }
16% { transform: translateX(3px); }
}
// 실제 동작: JS 없이 :user-invalid 만으로 흔들린다. 값을 만지고 벗어난 뒤에만 켜진다
.ui__input:user-invalid { border-color: $color; animation: ui-shake $duration $easing; }
// 데모 루프: :user-invalid 활성화 타이밍이 브라우저마다 달라 .is-demo 로 강제한다
.ui.is-demo .ui__input { animation: ui-shake $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.ui__input:user-invalid,
.ui.is-demo .ui__input { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ui {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.ui__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.ui__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid rgba(23, 20, 26, 0.2);
background: #fff;
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.ui__msg {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes ui-shake {
0%, 22%, 100% {
transform: translateX(0);
}
2% {
transform: translateX(-7px);
}
4% {
transform: translateX(7px);
}
6% {
transform: translateX(-7px);
}
8% {
transform: translateX(7px);
}
10% {
transform: translateX(-5px);
}
12% {
transform: translateX(5px);
}
14% {
transform: translateX(-3px);
}
16% {
transform: translateX(3px);
}
}
.ui__input:user-invalid {
border-color: #c2361a;
animation: ui-shake 500ms ease-out;
}
.ui.is-demo .ui__input {
animation: ui-shake 500ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.ui__input:user-invalid,
.ui.is-demo .ui__input {
animation: none !important;
}
}// CSS 만으로는 :user-invalid 가 "사용자가 값을 만지고 벗어난 뒤" 에만 켜진다.
// 데모에서 그 상태를 보여주려고 포커스·입력·블러를 흉내만 낸다 (JS 4줄).
var el = document.getElementById('ui-email');
el.focus({ preventScroll: true });
el.dispatchEvent(new Event('input'));
el.blur();import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./UserInvalidShake.module.scss";
export type UserInvalidShakeProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 는 :user-invalid 를 직접 읽지 못해, onBlur 에서 checkValidity() 로 같은 순간을 흉내 낸다.
// 실제 zip 의 vanilla 판은 JS 없이 CSS 의사 클래스 하나로 끝난다 — 이 파일은 React 이식일 뿐이다.
export default function UserInvalidShake({ duration = 500, easing = "ease-out", color = "#c2361a" }: UserInvalidShakeProps) {
const [touched, setTouched] = useState(true);
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const onBlur = (e: ChangeEvent<HTMLInputElement>) => {
setTouched(!e.target.checkValidity());
setRun((n) => n + 1);
};
return (
<div className={styles.ui} style={vars}>
<label className={styles.label} htmlFor="ui-email">이메일</label>
<input
key={run}
id="ui-email"
className={`${styles.input} ${touched ? styles.shake : ""}`}
type="email"
required
pattern=".+@.+\..+"
defaultValue="broken"
onBlur={onBlur}
/>
<p className={styles.msg}>올바른 이메일 주소를 입력하세요</p>
</div>
);
}07 · Shake via :user-invalid — Measured area 0.507% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
An empty required field stays quiet on page load, and only starts shaking after someone types something and clicks away.
.ui__input:user-invalid {
border-color: $color;
animation: ui-shake $duration $easing;
}
@keyframes ui-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-7px); }
4% { transform: translateX(7px); }
6% { transform: translateX(-7px); }
8% { transform: translateX(7px); }
10% { transform: translateX(-5px); }
12% { transform: translateX(5px); }
14% { transform: translateX(-3px); }
16% { transform: translateX(3px); }
}
08One-shot shake reset pattern
What the user notices
Press "check again" after a wrong password twice in a row, and the field restarts its shake from zero both times, not just the first.

<main class="stage">
<div class="rs is-demo">
<label class="rs__label" for="rs-pw">Password</label>
<input class="rs__input" id="rs-pw" type="password" value="wrong1" readonly>
<button class="rs__btn" type="button" id="rs-check">Check again</button>
<p class="rs__msg">Wrong password</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 500ms; // 흔들림 한 번의 길이
$easing: $ease-out;
$color: $error;
@include stage($stage-paper);
.rs { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.rs__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-ink; }
.rs__input {
box-sizing: border-box;
width: 100%;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid $color;
background: #fff;
color: $subject-ink;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.rs__btn {
align-self: flex-start;
padding: $sp-1 $sp-3;
border: 1px solid rgba(23, 20, 26, .3);
border-radius: $r-control;
background: none;
color: $subject-ink;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.rs__msg { margin: 0; color: $color; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
@keyframes rs-shake {
0%, 100% { transform: translateX(0); }
10% { transform: translateX(-7px); }
20% { transform: translateX(6px); }
30% { transform: translateX(-5px); }
40% { transform: translateX(4px); }
50% { transform: translateX(-3px); }
60% { transform: translateX(2px); }
70% { transform: translateX(-1px); }
80% { transform: translateX(1px); }
}
// 실제 동작: "다시 확인" 을 연속으로 눌러도 리플로우 트릭 덕분에 매번 새로 흔들린다
.rs__input.rs--shake { animation: rs-shake $duration $easing; }
// 데모 루프: 같은 흔들림을 반복해 "매번 처음부터" 를 보여준다
.rs.is-demo .rs__input { animation: rs-shake $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.rs__input.rs--shake,
.rs.is-demo .rs__input { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.rs__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.rs__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #c2361a;
background: #fff;
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.rs__btn {
align-self: flex-start;
padding: 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 12px;
background: none;
color: #17141a;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.rs__msg {
margin: 0;
color: #c2361a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes rs-shake {
0%, 100% {
transform: translateX(0);
}
10% {
transform: translateX(-7px);
}
20% {
transform: translateX(6px);
}
30% {
transform: translateX(-5px);
}
40% {
transform: translateX(4px);
}
50% {
transform: translateX(-3px);
}
60% {
transform: translateX(2px);
}
70% {
transform: translateX(-1px);
}
80% {
transform: translateX(1px);
}
}
.rs__input.rs--shake {
animation: rs-shake 500ms ease-out;
}
.rs.is-demo .rs__input {
animation: rs-shake 500ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.rs__input.rs--shake,
.rs.is-demo .rs__input {
animation: none !important;
}
}// 같은 클래스를 다시 붙이기만 하면 애니메이션이 재생되지 않는다.
// 지우고 → 강제 리플로우(offsetWidth) → 다시 붙이면 매번 처음부터 흔들린다 (JS 6줄).
var input = document.getElementById('rs-pw');
document.getElementById('rs-check').addEventListener('pointerdown', function () {
input.classList.remove('rs--shake');
void input.offsetWidth;
input.classList.add('rs--shake');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShakeOnceReset.module.scss";
export type ShakeOnceResetProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// DOM 판의 리플로우 트릭 대신, React 는 key 를 바꿔 요소를 통째로 새로 마운트한다 —
// 같은 효과(매번 처음부터 재생)를 React 답게 낸다.
export default function ShakeOnceReset({ duration = 500, easing = "ease-out", color = "#c2361a" }: ShakeOnceResetProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.rs} style={vars}>
<label className={styles.label} htmlFor="rs-pw">비밀번호</label>
<input key={run} id="rs-pw" className={`${styles.input} ${styles.shake}`} type="password" defaultValue="wrong1" readOnly />
<button type="button" className={styles.btn} onClick={() => setRun((n) => n + 1)}>다시 확인</button>
<p className={styles.msg}>비밀번호가 틀렸습니다</p>
</div>
);
}08 · One-shot shake reset pattern — Measured area 0.56% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
Re-adding the same class right after removing it doesn't replay a keyframe that already finished, so a few lines of JavaScript force a reflow in between to reset it.
var input = document.getElementById('rs-pw');
document.getElementById('rs-check').addEventListener('pointerdown', function () {
input.classList.remove('rs--shake');
void input.offsetWidth;
input.classList.add('rs--shake');
});
09Outline fallback for reduced motion
Where this shows up
By default, a textarea shakes the same way item 01 does when a message is too short.

<main class="stage">
<div class="rm is-demo">
<label class="rm__label" for="rm-msg">Message</label>
<div class="rm__field">
<textarea class="rm__textarea" id="rm-msg" rows="2"></textarea>
<span class="rm__ring" aria-hidden="true"></span>
</div>
<p class="rm__msg">Must be at least 10 characters</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 500ms; // 평소 흔들림 한 번의 길이
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.rm { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.rm__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-cream; }
.rm__field { position: relative; }
.rm__textarea {
box-sizing: border-box;
width: 100%;
resize: none;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid $color;
background: rgba(255, 255, 255, .06);
color: $subject-cream;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.rm__ring {
position: absolute;
inset: -$sp-1;
border: 2px solid $color;
border-radius: $r-sm;
opacity: 0;
pointer-events: none;
}
.rm__msg { margin: 0; color: $error-soft; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
// 평소: translateX 로 흔든다
@keyframes rm-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-6px); }
4% { transform: translateX(6px); }
6% { transform: translateX(-6px); }
8% { transform: translateX(6px); }
10% { transform: translateX(-4px); }
12% { transform: translateX(4px); }
14% { transform: translateX(-2px); }
16% { transform: translateX(2px); }
}
.rm__textarea.rm--invalid { animation: rm-shake $duration $easing; }
.rm.is-demo .rm__textarea { animation: rm-shake $duration $easing infinite; }
// 모션 축소: 흔들림 대신 opacity 로 링만 두 번 깜빡인다 — 움직임은 없지만 오류는 여전히 보인다
@keyframes rm-ring-pulse {
0%, 100% { opacity: 0; }
25% { opacity: 1; }
50% { opacity: 0; }
75% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.rm__textarea.rm--invalid,
.rm.is-demo .rm__textarea { animation: none !important; }
.rm__ring.rm--invalid { animation: rm-ring-pulse $dur-loop ease-in-out; }
.rm.is-demo .rm__ring { animation: rm-ring-pulse $dur-loop ease-in-out infinite; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rm {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.rm__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.rm__field {
position: relative;
}
.rm__textarea {
box-sizing: border-box;
width: 100%;
resize: none;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #c2361a;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.rm__ring {
position: absolute;
inset: -4px;
border: 2px solid #c2361a;
border-radius: 8px;
opacity: 0;
pointer-events: none;
}
.rm__msg {
margin: 0;
color: #ffe0d8;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes rm-shake {
0%, 22%, 100% {
transform: translateX(0);
}
2% {
transform: translateX(-6px);
}
4% {
transform: translateX(6px);
}
6% {
transform: translateX(-6px);
}
8% {
transform: translateX(6px);
}
10% {
transform: translateX(-4px);
}
12% {
transform: translateX(4px);
}
14% {
transform: translateX(-2px);
}
16% {
transform: translateX(2px);
}
}
.rm__textarea.rm--invalid {
animation: rm-shake 500ms ease-out;
}
.rm.is-demo .rm__textarea {
animation: rm-shake 500ms ease-out infinite;
}
@keyframes rm-ring-pulse {
0%, 100% {
opacity: 0;
}
25% {
opacity: 1;
}
50% {
opacity: 0;
}
75% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__textarea.rm--invalid,
.rm.is-demo .rm__textarea {
animation: none !important;
}
.rm__ring.rm--invalid {
animation: rm-ring-pulse 2s ease-in-out;
}
.rm.is-demo .rm__ring {
animation: rm-ring-pulse 2s ease-in-out infinite;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ReducedMotionOutline.module.scss";
export type ReducedMotionOutlineProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ReducedMotionOutline({ duration = 500, easing = "ease-out", color = "#c2361a" }: ReducedMotionOutlineProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.rm} style={vars}>
<label className={styles.label} htmlFor="rm-msg">문의 내용</label>
<div className={styles.field}>
<textarea key={run} id="rm-msg" rows={2} className={`${styles.textarea} ${styles.invalid}`} onFocus={() => setRun((n) => n + 1)} />
<span key={`ring-${run}`} className={`${styles.ring} ${styles.invalid}`} aria-hidden="true" />
</div>
<p className={styles.msg}>10자 이상 입력해야 합니다</p>
</div>
);
}09 · Outline fallback for reduced motion — Measured area 0.336% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Switch on reduced motion at the system level, though, and the shake swaps for a red ring pulsing through opacity twice instead, so turning off motion doesn't also turn off the warning.
@keyframes rm-ring-pulse {
0%, 100% { opacity: 0; }
25% { opacity: 1; }
50% { opacity: 0; }
75% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.rm__textarea.rm--invalid { animation: none !important; }
.rm__ring.rm--invalid { animation: rm-ring-pulse $dur-loop ease-in-out; }
}
Where does the shake break down?
What goes wrong
Eight of the nine animations above — the rotate-driven css wiggle animation in 02 included — share one quirk: once a CSS animation finishes, re-adding the same trigger class doesn't start it over, so a second identical failure produces no shake at all. Item 08's remove-reflow-add sequence is the fix for that, and the same three steps carry over to any of the other seven that need it.
How to avoid it
Neighboring elements appearing to shift as the shake amplitude grows uses the same arithmetic as spacing the deliberately overlapping layers in the collage maximalist landing kit.
The one exception is 07's :user-invalid, which recomputes on every value change and needs no workaround at all. The full vanilla and React source for all nine opens with 3v34eb5k, typed exactly as it reads here with nothing added or left out.
Accessibility
What reduced motion leaves
01 through 09 all drop their animation to none under prefers-reduced-motion: reduce, but 04 and 09 don't stop there — since motion is their only signal, both swap in an opacity pulse so the error still shows up once shaking is off the table. Browser support for :user-invalid covers Chrome/Edge 119+, Firefox 88+, and Safari 16.4+ per MDN; older browsers simply never match the selector, so a payment or signup form that can't afford a missed error should lean on the class-based pattern from 01 or 05 instead.
What else to check
A wider set of always-on keyframe classes, one of them a plain shake, lives in 9 CSS keyframe animations, and everything else this site has published sits in the CSS category hub.
FAQ
Why do shake animations use transform instead of margin or left?
transform and opacity skip layout entirely — the browser repaints pixels it already has instead of recalculating position for the shaken element and everything sitting next to it. Animating margin or left recalculates layout on every frame, which runs slower and can nudge neighboring fields along with the one that's actually wrong.
What's the difference between :invalid and :user-invalid?
:invalid matches the moment a page loads if a field's value fails validation, even a field nobody has touched. :user-invalid only matches after someone has typed into that field and moved on, so an empty required field doesn't turn red before anyone had a chance to fill it in.
How do I make a css error shake animation replay on a second identical error?
Remove the trigger class, force a reflow by reading a property like offsetWidth, then add the class back — the pattern in item 08 — and the browser treats it as a brand-new animation instead of an already-finished one. In React, swapping the element's key remounts it from scratch instead and gets the same restart without touching the DOM directly. If the page needs the whole success-and-failure vocabulary rather than one shake, the form validation CSS animations are that set.









