09모션 축소 시 정지 상태로 대체
평소엔 상태 아이콘이 scale(.5→1.15→1)과 opacity 로 팝인했다가 사라지는 루프지만, 시스템에서 모션을 줄이면 애니메이션이 전부 꺼지고 체크와 문구가 움직임 없이 마지막 상태로 고정됩니다.
쓰임모션 민감 사용자를 위한 폼, 접근성 우선 대시보드
scale+opacity 팝prefers-reduced-motion정지 상태 고정

<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 포함 · CSS 유효성 검사 애니메이션 9종 — 폼 에러·성공 표시
이 데모가 속한 글
CSS 유효성 검사 애니메이션 9종 — 폼 에러·성공 표시CSS 유효성 검사 애니메이션은 폼에 입력한 값이 맞는지 틀린지를 흔들림이 아니라 색·아이콘·글로우 같은 다른 신호로 알려주는 움직임입니다. 아래 아홉 개는 회원가입 폼에서 실제로 일어나는 순서대로 골라 복붙 한 번 이 글의 데모 전부 보기


