9 Form Validation CSS Animations You Can Paste
Form validation CSS animations are small signals — color, icon, glow — that show a field is right or wrong, no shaking required.
01 form-validation-feedback-realtime-inline-message
02 form-validation-feedback-label-color-shift
03 form-validation-feedback-field-glow-ring
04 form-validation-feedback-success-bounce-check
05 form-validation-feedback-error-border-pulse
07 form-validation-feedback-password-strength-fill
08 form-validation-feedback-submit-shake-success-morph
09 form-validation-feedback-reduced-motion-safe
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Realtime inline error message
- 02 Label color crossfade
- 03 Field validation glow ring
- 04 Success badge bounce check
- 05 Error field breathing ring
- 06 Status icon spinner-to-check/cross morph
- 07 3-tier password strength fill
- 08 Submit button loading-to-success morph
- 09 Static fallback for reduced motion
The order of this css input validation set follows what a person actually does while filling out a form, not popularity. A real-time message while typing (01) leads into a nickname-availability swap (02), a glow that rises once a field passes (03), and a save badge (04); then an error that refuses to go away (05), a status icon mid-check (06), a password meter (07), a submit button's full journey (08), and finally what's left once someone turns motion down (09). In the same feedback pillar, saying it all with one small label is 9 CSS Badge Design Ideas, and recutting those labels along status and priority is Status Badge Design UI.
01Real-time inline error message
What you see in css form validation
Type a badly formatted email and the message line reserved underneath the field slides down slightly, then fades back out. As the message lands, a sub-pixel translateX damped oscillation passes through the field once, and a glow wave spreading outward from the border underlines the error a second time.

<main class="stage">
<div class="fv1 is-demo">
<label class="fv1__label" for="fv1-email">Email</label>
<input class="fv1__input" id="fv1-email" type="email" value="wrong@mail" readonly aria-invalid="true">
<p class="fv1__msg" role="alert">Not a valid email format</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 실시간 인라인 에러 메시지: opacity + translateY 만 쓴다
$duration: $dur-loop;
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.fv1 { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.fv1__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-cream; }
.fv1__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;
}
.fv1__msg {
margin: 0; color: $color; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit;
opacity: 0; transform: translateY(-6px);
}
@keyframes fv1-in {
0%, 6% { opacity: 0; transform: translateY(-6px); }
28%, 78% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-6px); }
}
.fv1.is-demo .fv1__msg { animation: fv1-in $duration $easing infinite; }
// 한 겹(감쇠 진동): 메시지 등장(28%) 순간 필드에 미세 translateX 감쇠 shake + 테두리 글로우가 box-shadow spread 로 한 번 번지는 surge 파동
// (input 요소엔 ::after 가 렌더되지 않으므로 링은 box-shadow spread 로 구현 — border-color 불변)
.fv1.is-demo .fv1__input {
animation: fv1-shake $duration $easing infinite, fv1-surge $duration $easing infinite;
}
@keyframes fv1-shake {
0%, 28%, 34%, 100% { transform: translateX(0); }
29.5% { transform: translateX(.6px); }
31% { transform: translateX(-.4px); }
32.5% { transform: translateX(.2px); }
}
@keyframes fv1-surge {
0%, 28% { box-shadow: 0 0 0 0 rgba($color, 0); }
30% { box-shadow: 0 0 0 6px rgba($color, .45); }
32% { box-shadow: 0 0 0 2px rgba($color, .2); }
34% { box-shadow: 0 0 0 3.5px rgba($color, .28); }
38%, 100% { box-shadow: 0 0 0 0 rgba($color, 0); }
}
@media (prefers-reduced-motion: reduce) {
.fv1.is-demo .fv1__msg { animation: none !important; opacity: 1; transform: translateY(0); }
.fv1.is-demo .fv1__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);
}
.fv1 {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.fv1__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.fv1__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;
}
.fv1__msg {
margin: 0;
color: #c2361a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(-6px);
}
@keyframes fv1-in {
0%, 6% {
opacity: 0;
transform: translateY(-6px);
}
28%, 78% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-6px);
}
}
.fv1.is-demo .fv1__msg {
animation: fv1-in 2s ease-out infinite;
}
.fv1.is-demo .fv1__input {
animation: fv1-shake 2s ease-out infinite, fv1-surge 2s ease-out infinite;
}
@keyframes fv1-shake {
0%, 28%, 34%, 100% {
transform: translateX(0);
}
29.5% {
transform: translateX(0.6px);
}
31% {
transform: translateX(-0.4px);
}
32.5% {
transform: translateX(0.2px);
}
}
@keyframes fv1-surge {
0%, 28% {
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0);
}
30% {
box-shadow: 0 0 0 6px rgba(194, 54, 26, 0.45);
}
32% {
box-shadow: 0 0 0 2px rgba(194, 54, 26, 0.2);
}
34% {
box-shadow: 0 0 0 3.5px rgba(194, 54, 26, 0.28);
}
38%, 100% {
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.fv1.is-demo .fv1__msg {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
.fv1.is-demo .fv1__input {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./RealtimeInlineMessage.module.scss";
export type RealtimeInlineMessageProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RealtimeInlineMessage({ duration = 2000, easing = "ease-out", color = "#c2361a" }: RealtimeInlineMessageProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv1} style={vars}>
<label className={styles.label} htmlFor="fv1-email">이메일</label>
<input id="fv1-email" className={styles.input} type="email" defaultValue="wrong@mail" readOnly aria-invalid="true" />
<p className={styles.msg} role="alert">올바른 이메일 형식이 아니에요</p>
</div>
);
}01 · Realtime inline error message — Measured area 3.469% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The keyframe only touches opacity and translateY, so the input's own height never shifts while the message appears.
.fv1__msg {
opacity: 0;
transform: translateY(-6px);
}
@keyframes fv1-in {
0%, 6% { opacity: 0; transform: translateY(-6px); }
28%, 78% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-6px); }
}
.fv1.is-demo .fv1__msg { animation: fv1-in $duration $easing infinite; }
02Label color crossfade
What happens on screen
The label above a nickname field swaps from a gray "Nickname" to a mint "This nickname is available." Nothing transitions color directly — two labels sit stacked in the same spot and hand off through opacity. At the swap, a damped shake crosses the field once and a mint glow spreads and settles.

