CSS バリデーション アニメーション9選 — フォーム入力エラーの見せ方
CSS バリデーションのアニメーションは、揺れではなく色・アイコン・グローのような別の合図で、フォームの値が合っているか間違っているかを伝える動きです。以下9つはコピペで動く形にまとめ、会員登録フォームで実際に起きる順番に並べました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 リアルタイムのインラインエラー
- 02 ラベルの状態色クロスフェード
- 03 フィールド検証のグローリング
- 04 成功バッジのバウンスチェック
- 05 エラーフィールドの呼吸リング
- 06 ステータスアイコンのスピナー→チェック・クロス変化
- 07 3段階パスワード強度バー
- 08 送信ボタンのローディング→成功変化
- 09 モーション削減時は静止表示に
9つは人気順ではなく、入力フォーム エラー表示をフォームを入力する人が実際に体験する順番で並べています。入力中にリアルタイムで出るメッセージ(01)からニックネームの使用可否(02)へ、検証を通過したフィールドが浮き上がる合図(03)と保存完了バッジ(04)を経て、消えずに残るエラー(05)と確認中の状態アイコン(06)、パスワード強度(07)、送信ボタンの一連の流れ(08)、最後はモーションを減らした人にも合図が残るか(09)という順です。同じfeedback柱で、状態を小さな札だけで伝えるのがcss バッジ デザイン 9選、その札を状態・優先度の軸で組み直したのがステータス バッジ UI 9選です。
01リアルタイムのインラインエラー
html css バリデーションで何が見えるか
メールアドレス欄に不正な形式の値を入れると、その下にあらかじめ確保した行がわずかに滑り込んでから、また消えます。メッセージが現れる瞬間、フィールドにはサブピクセル単位で収まる 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
使っているのは 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
color そのものを遷移させているのではなく、同じ位置に重ねた2枚のラベルが 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フィールド検証のグローリング
どんな動きか
検証に通ったフィールドの背後に、柔らかいミント色のリングが浮き上がります。ピークを過ぎるとぱたりと消えるのではなく、二段・三段と弱まる波が余韻のように収まっていきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
入力欄の裏に重ねた 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
バッジは 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番のような位置移動なしでも同じ場所で波の余韻を感じられます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
周りの淡いコーラル色のリングが 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
3つのアイコンを同じ位置に重ねて 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; }
073段階パスワード強度バー
何が見えるか
同じトラックの上に、弱・中・強の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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
それぞれ 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送信ボタンのローディング→成功変化
画面で起きること
1つのボタンが、押される動き(scale(1→.96→1))→ローディングスピナー→ミント色のチェックの順に、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ラベルが 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
@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 でクロスフェードさせるとき、同じキーフレーム1つを animation-direction: reverse で鏡のように逆再生すればコードが短くなって楽だと考えました。ところが reverse は進行率(0〜100%)を反転させるだけで、キーフレームが完全に対称でない限り、ループの中間あたりで両方のラベルの opacity が同時に1になる瞬間が生まれ、文字が重なって見えてしまいました。レンダリングしたフレームを1枚ずつ開いて初めて気づき、直した方法は reverse をやめて、opacity の値がすべての段で正確に反対になる2つ目の @keyframes(fv2-neutral)を新しく書くことでした。合図を抑える代わりに画面全体を重ねて積み上げる逆の方向はコラージュ デザインのLPパーツ9選で見られます。9つ全部のvanilla・Reactコードが入った圧縮ファイルのパスワードはmk7s3ujkで、02番フォルダのこの2つの @keyframes を並べて見ると違いがそのまま見えます。
アクセシビリティ
9つとも prefers-reduced-motion: reduce でアニメーションを切ります。ただし03・04・07・08は形の変化そのものが情報なので、アニメーションだけ切ると結果自体が見えなくなってしまいます。これらの項目は途中の動きを飛ばし、最終状態(グローが点いた状態、チェックが描き終わった状態、バーが満タンの状態、送信ボタンにチェックが出た状態)をそのまま表示します。MDNの prefers-reduced-motion のドキュメントによれば、この値はOSの設定をそのまま読み取るため、モーションを減らしても検証結果という情報自体は残す必要があります。transform と opacity だけを動かしているのは、web.devの高性能アニメーションガイドにもある通り、この2つはレイアウトの再計算を伴わないためです。
揺れでエラーを伝えるバージョンはCSS 揺れるエラーアニメーション9選、チェックボックス・ラジオボタンの検証表示はCSS カスタムチェックボックス9選にあります。
FAQ
css バリデーションチェックで色を直接遷移させず opacity クロスフェードを使うのはなぜですか?
color や border-color を直接遷移させると、ブラウザは毎フレーム中間色を計算する必要があり、色がCSS変数から来ている画面(ダークモードなど)では中間色がおかしく見えることがあります。最終的な色をすでに持つ要素を2つ重ねて opacity だけを動かせば、この計算自体が不要になり、色は最初のフレームから正しい値のままです。合否をフィールドではなく表情で伝える側はcss キャラクター アニメーション 9選が担います。
チェックアイコンは stroke-dashoffset の代わりに棒2本の回転で描いてもいいですか?
描けますが、2本の棒が同じ頂点から始まっていないと、チェックが斜め一本の線に見えてしまう不具合がよくあります。04番のようにSVGの polyline を stroke-dasharray・stroke-dashoffset で描けば、この始点のずれ自体が起きません。
9つを1つのフォームに全部同時に使っても大丈夫ですか?
動きますが、05の呼吸リングと03のグローのように常時動くループを何個も並べると、画面がせわしなく見えます。02・06のように状態が変わったときだけ動くものは残しつつ、05のように常にループするものは1画面に1つ以下にとどめるのがおすすめです。









