09모션 축소 시 아웃라인 대체
평소엔 translateX 로 흔들리지만, 시스템 설정에서 모션을 줄이면 같은 오류 신호를 opacity 로 깜빡이는 빨간 아웃라인으로 대체해 움직임 없이도 오류를 알립니다.
쓰임모션 민감 사용자를 위한 폼, 접근성 우선 대시보드
prefers-reduced-motionrm__ringopacity

<main class="stage">
<div class="rm is-demo">
<label class="rm__label" for="rm-msg">문의 내용</label>
<div class="rm__field">
<textarea class="rm__textarea" id="rm-msg" rows="2"></textarea>
<span class="rm__ring" aria-hidden="true"></span>
</div>
<p class="rm__msg">10자 이상 입력해야 합니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 500ms; // 평소 흔들림 한 번의 길이
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.rm { width: min(260px, 86vw); display: flex; flex-direction: column; gap: $sp-2; }
.rm__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; color: $subject-cream; }
.rm__field { position: relative; }
.rm__textarea {
box-sizing: border-box;
width: 100%;
resize: none;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px solid $color;
background: rgba(255, 255, 255, .06);
color: $subject-cream;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.rm__ring {
position: absolute;
inset: -$sp-1;
border: 2px solid $color;
border-radius: $r-sm;
opacity: 0;
pointer-events: none;
}
.rm__msg { margin: 0; color: $error-soft; font-weight: 500; font-size: 11px; line-height: 1.3; font-family: inherit; }
// 평소: translateX 로 흔든다
@keyframes rm-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-6px); }
4% { transform: translateX(6px); }
6% { transform: translateX(-6px); }
8% { transform: translateX(6px); }
10% { transform: translateX(-4px); }
12% { transform: translateX(4px); }
14% { transform: translateX(-2px); }
16% { transform: translateX(2px); }
}
.rm__textarea.rm--invalid { animation: rm-shake $duration $easing; }
.rm.is-demo .rm__textarea { animation: rm-shake $duration $easing infinite; }
// 모션 축소: 흔들림 대신 opacity 로 링만 두 번 깜빡인다 — 움직임은 없지만 오류는 여전히 보인다
@keyframes rm-ring-pulse {
0%, 100% { opacity: 0; }
25% { opacity: 1; }
50% { opacity: 0; }
75% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.rm__textarea.rm--invalid,
.rm.is-demo .rm__textarea { animation: none !important; }
.rm__ring.rm--invalid { animation: rm-ring-pulse $dur-loop ease-in-out; }
.rm.is-demo .rm__ring { animation: rm-ring-pulse $dur-loop ease-in-out infinite; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rm {
width: min(260px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.rm__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.rm__field {
position: relative;
}
.rm__textarea {
box-sizing: border-box;
width: 100%;
resize: none;
padding: 8px 12px;
border-radius: 8px;
border: 2px solid #c2361a;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-weight: 400;
font-size: 13px;
line-height: 1.4;
font-family: inherit;
}
.rm__ring {
position: absolute;
inset: -4px;
border: 2px solid #c2361a;
border-radius: 8px;
opacity: 0;
pointer-events: none;
}
.rm__msg {
margin: 0;
color: #ffe0d8;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
@keyframes rm-shake {
0%, 22%, 100% {
transform: translateX(0);
}
2% {
transform: translateX(-6px);
}
4% {
transform: translateX(6px);
}
6% {
transform: translateX(-6px);
}
8% {
transform: translateX(6px);
}
10% {
transform: translateX(-4px);
}
12% {
transform: translateX(4px);
}
14% {
transform: translateX(-2px);
}
16% {
transform: translateX(2px);
}
}
.rm__textarea.rm--invalid {
animation: rm-shake 500ms ease-out;
}
.rm.is-demo .rm__textarea {
animation: rm-shake 500ms ease-out infinite;
}
@keyframes rm-ring-pulse {
0%, 100% {
opacity: 0;
}
25% {
opacity: 1;
}
50% {
opacity: 0;
}
75% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__textarea.rm--invalid,
.rm.is-demo .rm__textarea {
animation: none !important;
}
.rm__ring.rm--invalid {
animation: rm-ring-pulse 2s ease-in-out;
}
.rm.is-demo .rm__ring {
animation: rm-ring-pulse 2s ease-in-out infinite;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ReducedMotionOutline.module.scss";
export type ReducedMotionOutlineProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ReducedMotionOutline({ duration = 500, easing = "ease-out", color = "#c2361a" }: ReducedMotionOutlineProps) {
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.rm} style={vars}>
<label className={styles.label} htmlFor="rm-msg">문의 내용</label>
<div className={styles.field}>
<textarea key={run} id="rm-msg" rows={2} className={`${styles.textarea} ${styles.invalid}`} onFocus={() => setRun((n) => n + 1)} />
<span key={`ring-${run}`} className={`${styles.ring} ${styles.invalid}`} aria-hidden="true" />
</div>
<p className={styles.msg}>10자 이상 입력해야 합니다</p>
</div>
);
}09 모션 축소 시 아웃라인 대체 — 측정 누적면적 0.336% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 흔들리는 애니메이션 9종 — 폼 에러 표시
이 데모가 속한 글
CSS 흔들리는 애니메이션 9종 — 폼 에러 표시비밀번호를 잘못 입력했을 때 입력창이 좌우로 흔들리는 걸 본 적 있으신가요? 그 동작이 바로 css 흔들리는 애니메이션입니다 — 자바스크립트 없이 CSS 코드 몇 줄만으로 폼이 틀렸다는 걸 눈에 띄게 알려줍니다. 아 이 글의 데모 전부 보기