<main class="stage">
<div class="fv2 is-demo">
<span class="fv2__label-wrap">
<span class="fv2__label fv2__label--neutral">Nickname</span>
<span class="fv2__label fv2__label--ok">This nickname is available</span>
</span>
<input class="fv2__input" type="text" value="godrich_dev" readonly>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 닉네임 라벨 두 장을 겹쳐두고, 검증 상태에 따라 opacity 만 서로 교차한다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-yellow);
.fv2 { width: min(240px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.fv2__label-wrap { position: relative; height: 14px; display: block; }
.fv2__label {
position: absolute; left: 0; top: 0; font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit;
white-space: nowrap;
}
.fv2__label--neutral { color: $subject-ink; opacity: .6; }
.fv2__label--ok { color: $color; opacity: 0; }
.fv2__input {
box-sizing: border-box; width: 100%; padding: $sp-2 $sp-3; border-radius: $r-sm;
border: 2px solid $subject-ink; background: rgba(23, 20, 26, .06);
color: $subject-ink; font-weight: 400; font-size: 14px; line-height: 1; font-family: inherit;
}
@keyframes fv2-ok {
0%, 20% { opacity: 0; }
40%, 90% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes fv2-neutral {
0%, 20% { opacity: .6; }
40%, 90% { opacity: 0; }
100% { opacity: .6; }
}
.fv2.is-demo .fv2__label--ok { animation: fv2-ok $duration $easing infinite; }
.fv2.is-demo .fv2__label--neutral { animation: fv2-neutral $duration $easing infinite; }
// 한 겹(감쇠 진동): ok 라벨 등장(40%) 순간 필드에 미세 translateX 감쇠 shake + 민트 글로우가 box-shadow spread 로 한 번 번지는 surge 파동
// (input 요소엔 ::after 가 렌더되지 않으므로 링은 box-shadow spread 로 구현)
.fv2.is-demo .fv2__input {
animation: fv2-shake $duration $easing infinite, fv2-surge $duration $easing infinite;
}
@keyframes fv2-shake {
0%, 40%, 46%, 100% { transform: translateX(0); }
41.5% { transform: translateX(.8px); }
43% { transform: translateX(-.5px); }
44.5% { transform: translateX(.25px); }
}
@keyframes fv2-surge {
0%, 40% { box-shadow: 0 0 0 0 rgba($subject-mint, 0); }
42% { box-shadow: 0 0 0 6px rgba($subject-mint, .5); }
44% { box-shadow: 0 0 0 2px rgba($subject-mint, .22); }
46% { box-shadow: 0 0 0 3.5px rgba($subject-mint, .3); }
50%, 100% { box-shadow: 0 0 0 0 rgba($subject-mint, 0); }
}
@media (prefers-reduced-motion: reduce) {
.fv2.is-demo .fv2__label--ok,
.fv2.is-demo .fv2__label--neutral { animation: none !important; }
.fv2.is-demo .fv2__label--ok { opacity: 1; }
.fv2.is-demo .fv2__label--neutral { opacity: 0; }
.fv2.is-demo .fv2__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fv2 {
width: min(240px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.fv2__label-wrap {
position: relative;
height: 14px;
display: block;
}
.fv2__label {
position: absolute;
left: 0;
top: 0;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
white-space: nowrap;
}
.fv2__label--neutral {
color: #17141a;
opacity: 0.6;
}
.fv2__label--ok {
color: #4cd4a6;
opacity: 0;
}
.fv2__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #17141a;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
@keyframes fv2-ok {
0%, 20% {
opacity: 0;
}
40%, 90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fv2-neutral {
0%, 20% {
opacity: 0.6;
}
40%, 90% {
opacity: 0;
}
100% {
opacity: 0.6;
}
}
.fv2.is-demo .fv2__label--ok {
animation: fv2-ok 2s ease-out infinite;
}
.fv2.is-demo .fv2__label--neutral {
animation: fv2-neutral 2s ease-out infinite;
}
.fv2.is-demo .fv2__input {
animation: fv2-shake 2s ease-out infinite, fv2-surge 2s ease-out infinite;
}
@keyframes fv2-shake {
0%, 40%, 46%, 100% {
transform: translateX(0);
}
41.5% {
transform: translateX(0.8px);
}
43% {
transform: translateX(-0.5px);
}
44.5% {
transform: translateX(0.25px);
}
}
@keyframes fv2-surge {
0%, 40% {
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
42% {
box-shadow: 0 0 0 6px rgba(76, 212, 166, 0.5);
}
44% {
box-shadow: 0 0 0 2px rgba(76, 212, 166, 0.22);
}
46% {
box-shadow: 0 0 0 3.5px rgba(76, 212, 166, 0.3);
}
50%, 100% {
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.fv2.is-demo .fv2__label--ok,
.fv2.is-demo .fv2__label--neutral {
animation: none !important;
}
.fv2.is-demo .fv2__label--ok {
opacity: 1;
}
.fv2.is-demo .fv2__label--neutral {
opacity: 0;
}
.fv2.is-demo .fv2__input {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./LabelColorShift.module.scss";
export type LabelColorShiftProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LabelColorShift({ duration = 2000, easing = "ease-out", color = "#4cd4a6" }: LabelColorShiftProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv2} style={vars}>
<span className={styles.wrap}>
<span className={`${styles.label} ${styles.neutral}`}>닉네임</span>
<span className={`${styles.label} ${styles.ok}`}>사용할 수 있는 닉네임이에요</span>
</span>
<input className={styles.input} type="text" defaultValue="godrich_dev" readOnly />
</div>
);
}02 · Label color crossfade — Measured area 3.418% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
@keyframes fv2-ok {
0%, 20% { opacity: 0; }
40%, 90% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes fv2-neutral {
0%, 20% { opacity: .6; }
40%, 90% { opacity: 0; }
100% { opacity: .6; }
}
.fv2.is-demo .fv2__label--ok { animation: fv2-ok $duration $easing infinite; }
.fv2.is-demo .fv2__label--neutral { animation: fv2-neutral $duration $easing infinite; }
03Field validation glow ring
What moves
Once a field passes validation, a soft mint ring rises behind it. Past its peak, the ring doesn't just cut out — successively weaker second and third ripples decay away like a wave settling.

<main class="stage">
<div class="fv3 is-demo">
<span class="fv3__field">
<input class="fv3__input" type="text" value="[email protected]" readonly>
<span class="fv3__glow" aria-hidden="true"></span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 검증을 통과한 필드 뒤에 글로우 링이 떠오른다: box-shadow 레이어의 opacity + scale 만 움직인다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-orange);
.fv3 { width: min(240px, 86vw); }
.fv3__field { position: relative; display: block; }
.fv3__glow {
position: absolute; inset: -6px; border-radius: $r-control;
box-shadow: 0 0 0 4px rgba(76, 212, 166, .55);
opacity: 0; transform: scale(.94); pointer-events: none;
}
.fv3__input {
position: relative; box-sizing: border-box; width: 100%;
padding: $sp-2 $sp-3; border-radius: $r-sm;
border: 2px solid $color; background: $subject-cream;
color: $subject-ink; font-weight: 400; font-size: 14px; line-height: 1; font-family: inherit;
}
// 한 겹(감쇠 진동): 45% 피크 뒤 단순 소멸 대신 52%·58%·64% 에 점점 약해지는 2·3차 파동이 잦아들며 사라진다
@keyframes fv3-glow {
0%, 15% { opacity: 0; transform: scale(.94); }
45% { opacity: 1; transform: scale(1.04); }
48.5% { opacity: .35; transform: scale(.985); }
52% { opacity: .62; transform: scale(1.015); }
55% { opacity: .18; transform: scale(.985); }
58% { opacity: .34; transform: scale(1.008); }
61% { opacity: .08; transform: scale(.992); }
64% { opacity: .16; transform: scale(1.004); }
68% { opacity: 0; transform: scale(.94); }
75%, 100% { opacity: 0; transform: scale(.94); }
}
.fv3.is-demo .fv3__glow { animation: fv3-glow $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv3.is-demo .fv3__glow { animation: none !important; opacity: .6; transform: scale(1); }
}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);
}
.fv3 {
width: min(240px, 86vw);
}
.fv3__field {
position: relative;
display: block;
}
.fv3__glow {
position: absolute;
inset: -6px;
border-radius: 12px;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.55);
opacity: 0;
transform: scale(0.94);
pointer-events: none;
}
.fv3__input {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #4cd4a6;
background: #fff7e6;
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
@keyframes fv3-glow {
0%, 15% {
opacity: 0;
transform: scale(0.94);
}
45% {
opacity: 1;
transform: scale(1.04);
}
48.5% {
opacity: 0.35;
transform: scale(0.985);
}
52% {
opacity: 0.62;
transform: scale(1.015);
}
55% {
opacity: 0.18;
transform: scale(0.985);
}
58% {
opacity: 0.34;
transform: scale(1.008);
}
61% {
opacity: 0.08;
transform: scale(0.992);
}
64% {
opacity: 0.16;
transform: scale(1.004);
}
68% {
opacity: 0;
transform: scale(0.94);
}
75%, 100% {
opacity: 0;
transform: scale(0.94);
}
}
.fv3.is-demo .fv3__glow {
animation: fv3-glow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv3.is-demo .fv3__glow {
animation: none !important;
opacity: 0.6;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./FieldGlowRing.module.scss";
export type FieldGlowRingProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FieldGlowRing({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#4cd4a6" }: FieldGlowRingProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv3} style={vars}>
<span className={styles.field}>
<input className={styles.input} type="text" defaultValue="[email protected]" readOnly />
<span className={styles.glow} aria-hidden="true" />
</span>
</div>
);
}03 · Field validation glow ring — Measured area 3.533% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
A box-shadow layer stacked behind the input combines opacity 0→1 with a scale(.94→1.04) swell to make the glow feel like it's inflating rather than snapping on.
.fv3__glow {
position: absolute;
inset: -6px;
border-radius: $r-control;
box-shadow: 0 0 0 4px rgba(76, 212, 166, .55);
opacity: 0;
transform: scale(.94);
}
@keyframes fv3-glow {
0%, 15% { opacity: 0; transform: scale(.94); }
45% { opacity: 1; transform: scale(1.04); }
75%, 100% { opacity: 0; transform: scale(.94); }
}
.fv3.is-demo .fv3__glow { animation: fv3-glow $duration $easing infinite; }
04Success badge bounce check
What you see
A circular badge bounces into place after a save, and the checkmark inside draws itself. The bounce carries a damped rotate wobble that settles side to side, while a glow ring spreads around the badge on the same beat.

<main class="stage">
<div class="fv4 is-demo">
<span class="fv4__badge">
<svg class="fv4__check" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline class="fv4__path" points="4,13 9,18 20,6" />
</svg>
</span>
<span class="fv4__text">Saved</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 성공 배지가 scale 로 바운스하며 나타나고, 그 안 체크는 stroke-dashoffset 로 그려진다
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-paper);
.fv4 { display: flex; align-items: center; gap: $sp-3; width: min(220px, 86vw); }
.fv4__badge {
position: relative; /* ::after 서지 링의 기준 — 위치·크기 불변 */
width: 40px; height: 40px; border-radius: 50%;
background: $color; display: grid; place-items: center; color: $subject-ink;
opacity: 0; transform: scale(.4);
}
.fv4__path { stroke-dasharray: 24; stroke-dashoffset: 24; }
// 한 겹(감쇠 진동): 배지 등장 순간 rotate 감쇠 진동은 fv4-badge transform 안에 합성(scale 뒤에 rotate 를 얹는다)
.fv4__badge::after {
content: ""; position: absolute; inset: -6px; border-radius: 50%;
box-shadow: 0 0 0 4px rgba($color, .5); opacity: 0; transform: scale(.8); pointer-events: none;
}
.fv4__text { font-weight: 600; font-size: 14px; line-height: 1; font-family: inherit; color: $subject-blue; opacity: 0; transform: translateY(4px); }
@keyframes fv4-badge {
0%, 10% { opacity: 0; transform: scale(.4) rotate(0deg); }
20% { transform: scale(.86) rotate(8deg); }
27% { transform: scale(1.02) rotate(-5deg); }
33% { transform: scale(1.1) rotate(3deg); }
39% { transform: scale(1.04) rotate(-1deg); }
43% { transform: scale(1.06) rotate(0deg); }
48% { transform: scale(1); }
85%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes fv4-surge {
0%, 30% { opacity: 0; transform: scale(.8); }
38% { opacity: .55; transform: scale(1.18); }
50% { opacity: .12; transform: scale(1.32); }
60% { opacity: .22; transform: scale(1.42); }
72% { opacity: 0; transform: scale(1.48); }
100% { opacity: 0; transform: scale(1.48); }
}
@keyframes fv4-draw { 0%, 30% { stroke-dashoffset: 24; } 55%, 100% { stroke-dashoffset: 0; } }
@keyframes fv4-text { 0%, 30% { opacity: 0; transform: translateY(4px); } 55%, 100% { opacity: 1; transform: translateY(0); } }
.fv4.is-demo .fv4__badge { animation: fv4-badge $duration $easing infinite; }
.fv4.is-demo .fv4__badge::after { animation: fv4-surge $duration $easing infinite; }
.fv4.is-demo .fv4__path { animation: fv4-draw $duration linear infinite; }
.fv4.is-demo .fv4__text { animation: fv4-text $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv4.is-demo .fv4__badge,
.fv4.is-demo .fv4__badge::after,
.fv4.is-demo .fv4__path,
.fv4.is-demo .fv4__text { animation: none !important; }
.fv4.is-demo .fv4__badge { opacity: 1; transform: scale(1); }
.fv4.is-demo .fv4__badge::after { opacity: 0; }
.fv4.is-demo .fv4__path { stroke-dashoffset: 0; }
.fv4.is-demo .fv4__text { opacity: 1; transform: translateY(0); }
}@charset "UTF-8";
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);
}
.fv4 {
display: flex;
align-items: center;
gap: 12px;
width: min(220px, 86vw);
}
.fv4__badge {
position: relative; /* ::after 서지 링의 기준 — 위치·크기 불변 */
width: 40px;
height: 40px;
border-radius: 50%;
background: #4cd4a6;
display: grid;
place-items: center;
color: #17141a;
opacity: 0;
transform: scale(0.4);
}
.fv4__path {
stroke-dasharray: 24;
stroke-dashoffset: 24;
}
.fv4__badge::after {
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.5);
opacity: 0;
transform: scale(0.8);
pointer-events: none;
}
.fv4__text {
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
color: #2f6df6;
opacity: 0;
transform: translateY(4px);
}
@keyframes fv4-badge {
0%, 10% {
opacity: 0;
transform: scale(0.4) rotate(0deg);
}
20% {
transform: scale(0.86) rotate(8deg);
}
27% {
transform: scale(1.02) rotate(-5deg);
}
33% {
transform: scale(1.1) rotate(3deg);
}
39% {
transform: scale(1.04) rotate(-1deg);
}
43% {
transform: scale(1.06) rotate(0deg);
}
48% {
transform: scale(1);
}
85%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fv4-surge {
0%, 30% {
opacity: 0;
transform: scale(0.8);
}
38% {
opacity: 0.55;
transform: scale(1.18);
}
50% {
opacity: 0.12;
transform: scale(1.32);
}
60% {
opacity: 0.22;
transform: scale(1.42);
}
72% {
opacity: 0;
transform: scale(1.48);
}
100% {
opacity: 0;
transform: scale(1.48);
}
}
@keyframes fv4-draw {
0%, 30% {
stroke-dashoffset: 24;
}
55%, 100% {
stroke-dashoffset: 0;
}
}
@keyframes fv4-text {
0%, 30% {
opacity: 0;
transform: translateY(4px);
}
55%, 100% {
opacity: 1;
transform: translateY(0);
}
}
.fv4.is-demo .fv4__badge {
animation: fv4-badge 600ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv4.is-demo .fv4__badge::after {
animation: fv4-surge 600ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv4.is-demo .fv4__path {
animation: fv4-draw 600ms linear infinite;
}
.fv4.is-demo .fv4__text {
animation: fv4-text 600ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv4.is-demo .fv4__badge,
.fv4.is-demo .fv4__badge::after,
.fv4.is-demo .fv4__path,
.fv4.is-demo .fv4__text {
animation: none !important;
}
.fv4.is-demo .fv4__badge {
opacity: 1;
transform: scale(1);
}
.fv4.is-demo .fv4__badge::after {
opacity: 0;
}
.fv4.is-demo .fv4__path {
stroke-dashoffset: 0;
}
.fv4.is-demo .fv4__text {
opacity: 1;
transform: translateY(0);
}
}import type { CSSProperties } from "react";
import styles from "./SuccessBounceCheck.module.scss";
export type SuccessBounceCheckProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SuccessBounceCheck({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#4cd4a6" }: SuccessBounceCheckProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv4} style={vars}>
<span className={styles.badge}>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<polyline className={styles.path} points="4,13 9,18 20,6" />
</svg>
</span>
<span className={styles.text}>저장됐어요</span>
</div>
);
}04 · Success badge bounce check — Measured area 3.188% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The badge scales .4→1.12→1, while the check is one SVG polyline whose stroke-dashoffset counts down from 24 to 0.
@keyframes fv4-badge {
0%, 10% { opacity: 0; transform: scale(.4); }
35% { opacity: 1; transform: scale(1.12); }
50% { transform: scale(1); }
85%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes fv4-draw {
0%, 30% { stroke-dashoffset: 24; }
55%, 100% { stroke-dashoffset: 0; }
}
.fv4__path { stroke-dasharray: 24; stroke-dashoffset: 24; }
.fv4.is-demo .fv4__badge { animation: fv4-badge $duration $easing infinite; }
.fv4.is-demo .fv4__path { animation: fv4-draw $duration linear infinite; }
05Error field breathing ring
What happens on screen
Unlike a shake, this field never moves at all. Instead of cutting off flatly after its peak, the breathing ring leaves successively weaker ripples as it decays, so the wave's echo lingers on the same spot without the position shift of the shake in post 052.

<main class="stage">
<div class="fv5 is-demo">
<span class="fv5__field">
<input class="fv5__input" type="text" value="01021" readonly>
<span class="fv5__ring" aria-hidden="true"></span>
</span>
<p class="fv5__msg">Enter at least 4 digits</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 052편(shake-error) 은 흔들림이다. 여기서는 흔들지 않고, 필드 둘레의 글로우 링이 opacity + scale 로 천천히 숨쉬듯 반복하며 오류를 알린다
$duration: $dur-loop;
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.fv5 { width: min(240px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.fv5__field { position: relative; display: block; }
.fv5__ring {
position: absolute; inset: -5px; border-radius: $r-control;
box-shadow: 0 0 0 3px $error-soft; opacity: 0; transform: scale(.97); pointer-events: none;
}
.fv5__input {
position: relative; 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;
}
.fv5__msg { margin: 0; color: $color; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
// 한 겹(감쇠 진동): 50% 피크 뒤 단조 소멸 대신 62%·74%·86% 에 점점 약해지는 2·3차 파동 — 필드는 절대 움직이지 않는다(052 흔들림과의 차별화 유지)
@keyframes fv5-breathe {
0%, 100% { opacity: 0; transform: scale(.97); }
50% { opacity: 1; transform: scale(1.03); }
54% { opacity: .45; transform: scale(.99); }
58% { opacity: .72; transform: scale(1.022); }
62% { opacity: .3; transform: scale(.992); }
66% { opacity: .48; transform: scale(1.015); }
70% { opacity: .18; transform: scale(.995); }
74% { opacity: .28; transform: scale(1.009); }
78% { opacity: .1; transform: scale(.997); }
82% { opacity: .15; transform: scale(1.005); }
86% { opacity: .05; transform: scale(.999); }
92% { opacity: 0; transform: scale(.97); }
}
.fv5.is-demo .fv5__ring { animation: fv5-breathe $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv5.is-demo .fv5__ring { animation: none !important; opacity: .7; transform: scale(1); }
}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);
}
.fv5 {
width: min(240px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.fv5__field {
position: relative;
display: block;
}
.fv5__ring {
position: absolute;
inset: -5px;
border-radius: 12px;
box-shadow: 0 0 0 3px #ffe0d8;
opacity: 0;
transform: scale(0.97);
pointer-events: none;
}
.fv5__input {
position: relative;
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;
}
.fv5__msg {
margin: 0;
color: #c2361a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes fv5-breathe {
0%, 100% {
opacity: 0;
transform: scale(0.97);
}
50% {
opacity: 1;
transform: scale(1.03);
}
54% {
opacity: 0.45;
transform: scale(0.99);
}
58% {
opacity: 0.72;
transform: scale(1.022);
}
62% {
opacity: 0.3;
transform: scale(0.992);
}
66% {
opacity: 0.48;
transform: scale(1.015);
}
70% {
opacity: 0.18;
transform: scale(0.995);
}
74% {
opacity: 0.28;
transform: scale(1.009);
}
78% {
opacity: 0.1;
transform: scale(0.997);
}
82% {
opacity: 0.15;
transform: scale(1.005);
}
86% {
opacity: 0.05;
transform: scale(0.999);
}
92% {
opacity: 0;
transform: scale(0.97);
}
}
.fv5.is-demo .fv5__ring {
animation: fv5-breathe 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv5.is-demo .fv5__ring {
animation: none !important;
opacity: 0.7;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./ErrorBorderPulse.module.scss";
export type ErrorBorderPulseProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ErrorBorderPulse({ duration = 2000, easing = "ease-out", color = "#c2361a" }: ErrorBorderPulseProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv5} style={vars}>
<span className={styles.field}>
<input className={styles.input} type="text" defaultValue="01021" readOnly />
<span className={styles.ring} aria-hidden="true" />
</span>
<p className={styles.msg}>숫자만 4자리 이상 입력해 주세요</p>
</div>
);
}05 · Error field breathing ring — Measured area 2.63% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
A soft coral ring around it breathes between opacity and scale(.97↔1.03) on a slow loop, so an error that hasn't been fixed keeps quietly reminding the user.
.fv5__ring {
position: absolute;
inset: -5px;
border-radius: $r-control;
box-shadow: 0 0 0 3px $error-soft;
opacity: 0;
transform: scale(.97);
}
@keyframes fv5-breathe {
0%, 100% { opacity: 0; transform: scale(.97); }
50% { opacity: 1; transform: scale(1.03); }
}
.fv5.is-demo .fv5__ring { animation: fv5-breathe $duration $easing infinite; }
06Status icon spinner-to-check/cross morph
What moves
While a username check is in flight, the icon on the right of the field spins, then hands off to a mint check, then to a coral cross. Each handoff carries a damped shake and a glow of its own color, tuned subtle for the check and stronger for the cross to put more weight behind the bad news.
<main class="stage">
<div class="fv6 is-demo">
<span class="fv6__field">
<input class="fv6__input" type="text" value="godrich01" readonly>
<span class="fv6__icon">
<svg class="fv6__spinner" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-dasharray="42 14"/></svg>
<svg class="fv6__check" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,13 9,18 20,6"/></svg>
<svg class="fv6__cross" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 필드 오른쪽 아이콘이 스피너(rotate) → 체크(opacity 크로스페이드) → 엑스(opacity 크로스페이드) 순으로 모핑한다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.fv6 { width: min(240px, 86vw); }
.fv6__field { position: relative; display: block; }
.fv6__input {
box-sizing: border-box; width: 100%;
padding: $sp-2 $sp-8 $sp-2 $sp-3; border-radius: $r-sm;
border: 2px solid $color; background: rgba(23, 20, 26, .06);
color: $color; font-weight: 400; font-size: 14px; line-height: 1; font-family: inherit;
}
.fv6__icon { position: absolute; right: $sp-3; top: 0; bottom: 0; display: flex; align-items: center; width: 18px; }
.fv6__icon svg { position: absolute; inset: 0; opacity: 0; }
.fv6__spinner { color: $color; }
.fv6__check { color: $subject-mint; }
.fv6__cross { color: $error; }
.fv6__icon::after {
content: ""; position: absolute; left: 50%; top: 50%; width: 26px; height: 26px;
margin: -13px 0 0 -13px; border-radius: 50%; opacity: 0; pointer-events: none;
}
@keyframes fv6-spin { to { transform: rotate(360deg); } }
@keyframes fv6-spinner-show { 0%, 32% { opacity: 1; } 38%, 100% { opacity: 0; } }
@keyframes fv6-check-show { 0%, 32% { opacity: 0; } 40%, 62% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes fv6-cross-show { 0%, 62% { opacity: 0; } 70%, 96% { opacity: 1; } 100% { opacity: 0; } }
// 한 겹: 상태 등장 순간의 감쇠 shake + 글로우 surge (체크 40% 미세·mint / 엑스 70% 뚜렷·error)
@keyframes fv6-shake {
0%, 40% { transform: translateX(0); }
42% { transform: translateX(-2px); } 44% { transform: translateX(2px); }
46% { transform: translateX(-1.4px); } 48% { transform: translateX(1.2px); }
50% { transform: translateX(-.7px); }
52%, 70% { transform: translateX(0); }
72% { transform: translateX(-3px); } 74% { transform: translateX(3px); }
76% { transform: translateX(-2.2px); } 78% { transform: translateX(2px); }
80% { transform: translateX(-1.2px); } 82% { transform: translateX(.8px); }
84%, 100% { transform: translateX(0); }
}
@keyframes fv6-surge {
0%, 38% { opacity: 0; box-shadow: 0 0 0 0 rgba($subject-mint, 0); }
40% { opacity: .9; box-shadow: 0 0 0 4px rgba($subject-mint, .55); }
46% { opacity: .7; box-shadow: 0 0 0 8px rgba($subject-mint, .3); }
54% { opacity: 0; box-shadow: 0 0 0 12px rgba($subject-mint, 0); }
68% { opacity: 0; box-shadow: 0 0 0 0 rgba($error, 0); }
70% { opacity: .9; box-shadow: 0 0 0 4px rgba($error, .5); }
78% { opacity: .6; box-shadow: 0 0 0 9px rgba($error, .25); }
88% { opacity: 0; box-shadow: 0 0 0 13px rgba($error, 0); }
100% { opacity: 0; box-shadow: 0 0 0 0 rgba($subject-mint, 0); }
}
.fv6.is-demo .fv6__spinner { animation: fv6-spin $dur-base linear infinite, fv6-spinner-show $duration $easing infinite; }
.fv6.is-demo .fv6__check { animation: fv6-check-show $duration $easing infinite; }
.fv6.is-demo .fv6__cross { animation: fv6-cross-show $duration $easing infinite; }
.fv6.is-demo .fv6__input { animation: fv6-shake $duration $easing infinite; }
.fv6.is-demo .fv6__icon::after { animation: fv6-surge $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv6.is-demo .fv6__spinner,
.fv6.is-demo .fv6__check,
.fv6.is-demo .fv6__cross { animation: none !important; opacity: 0; }
.fv6.is-demo .fv6__check { opacity: 1; }
.fv6.is-demo .fv6__input,
.fv6.is-demo .fv6__icon::after { animation: none !important; }
.fv6.is-demo .fv6__icon::after { opacity: 0; }
}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);
}
.fv6 {
width: min(240px, 86vw);
}
.fv6__field {
position: relative;
display: block;
}
.fv6__input {
box-sizing: border-box;
width: 100%;
padding: 8px 32px 8px 12px;
border-radius: 8px;
border: 2px solid #17141a;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.fv6__icon {
position: absolute;
right: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
width: 18px;
}
.fv6__icon svg {
position: absolute;
inset: 0;
opacity: 0;
}
.fv6__spinner {
color: #17141a;
}
.fv6__check {
color: #4cd4a6;
}
.fv6__cross {
color: #c2361a;
}
.fv6__icon::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 26px;
height: 26px;
margin: -13px 0 0 -13px;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
@keyframes fv6-spin {
to {
transform: rotate(360deg);
}
}
@keyframes fv6-spinner-show {
0%, 32% {
opacity: 1;
}
38%, 100% {
opacity: 0;
}
}
@keyframes fv6-check-show {
0%, 32% {
opacity: 0;
}
40%, 62% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes fv6-cross-show {
0%, 62% {
opacity: 0;
}
70%, 96% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fv6-shake {
0%, 40% {
transform: translateX(0);
}
42% {
transform: translateX(-2px);
}
44% {
transform: translateX(2px);
}
46% {
transform: translateX(-1.4px);
}
48% {
transform: translateX(1.2px);
}
50% {
transform: translateX(-0.7px);
}
52%, 70% {
transform: translateX(0);
}
72% {
transform: translateX(-3px);
}
74% {
transform: translateX(3px);
}
76% {
transform: translateX(-2.2px);
}
78% {
transform: translateX(2px);
}
80% {
transform: translateX(-1.2px);
}
82% {
transform: translateX(0.8px);
}
84%, 100% {
transform: translateX(0);
}
}
@keyframes fv6-surge {
0%, 38% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
40% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.55);
}
46% {
opacity: 0.7;
box-shadow: 0 0 0 8px rgba(76, 212, 166, 0.3);
}
54% {
opacity: 0;
box-shadow: 0 0 0 12px rgba(76, 212, 166, 0);
}
68% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0);
}
70% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(194, 54, 26, 0.5);
}
78% {
opacity: 0.6;
box-shadow: 0 0 0 9px rgba(194, 54, 26, 0.25);
}
88% {
opacity: 0;
box-shadow: 0 0 0 13px rgba(194, 54, 26, 0);
}
100% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
}
.fv6.is-demo .fv6__spinner {
animation: fv6-spin 300ms linear infinite, fv6-spinner-show 2s ease-out infinite;
}
.fv6.is-demo .fv6__check {
animation: fv6-check-show 2s ease-out infinite;
}
.fv6.is-demo .fv6__cross {
animation: fv6-cross-show 2s ease-out infinite;
}
.fv6.is-demo .fv6__input {
animation: fv6-shake 2s ease-out infinite;
}
.fv6.is-demo .fv6__icon::after {
animation: fv6-surge 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv6.is-demo .fv6__spinner,
.fv6.is-demo .fv6__check,
.fv6.is-demo .fv6__cross {
animation: none !important;
opacity: 0;
}
.fv6.is-demo .fv6__check {
opacity: 1;
}
.fv6.is-demo .fv6__input,
.fv6.is-demo .fv6__icon::after {
animation: none !important;
}
.fv6.is-demo .fv6__icon::after {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./IconMorphCheckCross.module.scss";
export type IconMorphCheckCrossProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function IconMorphCheckCross({ duration = 2000, easing = "ease-out", color = "#17141a" }: IconMorphCheckCrossProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv6} style={vars}>
<span className={styles.field}>
<input className={styles.input} type="text" defaultValue="godrich01" readOnly />
<span className={styles.icon}>
<svg className={styles.spinner} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round"><circle cx="12" cy="12" r="9" strokeDasharray="42 14" /></svg>
<svg className={styles.check} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,6" /></svg>
<svg className={styles.cross} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round"><line x1={6} y1={6} x2={18} y2={18} /><line x1={18} y1={6} x2={6} y2={18} /></svg>
</span>
</span>
</div>
);
}06 · Status icon spinner-to-check/cross morph — Measured area 1.374% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Three icons sit stacked in the same spot, crossfading via opacity, with rotate layered only on the spinner.
@keyframes fv6-spinner-show { 0%, 32% { opacity: 1; } 38%, 100% { opacity: 0; } }
@keyframes fv6-check-show { 0%, 32% { opacity: 0; } 40%, 62% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes fv6-cross-show { 0%, 62% { opacity: 0; } 70%, 96% { opacity: 1; } 100% { opacity: 0; } }
.fv6.is-demo .fv6__spinner {
animation: fv6-spin $dur-base linear infinite,
fv6-spinner-show $duration $easing infinite;
}
.fv6.is-demo .fv6__check { animation: fv6-check-show $duration $easing infinite; }
.fv6.is-demo .fv6__cross { animation: fv6-cross-show $duration $easing infinite; }
073-tier password strength fill
What you see
Three bars — weak, mid, strong — sit stacked on one track. Each time a tier lands, the track pulses once with scaleY and settles, while a glow in that tier's color ripples out along the track's outer edge.

<main class="stage">
<div class="fv7 is-demo">
<input class="fv7__input" type="password" value="********" readonly>
<div class="fv7__track">
<span class="fv7__fill fv7__fill--weak"></span>
<span class="fv7__fill fv7__fill--mid"></span>
<span class="fv7__fill fv7__fill--strong"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 강도 3단계가 같은 트랙 위에 겹쳐 있다: 각 단계는 scaleX 로 자라고(transform-origin:left), 단계마다 다른 색은 opacity 로 교차한다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-orange);
.fv7 { width: min(240px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.fv7__input {
box-sizing: border-box; width: 100%; padding: $sp-2 $sp-3; border-radius: $r-sm;
border: 2px solid $subject-cream; background: rgba(255, 255, 255, .14);
color: $subject-cream; font-weight: 400; font-size: 14px; line-height: 1; font-family: inherit;
}
.fv7__track { position: relative; height: 6px; border-radius: $r-xs; background: rgba(255, 255, 255, .25); overflow: hidden; }
.fv7__fill {
position: absolute; left: 0; top: 0; bottom: 0; width: 100%;
border-radius: $r-xs; transform-origin: left center; transform: scaleX(0); opacity: 0;
}
.fv7__fill--weak { background: $error-soft; }
.fv7__fill--mid { background: $stage-yellow; }
.fv7__fill--strong { background: $color; }
// 한 겹: 트랙 링은 overflow: hidden 바깥에서 파동을 그린다 (트랙 자체는 불변)
.fv7__track::after {
content: ""; position: absolute; left: 0; top: -3px; bottom: -3px; width: 100%;
border-radius: $r-xs; opacity: 0; pointer-events: none;
}
@keyframes fv7-weak { 0%, 6% { transform: scaleX(0); opacity: 0; } 14%, 30% { transform: scaleX(.32); opacity: 1; } 36%, 100% { opacity: 0; } }
@keyframes fv7-mid { 0%, 36% { transform: scaleX(.32); opacity: 0; } 44%, 62% { transform: scaleX(.64); opacity: 1; } 68%, 100% { opacity: 0; } }
@keyframes fv7-strong { 0%, 68% { transform: scaleX(.64); opacity: 0; } 78%, 96% { transform: scaleX(1); opacity: 1; } 100% { opacity: 0; transform: scaleX(0); } }
// 한 겹: 단계 도달 순간(weak 14% · mid 44% · strong 78%) 감쇠 scaleY 펄스 + 단계색 글로우 surge
@keyframes fv7-track-pulse {
0%, 13% { transform: scaleY(1); }
14% { transform: scaleY(1.15); } 17% { transform: scaleY(.95); } 20% { transform: scaleY(1.03); } 23% { transform: scaleY(1); }
43% { transform: scaleY(1); }
44% { transform: scaleY(1.15); } 47% { transform: scaleY(.95); } 50% { transform: scaleY(1.03); } 53% { transform: scaleY(1); }
77% { transform: scaleY(1); }
78% { transform: scaleY(1.15); } 81% { transform: scaleY(.95); } 84% { transform: scaleY(1.03); } 87% { transform: scaleY(1); }
100% { transform: scaleY(1); }
}
@keyframes fv7-surge {
0%, 12% { opacity: 0; box-shadow: 0 0 0 0 rgba($error-soft, 0); }
14% { opacity: .9; box-shadow: 0 0 0 4px rgba($error-soft, .55); }
22% { opacity: 0; box-shadow: 0 0 0 9px rgba($error-soft, 0); }
42% { opacity: 0; box-shadow: 0 0 0 0 rgba($stage-yellow, 0); }
44% { opacity: .9; box-shadow: 0 0 0 4px rgba($stage-yellow, .55); }
52% { opacity: 0; box-shadow: 0 0 0 9px rgba($stage-yellow, 0); }
76% { opacity: 0; box-shadow: 0 0 0 0 rgba($color, 0); }
78% { opacity: .9; box-shadow: 0 0 0 4px rgba($color, .55); }
86% { opacity: 0; box-shadow: 0 0 0 9px rgba($color, 0); }
100% { opacity: 0; box-shadow: 0 0 0 0 rgba($error-soft, 0); }
}
.fv7.is-demo .fv7__fill--weak { animation: fv7-weak $duration $easing infinite; }
.fv7.is-demo .fv7__fill--mid { animation: fv7-mid $duration $easing infinite; }
.fv7.is-demo .fv7__fill--strong { animation: fv7-strong $duration $easing infinite; }
.fv7.is-demo .fv7__track { animation: fv7-track-pulse $duration $easing infinite; }
.fv7.is-demo .fv7__track::after { animation: fv7-surge $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv7.is-demo .fv7__fill { animation: none !important; opacity: 0; }
.fv7.is-demo .fv7__fill--strong { opacity: 1; transform: scaleX(1); }
.fv7.is-demo .fv7__track,
.fv7.is-demo .fv7__track::after { animation: none !important; }
.fv7.is-demo .fv7__track::after { opacity: 0; }
}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);
}
.fv7 {
width: min(240px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.fv7__input {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #fff7e6;
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.fv7__track {
position: relative;
height: 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
}
.fv7__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
border-radius: 4px;
transform-origin: left center;
transform: scaleX(0);
opacity: 0;
}
.fv7__fill--weak {
background: #ffe0d8;
}
.fv7__fill--mid {
background: #ffd23f;
}
.fv7__fill--strong {
background: #4cd4a6;
}
.fv7__track::after {
content: "";
position: absolute;
left: 0;
top: -3px;
bottom: -3px;
width: 100%;
border-radius: 4px;
opacity: 0;
pointer-events: none;
}
@keyframes fv7-weak {
0%, 6% {
transform: scaleX(0);
opacity: 0;
}
14%, 30% {
transform: scaleX(0.32);
opacity: 1;
}
36%, 100% {
opacity: 0;
}
}
@keyframes fv7-mid {
0%, 36% {
transform: scaleX(0.32);
opacity: 0;
}
44%, 62% {
transform: scaleX(0.64);
opacity: 1;
}
68%, 100% {
opacity: 0;
}
}
@keyframes fv7-strong {
0%, 68% {
transform: scaleX(0.64);
opacity: 0;
}
78%, 96% {
transform: scaleX(1);
opacity: 1;
}
100% {
opacity: 0;
transform: scaleX(0);
}
}
@keyframes fv7-track-pulse {
0%, 13% {
transform: scaleY(1);
}
14% {
transform: scaleY(1.15);
}
17% {
transform: scaleY(0.95);
}
20% {
transform: scaleY(1.03);
}
23% {
transform: scaleY(1);
}
43% {
transform: scaleY(1);
}
44% {
transform: scaleY(1.15);
}
47% {
transform: scaleY(0.95);
}
50% {
transform: scaleY(1.03);
}
53% {
transform: scaleY(1);
}
77% {
transform: scaleY(1);
}
78% {
transform: scaleY(1.15);
}
81% {
transform: scaleY(0.95);
}
84% {
transform: scaleY(1.03);
}
87% {
transform: scaleY(1);
}
100% {
transform: scaleY(1);
}
}
@keyframes fv7-surge {
0%, 12% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(255, 224, 216, 0);
}
14% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(255, 224, 216, 0.55);
}
22% {
opacity: 0;
box-shadow: 0 0 0 9px rgba(255, 224, 216, 0);
}
42% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(255, 210, 63, 0);
}
44% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(255, 210, 63, 0.55);
}
52% {
opacity: 0;
box-shadow: 0 0 0 9px rgba(255, 210, 63, 0);
}
76% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
78% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.55);
}
86% {
opacity: 0;
box-shadow: 0 0 0 9px rgba(76, 212, 166, 0);
}
100% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(255, 224, 216, 0);
}
}
.fv7.is-demo .fv7__fill--weak {
animation: fv7-weak 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fv7.is-demo .fv7__fill--mid {
animation: fv7-mid 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fv7.is-demo .fv7__fill--strong {
animation: fv7-strong 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fv7.is-demo .fv7__track {
animation: fv7-track-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fv7.is-demo .fv7__track::after {
animation: fv7-surge 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv7.is-demo .fv7__fill {
animation: none !important;
opacity: 0;
}
.fv7.is-demo .fv7__fill--strong {
opacity: 1;
transform: scaleX(1);
}
.fv7.is-demo .fv7__track,
.fv7.is-demo .fv7__track::after {
animation: none !important;
}
.fv7.is-demo .fv7__track::after {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./PasswordStrengthFill.module.scss";
export type PasswordStrengthFillProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PasswordStrengthFill({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#4cd4a6" }: PasswordStrengthFillProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv7} style={vars}>
<input className={styles.input} type="password" defaultValue="********" readOnly />
<div className={styles.track}>
<span className={`${styles.fill} ${styles.weak}`} />
<span className={`${styles.fill} ${styles.mid}`} />
<span className={`${styles.fill} ${styles.strong}`} />
</div>
</div>
);
}07 · 3-tier password strength fill — Measured area 1.161% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Each grows via scaleX (transform-origin: left) and then hands the track off to the next tier through opacity, so the meter reads as bars swapping rather than one bar changing color.
.fv7__fill {
position: absolute; left: 0; top: 0; bottom: 0; width: 100%;
transform-origin: left center;
transform: scaleX(0);
opacity: 0;
}
@keyframes fv7-strong {
0%, 68% { transform: scaleX(.64); opacity: 0; }
78%, 96% { transform: scaleX(1); opacity: 1; }
100% { opacity: 0; transform: scaleX(0); }
}
.fv7.is-demo .fv7__fill--strong { animation: fv7-strong $duration $easing infinite; }
08Submit button loading-to-success morph
What happens on screen
One button covers a full submit journey in a single loop: a press (scale(1→.96→1)), a spinner rotating where the label just faded out, then a crossfade into a mint check. When the check lands, a mint glow ring spreads around the button and a damped rotate wobble settles it back in place.

<main class="stage">
<button class="fv8 is-demo" type="button">
<span class="fv8__label">Sign up</span>
<svg class="fv8__spinner" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-dasharray="42 14"/></svg>
<svg class="fv8__check" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,13 9,18 20,6"/></svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 제출 버튼 하나가 눌림(scale) → 로딩 스피너(rotate) → 성공 체크로 모핑(opacity+scale) 된다. 하나의 제출 상태 여정이다
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-paper);
.fv8 {
position: relative; display: inline-flex; align-items: center; justify-content: center;
gap: $sp-2; min-width: min(160px, 70vw);
padding: $sp-3 $sp-6; border-radius: $r-control; border: 0;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 14px; line-height: 1; font-family: inherit;
transform: scale(1);
}
.fv8__spinner, .fv8__check { position: absolute; opacity: 0; }
.fv8__check { color: $color; }
// 한 겹: 성공 체크 순간(74%) 버튼 둘레로 퍼지는 mint 글로우 surge 링
.fv8::after {
content: ""; position: absolute; inset: -3px; border-radius: calc(#{$r-control} + 3px);
opacity: 0; pointer-events: none;
}
@keyframes fv8-press { 0%, 8% { transform: scale(1); } 14% { transform: scale(.96); } 22%, 100% { transform: scale(1); } }
@keyframes fv8-label { 0%, 14% { opacity: 1; } 22%, 100% { opacity: 0; } }
@keyframes fv8-spin { to { transform: rotate(360deg); } }
@keyframes fv8-spinner-show { 0%, 20% { opacity: 0; } 26%, 58% { opacity: 1; } 66%, 100% { opacity: 0; } }
@keyframes fv8-check-show { 0%, 66% { opacity: 0; transform: scale(.5); } 74% { opacity: 1; transform: scale(1.15); } 86%, 100% { opacity: 1; transform: scale(1); } }
// 한 겹: 체크 피크(74%) 감쇠 rotate 진동 — 개별 rotate 속성이라 fv8-press 의 scale 과 독립 결합된다
@keyframes fv8-shake {
0%, 73% { rotate: 0deg; }
74% { rotate: 2deg; } 76% { rotate: -2deg; } 78% { rotate: 1.4deg; }
80% { rotate: -1deg; } 82% { rotate: .4deg; }
84%, 100% { rotate: 0deg; }
}
@keyframes fv8-surge {
0%, 73% { opacity: 0; box-shadow: 0 0 0 0 rgba($color, 0); }
74% { opacity: .95; box-shadow: 0 0 0 4px rgba($color, .6); }
82% { opacity: .5; box-shadow: 0 0 0 10px rgba($color, .22); }
92% { opacity: 0; box-shadow: 0 0 0 15px rgba($color, 0); }
100% { opacity: 0; box-shadow: 0 0 0 0 rgba($color, 0); }
}
.fv8.is-demo { animation: fv8-press $duration $easing infinite, fv8-shake $duration $easing infinite; }
.fv8.is-demo .fv8__label { animation: fv8-label $duration $easing infinite; }
.fv8.is-demo .fv8__spinner { animation: fv8-spin $dur-base linear infinite, fv8-spinner-show $duration $easing infinite; }
.fv8.is-demo .fv8__check { animation: fv8-check-show $duration $easing infinite; }
.fv8.is-demo::after { animation: fv8-surge $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv8.is-demo,
.fv8.is-demo .fv8__label,
.fv8.is-demo .fv8__spinner,
.fv8.is-demo .fv8__check { animation: none !important; }
.fv8.is-demo .fv8__label { opacity: 0; }
.fv8.is-demo .fv8__check { opacity: 1; transform: scale(1); }
.fv8.is-demo::after { animation: none !important; opacity: 0; }
.fv8.is-demo { rotate: 0deg; }
}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);
}
.fv8 {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: min(160px, 70vw);
padding: 12px 24px;
border-radius: 12px;
border: 0;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 14px;
line-height: 1;
font-family: inherit;
transform: scale(1);
}
.fv8__spinner, .fv8__check {
position: absolute;
opacity: 0;
}
.fv8__check {
color: #4cd4a6;
}
.fv8::after {
content: "";
position: absolute;
inset: -3px;
border-radius: calc(12px + 3px);
opacity: 0;
pointer-events: none;
}
@keyframes fv8-press {
0%, 8% {
transform: scale(1);
}
14% {
transform: scale(0.96);
}
22%, 100% {
transform: scale(1);
}
}
@keyframes fv8-label {
0%, 14% {
opacity: 1;
}
22%, 100% {
opacity: 0;
}
}
@keyframes fv8-spin {
to {
transform: rotate(360deg);
}
}
@keyframes fv8-spinner-show {
0%, 20% {
opacity: 0;
}
26%, 58% {
opacity: 1;
}
66%, 100% {
opacity: 0;
}
}
@keyframes fv8-check-show {
0%, 66% {
opacity: 0;
transform: scale(0.5);
}
74% {
opacity: 1;
transform: scale(1.15);
}
86%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fv8-shake {
0%, 73% {
rotate: 0deg;
}
74% {
rotate: 2deg;
}
76% {
rotate: -2deg;
}
78% {
rotate: 1.4deg;
}
80% {
rotate: -1deg;
}
82% {
rotate: 0.4deg;
}
84%, 100% {
rotate: 0deg;
}
}
@keyframes fv8-surge {
0%, 73% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
74% {
opacity: 0.95;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.6);
}
82% {
opacity: 0.5;
box-shadow: 0 0 0 10px rgba(76, 212, 166, 0.22);
}
92% {
opacity: 0;
box-shadow: 0 0 0 15px rgba(76, 212, 166, 0);
}
100% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
}
.fv8.is-demo {
animation: fv8-press 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite, fv8-shake 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv8.is-demo .fv8__label {
animation: fv8-label 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv8.is-demo .fv8__spinner {
animation: fv8-spin 300ms linear infinite, fv8-spinner-show 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv8.is-demo .fv8__check {
animation: fv8-check-show 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv8.is-demo::after {
animation: fv8-surge 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv8.is-demo,
.fv8.is-demo .fv8__label,
.fv8.is-demo .fv8__spinner,
.fv8.is-demo .fv8__check {
animation: none !important;
}
.fv8.is-demo .fv8__label {
opacity: 0;
}
.fv8.is-demo .fv8__check {
opacity: 1;
transform: scale(1);
}
.fv8.is-demo::after {
animation: none !important;
opacity: 0;
}
.fv8.is-demo {
rotate: 0deg;
}
}import type { CSSProperties } from "react";
import styles from "./SubmitButtonSuccessMorph.module.scss";
export type SubmitButtonSuccessMorphProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SubmitButtonSuccessMorph({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#4cd4a6" }: SubmitButtonSuccessMorphProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.fv8} style={vars}>
<span className={styles.label}>가입하기</span>
<svg className={styles.spinner} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round"><circle cx="12" cy="12" r="9" strokeDasharray="42 14" /></svg>
<svg className={styles.check} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,6" /></svg>
</button>
);
}08 · Submit button loading-to-success morph — Measured area 4.267% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The only properties animated here are opacity, transform: scale, and transform: rotate.
@keyframes fv8-press { 0%, 8% { transform: scale(1); } 14% { transform: scale(.96); } 22%, 100% { transform: scale(1); } }
@keyframes fv8-check-show {
0%, 66% { opacity: 0; transform: scale(.5); }
74% { opacity: 1; transform: scale(1.15); }
86%, 100% { opacity: 1; transform: scale(1); }
}
.fv8.is-demo { animation: fv8-press $duration $easing infinite; }
.fv8.is-demo .fv8__check { animation: fv8-check-show $duration $easing infinite; }
09Static fallback for reduced motion
What moves
Normally a status icon pops in via scale(.5→1.15→1) and opacity, then fades out on a loop. At the pop, a faint glow wave ripples out once too, and it switches off with everything else once reduced motion is on.

<main class="stage">
<div class="fv9 is-demo">
<span class="fv9__field">
<input class="fv9__input" type="text" value="[email protected]" readonly>
<span class="fv9__status">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,13 9,18 20,6"/></svg>
</span>
</span>
<p class="fv9__msg">Verified</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 평소엔 상태 아이콘이 scale+opacity 로 팝인 후 사라지는 루프지만, 시스템에서 모션을 줄이면 애니메이션이 없어지고 움직임 없이 마지막 상태로 고정 표시된다
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.fv9 { width: min(240px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.fv9__field { position: relative; display: block; }
.fv9__input {
box-sizing: border-box; width: 100%;
padding: $sp-2 $sp-8 $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;
}
.fv9__status {
position: absolute; right: $sp-3; top: 0; bottom: 0; display: flex; align-items: center; width: 18px;
color: $color; opacity: 0; transform: scale(.5);
}
// 한 겹: 팝인 순간 아주 옅은 글로우 surge (reduced-motion 에서는 같이 꺼진다 — 이 항목의 교육적 의도)
.fv9__status::after {
content: ""; position: absolute; inset: -5px; border-radius: 50%; opacity: 0; pointer-events: none;
}
.fv9__msg { margin: 0; color: $color; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; opacity: 0; }
@keyframes fv9-pop {
0%, 10% { opacity: 0; transform: scale(.5); }
40% { opacity: 1; transform: scale(1.15); }
46% { transform: scale(1.08); } 52% { transform: scale(1.12); } 58% { transform: scale(1); }
55%, 85% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.5); }
}
@keyframes fv9-msg { 0%, 30% { opacity: 0; } 55%, 85% { opacity: 1; } 100% { opacity: 0; } }
@keyframes fv9-surge {
0%, 38% { opacity: 0; box-shadow: 0 0 0 0 rgba($color, 0); }
40% { opacity: .35; box-shadow: 0 0 0 3px rgba($color, .3); }
54% { opacity: 0; box-shadow: 0 0 0 8px rgba($color, 0); }
100% { opacity: 0; box-shadow: 0 0 0 0 rgba($color, 0); }
}
.fv9.is-demo .fv9__status { animation: fv9-pop $duration $easing infinite; }
.fv9.is-demo .fv9__msg { animation: fv9-msg $duration $easing infinite; }
.fv9.is-demo .fv9__status::after { animation: fv9-surge $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv9.is-demo .fv9__status,
.fv9.is-demo .fv9__msg { animation: none !important; }
.fv9.is-demo .fv9__status { opacity: 1; transform: scale(1); }
.fv9.is-demo .fv9__msg { opacity: 1; }
.fv9.is-demo .fv9__status::after { animation: none !important; opacity: 0; }
}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);
}
.fv9 {
width: min(240px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.fv9__field {
position: relative;
display: block;
}
.fv9__input {
box-sizing: border-box;
width: 100%;
padding: 8px 32px 8px 12px;
border-radius: 8px;
border: 2px solid #4cd4a6;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.fv9__status {
position: absolute;
right: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
width: 18px;
color: #4cd4a6;
opacity: 0;
transform: scale(0.5);
}
.fv9__status::after {
content: "";
position: absolute;
inset: -5px;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.fv9__msg {
margin: 0;
color: #4cd4a6;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
opacity: 0;
}
@keyframes fv9-pop {
0%, 10% {
opacity: 0;
transform: scale(0.5);
}
40% {
opacity: 1;
transform: scale(1.15);
}
46% {
transform: scale(1.08);
}
52% {
transform: scale(1.12);
}
58% {
transform: scale(1);
}
55%, 85% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
@keyframes fv9-msg {
0%, 30% {
opacity: 0;
}
55%, 85% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fv9-surge {
0%, 38% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
40% {
opacity: 0.35;
box-shadow: 0 0 0 3px rgba(76, 212, 166, 0.3);
}
54% {
opacity: 0;
box-shadow: 0 0 0 8px rgba(76, 212, 166, 0);
}
100% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
}
.fv9.is-demo .fv9__status {
animation: fv9-pop 600ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv9.is-demo .fv9__msg {
animation: fv9-msg 600ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fv9.is-demo .fv9__status::after {
animation: fv9-surge 600ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv9.is-demo .fv9__status,
.fv9.is-demo .fv9__msg {
animation: none !important;
}
.fv9.is-demo .fv9__status {
opacity: 1;
transform: scale(1);
}
.fv9.is-demo .fv9__msg {
opacity: 1;
}
.fv9.is-demo .fv9__status::after {
animation: none !important;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./ReducedMotionSafeStatus.module.scss";
export type ReducedMotionSafeStatusProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ReducedMotionSafeStatus({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#4cd4a6" }: ReducedMotionSafeStatusProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv9} style={vars}>
<span className={styles.field}>
<input className={styles.input} type="text" defaultValue="[email protected]" readOnly />
<span className={styles.status}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,6" /></svg>
</span>
</span>
<p className={styles.msg}>확인됐어요</p>
</div>
);
}09 · Static fallback for reduced motion — Measured area 0.675% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
With reduced motion on, the animation turns off entirely and the check and its message just sit fixed at the final state.
@keyframes fv9-pop {
0%, 10% { opacity: 0; transform: scale(.5); }
40% { opacity: 1; transform: scale(1.15); }
55%, 85% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.5); }
}
.fv9.is-demo .fv9__status { animation: fv9-pop $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv9.is-demo .fv9__status { animation: none !important; opacity: 1; transform: scale(1); }
}
Where this breaks — a crossfade trap
This one actually bit us while building 02. Crossfading a neutral label and a success label with animation-direction: reverse on one shared keyframe looked like a shortcut — mirror the same timing, save a few lines. But reverse only flips progress (0–100%), and unless the keyframe is perfectly symmetric, both labels land on opacity: 1 near the middle of the loop at the same time, so the two lines of text sit visibly on top of each other. It only showed up once we opened the rendered frames one by one. The fix was to drop reverse and write a second @keyframes (fv2-neutral) whose opacity values are the literal inverse of the first at every stop. The opposite instinct — piling the whole screen up instead of rationing every signal — is on display in Maximalist Web Design. The password for the vanilla and React source of all nine is mk7s3ujk, and comparing those two blocks side by side in the 02 folder makes the difference obvious.
Accessibility
All nine turn their animation off under prefers-reduced-motion: reduce. But 03, 04, 07, and 08 use a shape change as the actual information, so switching off the animation without a fallback would hide the result itself — those items jump straight to their final frame (glow already on, check already drawn, bar already filled) instead of just freezing mid-motion. Per MDN's prefers-reduced-motion reference, the media feature reads straight from the OS setting, so validation state has to survive even when motion doesn't. Sticking to transform and opacity is also why these stay smooth on lower-end phones — web.dev's high-performance animations guide covers why those two properties skip layout entirely.
For the shake-based version of the same idea, see 9 CSS shake animations for form errors; custom checkbox and radio validation states live in 9 custom checkbox and radio animations.
FAQ
Why does this form validation ui crossfade opacity instead of transitioning the color directly?
Transitioning color or border-color makes the browser compute intermediate colors every frame, and on a page where colors come from CSS variables (dark mode, for instance) those in-between values can look wrong. Stacking two elements that already hold their final colors and only moving opacity skips that math entirely — each color is correct from frame one. Delivering pass and fail through a face instead of a field is what Mascot Animation does.
Can I draw the checkmark with two rotated bars instead of stroke-dashoffset?
You can, but if the two bars don't start from the exact same corner, the check reads as one diagonal line instead of a check — a common bug on this kind of icon. An SVG polyline animated with stroke-dasharray/stroke-dashoffset, like item 04, removes that failure mode completely.
Is it safe to run all nine on one form at once?
It works, but stacking multiple always-on loops — 05's breathing ring next to 03's glow — makes the form feel restless. A good rule: keep state-change animations like 02 and 06, which only move when something changes, but limit continuously looping ones like 05 to at most one per screen.









