CSS 유효성 검사 애니메이션 9종 — 폼 에러·성공 표시
CSS 유효성 검사 애니메이션은 폼에 입력한 값이 맞는지 틀린지를 흔들림이 아니라 색·아이콘·글로우 같은 다른 신호로 알려주는 움직임입니다. 아래 아홉 개는 회원가입 폼에서 실제로 일어나는 순서대로 골라 복붙 한 번으로 적용할 수 있게 정리했습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 실시간 인라인 에러 메시지
- 02 라벨 상태 색 크로스페이드
- 03 필드 검증 글로우 링
- 04 성공 배지 바운스 체크
- 05 오류 필드 브리딩 링
- 06 상태 아이콘 스피너→체크·엑스 모핑
- 07 비밀번호 강도 3단 채움 바
- 08 제출 버튼 로딩→성공 모핑
- 09 모션 축소 시 정지 상태로 대체
아홉 개는 인기 순이 아니라 회원가입 폼 디자인에서 사용자가 폼을 채우며 실제로 마주치는 순서를 따라갔습니다. 타이핑하는 동안 실시간으로 뜨는 메시지(01)에서 아이디 사용 가능 여부(02)로, 검증을 통과한 필드가 떠오르는 신호(03)와 저장 완료 배지(04)를 지나, 계속 남아있는 오류(05)와 진행 중 상태를 보여주는 아이콘(06)을 거쳐, 비밀번호 강도(07)와 제출 버튼의 전체 여정(08), 마지막은 움직임을 줄인 사람에게도 신호가 남는 문제(09) 순서입니다. 같은 feedback 기둥에서 상태를 작은 라벨로만 알리는 편이 뱃지 디자인 UI 9종이고, 그 라벨을 상태·우선순위 축으로 다시 짠 편이 태그 UI 디자인 9종입니다.
01실시간 인라인 에러 메시지
폼 유효성 검사에서 무엇이 보이나
이메일 칸에 형식이 틀린 값을 넣어 두면 그 아래 자리를 미리 잡아 둔 문구가 살짝 위에서 미끄러져 내려오다가 다시 숨습니다. 문구가 나타나는 순간 필드에 translateX가 픽셀 미만으로 잦아드는 감쇠 진동이 한 번 지나가고, 동시에 테두리 밖으로 번지는 글로우 파동이 오류를 한 번 더 짚어줍니다.

<main class="stage">
<div class="fv1 is-demo">
<label class="fv1__label" for="fv1-email">이메일</label>
<input class="fv1__input" id="fv1-email" type="email" value="wrong@mail" readonly aria-invalid="true">
<p class="fv1__msg" role="alert">올바른 이메일 형식이 아니에요</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 · 실시간 인라인 에러 메시지 — 측정 누적면적 3.469% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
opacity와 translateY만 쓴 키프레임 하나뿐이라 문구가 나타나는 동안 옆의 입력창 높이는 전혀 흔들리지 않습니다.
.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; }
02라벨 상태 색 크로스페이드
화면에서 일어나는 일
닉네임 입력창 위 라벨이 회색 “닉네임”에서 민트색 “사용할 수 있는 닉네임이에요”로 바뀝니다. 라벨이 바뀌는 순간 필드에 감쇠 진동 살짝 지나가고 민트 글로우가 한 번 번졌다 잦아듭니다.

<main class="stage">
<div class="fv2 is-demo">
<span class="fv2__label-wrap">
<span class="fv2__label fv2__label--neutral">닉네임</span>
<span class="fv2__label fv2__label--ok">사용할 수 있는 닉네임이에요</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 · 라벨 상태 색 크로스페이드 — 측정 누적면적 3.418% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
color 자체를 전환하는 게 아니라 같은 자리에 겹쳐 둔 라벨 두 장이 opacity로 자리를 주고받는 크로스페이드입니다.
@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; }
03필드 검증 글로우 링
어떤 움직임인가
검증을 통과한 입력창 뒤로 은은한 민트색 링이 부풀어 오르듯 떠오릅니다. 피크 뒤에는 곧바로 소멸하는 대신 점점 약해지는 2·3차 파동이 잦아들며 사라져, 물결이 가라앉는 여운을 남깁니다.

