CSS 흔들리는 애니메이션 9종 — 폼 에러 표시
비밀번호를 잘못 입력했을 때 입력창이 좌우로 흔들리는 걸 본 적 있으신가요? 그 동작이 바로 css 흔들리는 애니메이션입니다 — 자바스크립트 없이 CSS 코드 몇 줄만으로 폼이 틀렸다는 걸 눈에 띄게 알려줍니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 좌우 흔들림
- 02 비틀거림 회전
- 03 젤리처럼 출렁
- 04 오류 테두리 플래시
- 05 입력창 헤드셰이크
- 06 경고 아이콘 바운스
- 07 :user-invalid 자동 흔들림
- 08 1회 재생 리셋 패턴
- 09 모션 축소 시 아웃라인 대체
아홉 개는 인기 순이 아니라 오류가 어디서 나느냐를 따라간 순서입니다. 입력값 자체가 틀렸을 때(01~03)에서 화면 요소만 반짝이는 절제된 신호(04)로, 그다음은 비밀번호처럼 더 무거운 실패(05~06)를 거쳐, 브라우저가 스스로 처리하는 경우(07)와 같은 실패가 반복될 때(08), 마지막은 움직임을 줄인 사람에게도 신호가 남아야 하는 문제(09) 순입니다. 틀렸다고 알리는 대신 맞는 곳을 짚어 주는 움직임이 필요하면 텍스트 강조 애니메이션이 그 반대 신호를 담당하고, 에러 색을 빛으로 키우는 쪽은 CSS 글로우 효과가 가져갑니다.
01좌우 흔들림
css shake div 는 무엇을 흔드나
이메일 칸에 형식이 틀린 값을 넣고 다른 곳을 클릭하면 입력창 전체가 좌우로 짧게 흔들리다 멈춥니다.

<main class="stage">
<div class="sh is-demo">
<label class="sh__label" for="sh-email">이메일</label>
<input class="sh__input" id="sh-email" type="email" value="wrong@mail" aria-invalid="true" readonly>
<p class="sh__msg">올바른 이메일 형식이 아닙니다</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 · 좌우 흔들림 — 측정 누적면적 0.494% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
translateX 값을 -7px→7px→-5px→3px→0px 순서로 나열해 진폭이 갈수록 줄어드는 @keyframes 하나가 전부라, 실제 떨림처럼 보입니다.
@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); }
}
.sh__input.sh--invalid { animation: sh-shake $duration $easing; }
02비틀거림 회전
어떻게 보이나
약관 체크박스를 누르지 않고 제출을 누르면 체크박스와 문구가 함께 담긴 줄 전체가 좌우로 기우뚱거립니다.

<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">이용약관에 동의합니다</span>
</label>
<p class="wg__msg">계속하려면 약관에 동의해야 합니다</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 · 비틀거림 회전 — 측정 누적면적 1.942% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
rotate 각도만 바꾸는 @keyframes 라 체크박스 모양 자체는 전혀 건드리지 않습니다.
@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; }
03젤리처럼 출렁
언제 쓰나
저장 버튼을 눌렀는데 실패하면 버튼이 젤리처럼 출렁이다 가라앉습니다.

<main class="stage">
<div class="je is-demo">
<button class="je__btn" type="button">저장</button>
<p class="je__msg">저장하지 못했습니다. 다시 시도하세요</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 · 젤리처럼 출렁 — 측정 누적면적 1.069% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
skewX와 skewY를 같은 키프레임 안에서 함께 틀어 비스듬한 두 방향으로 동시에 찌그러지는 게 핵심입니다.
@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; }
04오류 테두리 플래시
어떤 화면인가
카드 번호 칸 자체는 움직이지 않고, 뒤에 겹쳐 둔 빨간 테두리 레이어만 두 번 반짝입니다.

