09Đổi outline khi giảm chuyển động
Bình thường rung bằng translateX, nhưng khi hệ thống bật giảm chuyển động, cùng một tín hiệu lỗi đổi thành viền đỏ nhấp nháy bằng opacity.
Dùng choForm cho người nhạy với chuyển động, dashboard ưu tiên accessibility
prefers-reduced-motionrm__ringopacity

<main class="stage">
<div class="rm is-demo">
<label class="rm__label" for="rm-msg">Nội dung liên hệ</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">Phải nhập ít nhất 10 ký tự</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 Đổi outline khi giảm chuyển động — Diện tích đo được 0.336% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 css shake animation — hiệu ứng rung báo lỗi form
Bài viết chứa demo này
9 css shake animation — hiệu ứng rung báo lỗi formBạn từng thấy ô mật khẩu rung qua rung lại khi gõ sai chưa? Đó là css shake animation — vài dòng CSS, không cần JS, đủ b Xem tất cả demo trong bài này