<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 · 필드 검증 글로우 링 — 측정 누적면적 3.533% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
입력창 뒤에 포갠 box-shadow 레이어가 opacity 0→1과 scale(.94→1.04)를 함께 써서 만든 효과입니다.
.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; }
04성공 배지 바운스 체크
무엇이 보이나
저장이 끝나면 원형 배지가 튀어 오르듯 나타나고, 그 안 체크 표시가 선을 긋듯 완성됩니다. 배지 등장에는 rotate 감쇠 진동이 얹혀 좌우로 잦아들며 튀고, 둘레로 글로우 링이 같은 박자로 번집니다.

<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">저장됐어요</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 · 성공 배지 바운스 체크 — 측정 누적면적 3.188% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
배지는 scale(.4→1.12→1)로 바운스하고, 체크는 SVG polyline의 stroke-dashoffset을 24에서 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; }
05오류 필드 브리딩 링
화면에서 일어나는 일
052편의 흔들림과 달리 이 필드는 전혀 움직이지 않습니다. 숨 쉬던 링은 정점 뒤 단조롭게 사라지는 게 아니라 점점 약해지는 파동을 남기며 잦아들어, 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">숫자만 4자리 이상 입력해 주세요</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 · 오류 필드 브리딩 링 — 측정 누적면적 2.63% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
둘레의 연한 코럴색 링이 opacity와 scale(.97↔1.03) 사이를 오가며 천천히 숨 쉬듯 반복해, 사라지지 않는 오류를 계속 알립니다.
.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; }
06상태 아이콘 스피너→체크·엑스 모핑
어떤 움직임인가
아이디 중복을 확인하는 동안 입력창 오른쪽 아이콘이 돌아가는 스피너에서 민트색 체크로, 다시 코럴색 엑스로 자리를 넘겨줍니다. 상태가 바뀌는 두 순간마다 감쇠 진동이 지나가고 해당 상태 색 글로우가 번지는데, 체크에서는 미세하게·엑스에서는 뚜렷하게 세기를 달리해 좋지 않은 소식에 더 힘을 줍니다.
<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 · 상태 아이콘 스피너→체크·엑스 모핑 — 측정 누적면적 1.374% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
세 아이콘을 같은 자리에 겹쳐 두고 opacity 크로스페이드로 순서를 만든 뒤, 스피너에만 rotate 애니메이션을 얹었습니다.
@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; }
07비밀번호 강도 3단 채움 바
무엇이 보이나
같은 트랙 위에 약함·보통·강함 막대 세 개가 겹쳐 있습니다. 각 단계에 도달하는 순간 트랙이 scaleY로 한 번 튀었다 잦아들고, 트랙 바깥 둘레로는 그 단계의 색 글로우가 번지는 파동이 따라옵니다.

<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단 채움 바 — 측정 누적면적 1.161% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
각 막대는 scaleX(transform-origin: left)로 자라난 뒤 opacity로 다음 단계에 자리를 넘겨줘, 색이 서서히 바뀌는 게 아니라 막대 자체가 교체되는 것처럼 보입니다.
.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; }
08제출 버튼 로딩→성공 모핑
화면에서 일어나는 일
버튼 하나가 눌리는 동작(scale(1→.96→1))부터 로딩 스피너, 민트색 체크까지 한 번의 루프 안에서 순서대로 보여줍니다. 체크가 뜨는 순간 버튼 둘레로 민트 글로우 링이 퍼지고, 버튼은 감쇠 rotate 진동으로 한 번 흔들렸다 멈춥니다.

<main class="stage">
<button class="fv8 is-demo" type="button">
<span class="fv8__label">가입하기</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 · 제출 버튼 로딩→성공 모핑 — 측정 누적면적 4.267% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
라벨이 opacity로 숨은 자리에 스피너가 rotate로 돌다가, 다시 opacity·scale 크로스페이드로 체크에 자리를 넘깁니다.
@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; }
09모션 축소 시 정지 상태로 대체
어떤 움직임인가
평소엔 상태 아이콘이 scale(.5→1.15→1)과 opacity로 팝인했다가 사라지는 루프지만, 시스템에서 동작 줄이기를 켜면 애니메이션이 전부 꺼지고 체크와 문구가 움직임 없이 마지막 상태로 고정됩니다. 팝인 순간엔 아주 옅은 글로우 파동이 한 번 번지는데, 이 파동 역시 동작 줄이기에서는 같이 꺼집니다.