<main class="stage">
<div class="bf is-demo">
<div class="bf__field">
<input class="bf__input" type="text" placeholder="카드 번호" value="1234">
<span class="bf__ring" aria-hidden="true"></span>
</div>
<p class="bf__msg">카드 번호를 확인하세요</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 · 오류 테두리 플래시 — 측정 누적면적 0.869% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
입력창까지 흔들리면 옆에 나란히 있는 다른 칸들도 함께 흔들리는 것처럼 보일 수 있어, opacity만 바꿔 레이아웃은 그대로 둡니다.
.bf__ring {
position: absolute;
inset: -$sp-1;
border: 2px solid $color;
border-radius: $r-sm;
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; }
05입력창 헤드셰이크
무엇이 움직이나
비밀번호 확인 칸이 일치하지 않으면 입력창이 마치 고개를 젓듯 입체적으로 살짝 돌아갑니다.

<main class="stage">
<div class="hs is-demo">
<label class="hs__label" for="hs-pw">비밀번호 확인</label>
<input class="hs__input" id="hs-pw" type="password" value="wrongpass" readonly>
<p class="hs__msg">비밀번호가 일치하지 않습니다</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 · 입력창 헤드셰이크 — 측정 누적면적 2.372% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
translateX와 rotateY를 같은 키프레임에서 함께 쓰고, 부모에 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; }
06경고 아이콘 바운스
어디에 쓰나
느낌표가 그려진 원형 아이콘이 튀어 올랐다가 착지하며 시선을 오류 메시지 쪽으로 끕니다.
<main class="stage">
<div class="ib is-demo">
<span class="ib__icon" aria-hidden="true">!</span>
<p class="ib__msg">필수 항목입니다</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 · 경고 아이콘 바운스 — 측정 누적면적 0.726% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
scale과 translateY를 같이 걸어 통통 튀는 인상을 만들고, 아이콘은 장식이라 aria-hidden으로 처리했습니다.
@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; }
07:user-invalid 자동 흔들림
눈에 보이는 것
CSS 쪽 코드는 JS를 한 줄도 안 쓰고, 사용자가 이메일 칸에 값을 넣고 벗어난 뒤 형식이 틀렸을 때만 브라우저가 스스로 흔들어 줍니다.

<main class="stage">
<div class="ui is-demo">
<label class="ui__label" for="ui-email">이메일</label>
<input class="ui__input" id="ui-email" type="email" required pattern=".+@.+\..+" value="broken">
<p class="ui__msg">올바른 이메일 주소를 입력하세요</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 · :user-invalid 자동 흔들림 — 측정 누적면적 0.507% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
input:user-invalid 의사 클래스 하나에 animation을 걸면 끝이고, 값을 만지기 전이나 페이지가 막 열렸을 때는 조용합니다.
.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); }
}
081회 재생 리셋 패턴
어떤 상황인가
"다시 확인"을 연달아 눌러도 매번 처음부터 새로 흔들립니다.

<main class="stage">
<div class="rs is-demo">
<label class="rs__label" for="rs-pw">비밀번호</label>
<input class="rs__input" id="rs-pw" type="password" value="wrong1" readonly>
<button class="rs__btn" type="button" id="rs-check">다시 확인</button>
<p class="rs__msg">비밀번호가 틀렸습니다</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 · 1회 재생 리셋 패턴 — 측정 누적면적 0.56% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
같은 클래스를 지우고 곧바로 다시 붙이면 브라우저가 재생 중이던 애니메이션으로 착각해 무시하기 때문에, 지운 뒤 offsetWidth를 한 번 읽어 강제로 리플로우를 일으키고 나서 다시 붙입니다.
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');
});
09모션 축소 시 아웃라인 대체
무슨 일이 일어나나
평소에는 01과 같은 방식으로 흔들리지만, 시스템에서 동작 줄이기를 켜면 흔들림 대신 빨간 테두리 링이 두 번 깜빡이는 것으로 바뀝니다.