<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">확인됐어요</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 · 모션 축소 시 정지 상태로 대체 — 측정 누적면적 0.675% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
@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); }
}
어디서 깨지는가 — 크로스페이드 함정
02번을 만들면서 실제로 겪은 함정입니다. 중립 라벨과 성공 라벨을 opacity로 교차시킬 때 animation-direction: reverse로 같은 키프레임 하나를 거울처럼 되돌려 쓰면 코드가 짧아져서 편할 것 같았습니다. 그런데 reverse는 진행률(0~100%)만 뒤집을 뿐이라, 두 라벨의 키프레임이 대칭이 아니면 루프 중간 지점 근처에서 둘 다 opacity가 1이 되는 순간이 생겨 글자가 겹쳐 보였습니다. 렌더된 프레임을 한 장씩 열어보고서야 발견했고, 고친 방법은 reverse를 버리고 opacity 값이 정확히 서로 반대인 두 번째 @keyframes(fv2-neutral)를 새로 만든 것입니다. 신호를 절제하는 대신 화면 전체를 겹쳐 쌓는 반대 방향은 맥시멀리즘 콜라주 랜딩 키트에서 볼 수 있습니다. 아홉 개 전체의 vanilla·React 코드가 든 압축 파일 비밀번호는 mk7s3ujk이고, 02번 폴더의 두 @keyframes를 나란히 놓고 보면 이 차이가 바로 보입니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce에서 움직임을 끕니다. 다만 03·04·07·08처럼 모양이 바뀌는 것 자체가 정보인 항목은 애니메이션만 끄면 최종 상태 자체가 안 보일 수 있어, 이런 항목은 전환 없이 곧바로 마지막 상태(글로우가 켜진 채, 체크가 다 그려진 채, 막대가 끝까지 채워진 채)로 표시하도록 했습니다. MDN의 prefers-reduced-motion 문서에 따르면 이 값은 운영체제 설정을 그대로 읽어오므로, 움직임을 줄여도 검증 결과라는 정보 자체는 항상 남겨야 합니다. transform·opacity만 움직이는 이유는 web.dev의 고성능 애니메이션 가이드에도 정리되어 있듯, 레이아웃을 다시 계산하지 않고 합성 레이어에서만 처리되기 때문입니다.
흔들림으로 오류를 알리는 쪽이 궁금하면 CSS 흔들리는 애니메이션 9종을, 체크박스·라디오처럼 다른 입력 요소의 검증 표시는 CSS 체크박스 커스텀 9종에서 볼 수 있습니다.
FAQ
회원가입 유효성 검사 애니메이션은 왜 색상 전환 대신 opacity 크로스페이드를 쓰나요?
color나 border-color를 직접 전환하면 브라우저가 두 색 사이 값을 매 프레임 계산해야 해서 연산이 늘고, 다크모드처럼 색 자체가 CSS 변수인 화면에서는 전환 중간값이 이상하게 보일 수 있습니다. 같은 자리에 최종 색을 가진 요소 두 개를 겹쳐 opacity만 바꾸면 계산이 훨씬 가볍고 색 자체는 처음부터 확정된 값입니다. 성공·실패를 사람 대신 표정으로 전하는 쪽은 마스코트 애니메이션 9종이 맡습니다.
체크 아이콘을 stroke-dashoffset 대신 두 막대 회전으로 그려도 되나요?
가능하지만 두 막대가 정확히 같은 꼭짓점에서 시작하지 않으면 체크 모양이 사선 한 줄로 어긋나 보이는 문제가 흔합니다. stroke-dasharray·stroke-dashoffset으로 SVG polyline 하나를 그리면 시작점 어긋남 문제 자체가 없어 04번처럼 더 안전합니다.
아홉 개를 한 폼에 전부 같이 써도 되나요?
되지만 필드마다 05(브리딩 링)·03(글로우 링)처럼 항상 켜져 있는 지속 애니메이션을 동시에 여러 개 두면 화면이 산만해집니다. 02·06처럼 상태가 바뀔 때만 잠깐 움직이는 것과 05처럼 오류가 남아있는 동안 계속 도는 것을 구분해서, 한 화면엔 지속 애니메이션을 하나 이하로 두는 걸 권합니다.