<main class="stage">
<div class="rm is-demo">
<label class="rm__label" for="rm-msg">문의 내용</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">10자 이상 입력해야 합니다</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 · 모션 축소 시 아웃라인 대체 — 측정 누적면적 0.336% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
움직임을 꺼도 오류가 있다는 신호까지 함께 사라지지 않게 하려는 것입니다.
@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; }
}
@keyframes rm-ring-pulse {
0%, 100% { opacity: 0; }
25% { opacity: 1; }
50% { opacity: 0; }
75% { opacity: 1; }
}
어디서 깨지는가 — 함정 하나
무엇이 문제인가
입력창을 통째로 흔들든 css shake text 처럼 글자만 흔들든, 여덟 개(01~07·09)는 CSS 애니메이션이 한 번 재생되면 끝이라는 공통 함정을 가지고 있습니다. 같은 값으로 또 틀려도 클래스가 이미 붙어 있으면 브라우저는 "이미 재생 중이던 애니메이션"으로 보고 다시 틀어 주지 않습니다. 08번 항목이 이 문제를 classList.remove() → offsetWidth 강제 읽기 → classList.add() 세 단계로 고친 코드를 담고 있고, 나머지 항목에도 같은 패턴을 그대로 옮겨 쓸 수 있습니다.
어떻게 피하나
떨림 폭이 커질수록 주변 요소까지 밀려 보이는 이 현상은, 조각들을 일부러 겹쳐 붙이는 콜라주 맥시멀리즘 랜딩에서 레이어 간격을 잡을 때와 같은 계산을 씁니다.
:user-invalid(07)를 쓴다면 이 문제 자체가 없습니다 — 값이 바뀔 때마다 브라우저가 상태를 다시 계산해 스스로 재생하기 때문입니다. 아홉 개 전체 vanilla·React 코드가 든 압축 파일은 3v34eb5k으로 열립니다.
접근성
reduced-motion 에서 남는 것
아홉 개 모두 prefers-reduced-motion: reduce에서 animation: none !important으로 움직임을 끕니다. 다만 04·09처럼 움직임이 유일한 오류 신호인 항목은 애니메이션만 끄면 오류 자체가 안 보일 수 있어, 09는 흔들림 대신 opacity로 깜빡이는 테두리 링을 대신 켜 두고 나머지 항목은 문구 텍스트가 항상 함께 보이게 했습니다. :user-invalid 같은 네이티브 검증 의사 클래스는 MDN 문서에 따르면 Chrome/Edge 119+·Firefox 88+·Safari 16.4+에서 동작하고, 그 이전 버전은 조용히 무시되니 결제·회원가입처럼 놓치면 안 되는 폼은 01·05 같은 클래스 기반 방식을 기본으로 두는 걸 권합니다.
이어서 볼 글
탭이 바뀔 때 인디케이터가 미끄러지는 동작이 궁금하면 CSS 탭 메뉴 전환 애니메이션 9종을, 이 사이트가 오류 신호 말고 또 뭘 다루는지는 CSS 애니메이션 카테고리에서 볼 수 있습니다.
FAQ
흔들림 애니메이션은 왜 opacity나 transform만 써야 하나요?
margin이나 left 같은 속성을 흔들면 브라우저가 매 프레임 레이아웃을 다시 계산해야 해서 버벅이고, 흔들리는 요소 옆 칸까지 따라 밀립니다. transform과 opacity는 레이아웃을 다시 계산하지 않고 화면에 이미 그려진 픽셀만 옮기거나 흐리게 해서 끝나 훨씬 매끄럽습니다.
:user-invalid랑 :invalid는 뭐가 다른가요?
:invalid는 값이 비어 있거나 형식이 틀리면 페이지가 열리자마자 곧바로 매치됩니다. :user-invalid는 사용자가 실제로 값을 만지고 포커스를 벗어난 뒤에만 매치되어서, 아직 아무것도 입력 안 한 빈칸을 열자마자 빨갛게 흔드는 실수를 막아 줍니다.
흔들리는 효과를 한 번만 재생하려면 어떻게 하나요?
08번처럼 클래스를 지우고 offsetWidth를 한 번 읽어 강제로 리플로우를 일으킨 뒤 다시 클래스를 붙이면 같은 클래스라도 매번 처음부터 재생됩니다. React라면 이 대신 요소의 key를 바꿔 통째로 새로 마운트하는 쪽이 더 자연스럽습니다. 흔들림 하나가 아니라 입력 검증 전체의 성공·실패 표시가 필요하다면 CSS 유효성 검사 애니메이션이 그 묶음입니다.









