09Bản tĩnh khi giảm chuyển động
Công tắc so sánh trực tiếp track đang chạy với trạng thái tĩnh mà prefers-reduced-motion: reduce thực sự đưa cho người dùng — track dừng lại đúng khung hình cuối.
Dùng choNgười dùng nhạy cảm tiền đình, trang kiểm toán khả năng tiếp cận
prefers-reduced-motion정적 목록토글 비교

<main class="stage">
<div class="panel is-demo">
<div class="panel__row">
<div class="marquee">
<div class="marquee__track">
<span class="marquee__chip">Thông báo</span>
<span class="marquee__chip">Cập nhật</span>
<span class="marquee__chip">Sự kiện</span>
<span class="marquee__chip" aria-hidden="true">Thông báo</span>
<span class="marquee__chip" aria-hidden="true">Cập nhật</span>
<span class="marquee__chip" aria-hidden="true">Sự kiện</span>
</div>
</div>
<span class="panel__state">
<span class="panel__state-text panel__state-text--play">Đang chạy</span>
<span class="panel__state-text panel__state-text--pause">Đã dừng</span>
</span>
</div>
<label class="panel__toggle" aria-hidden="true">
<span class="panel__toggle-track"><span class="panel__toggle-knob"></span></span>
<span class="panel__toggle-label">Giảm chuyển động</span>
</label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
$fg: $subject-cream;
@include stage($stage-ink);
.panel {
display: grid;
gap: 14px;
justify-items: center;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.panel__row {
display: grid;
gap: 8px;
justify-items: center;
}
.marquee {
width: min(240px, 70vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
}
.marquee__chip {
margin-right: 14px; // 트랙의 gap 대신 — 세트 폭이 정확히 절반이 되어 -50% 이음새가 0px 이 된다
flex-shrink: 0;
display: grid;
place-items: center;
height: 30px;
padding: 0 12px;
border-radius: $r-control;
background: rgba($fg, .1);
color: $fg;
font-weight: 700; font-size: 12px; line-height: 1; font-family: inherit;
}
.panel__state { position: relative; width: 100%; min-width: 84px; height: 16px; }
.panel__state-text {
position: absolute;
inset: 0;
width: 100%;
text-align: center;
white-space: nowrap;
font-weight: 700; font-size: 12px; line-height: 16px; font-family: inherit;
color: rgba($fg, .7);
}
.panel__toggle { display: flex; align-items: center; gap: 8px; }
.panel__toggle-track {
position: relative;
width: 36px;
height: 20px;
border-radius: $r-pill;
background: rgba($fg, .18);
}
.panel__toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
}
.panel__toggle-label {
font-weight: 600; font-size: 11px; line-height: 1; font-family: inherit;
color: rgba($fg, .68);
}
// 트랙은 토글이 꺼진 55% 구간까지만 움직이고, 켜진 뒤로는 그 자리에 그대로 멈춘다 —
// 실제 prefers-reduced-motion: reduce 사용자에게 나가는 화면과 같은 정지 상태다
.panel.is-demo .marquee__track { animation: marquee-toggle $duration linear infinite; }
.panel.is-demo .panel__toggle-knob { animation: toggle-knob $duration $easing infinite; }
.panel.is-demo .panel__toggle-track { animation: toggle-track $duration $easing infinite; }
.panel.is-demo .panel__state-text--play { animation: state-play $duration steps(1) infinite; }
.panel.is-demo .panel__state-text--pause { animation: state-pause $duration steps(1) infinite; }
@keyframes marquee-toggle {
0% { transform: translateX(0); }
55% { transform: translateX(-30%); }
100% { transform: translateX(-30%); }
}
@keyframes toggle-knob {
0%, 50% { transform: translateX(0); }
70%, 100% { transform: translateX(16px); }
}
@keyframes toggle-track {
0%, 50% { background: rgba($fg, .18); }
70%, 100% { background: rgba($color, .38); }
}
@keyframes state-play {
0%, 50% { opacity: 1; }
60%, 100% { opacity: 0; }
}
@keyframes state-pause {
0%, 50% { opacity: 0; }
60%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.marquee__track, .panel__toggle-knob, .panel__toggle-track,
.panel__state-text--play, .panel__state-text--pause {
animation: none !important;
}
.panel.is-demo .marquee__track { transform: translateX(-30%); }
.panel.is-demo .panel__toggle-knob { transform: translateX(16px); }
.panel.is-demo .panel__toggle-track { background: rgba($color, .38); }
.panel.is-demo .panel__state-text--play { opacity: 0; }
.panel.is-demo .panel__state-text--pause { opacity: 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);
}
.panel {
display: grid;
gap: 14px;
justify-items: center;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.panel__row {
display: grid;
gap: 8px;
justify-items: center;
}
.marquee {
width: min(240px, 70vw);
overflow: hidden;
}
.marquee__track {
display: flex;
width: max-content;
}
.marquee__chip {
margin-right: 14px;
flex-shrink: 0;
display: grid;
place-items: center;
height: 30px;
padding: 0 12px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.panel__state {
position: relative;
width: 100%;
min-width: 84px;
height: 16px;
}
.panel__state-text {
position: absolute;
inset: 0;
width: 100%;
text-align: center;
white-space: nowrap;
font-weight: 700;
font-size: 12px;
line-height: 16px;
font-family: inherit;
color: rgba(255, 247, 230, 0.7);
}
.panel__toggle {
display: flex;
align-items: center;
gap: 8px;
}
.panel__toggle-track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
}
.panel__toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2f6df6;
}
.panel__toggle-label {
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.68);
}
.panel.is-demo .marquee__track {
animation: marquee-toggle 2s linear infinite;
}
.panel.is-demo .panel__toggle-knob {
animation: toggle-knob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel.is-demo .panel__toggle-track {
animation: toggle-track 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel.is-demo .panel__state-text--play {
animation: state-play 2s steps(1) infinite;
}
.panel.is-demo .panel__state-text--pause {
animation: state-pause 2s steps(1) infinite;
}
@keyframes marquee-toggle {
0% {
transform: translateX(0);
}
55% {
transform: translateX(-30%);
}
100% {
transform: translateX(-30%);
}
}
@keyframes toggle-knob {
0%, 50% {
transform: translateX(0);
}
70%, 100% {
transform: translateX(16px);
}
}
@keyframes toggle-track {
0%, 50% {
background: rgba(255, 247, 230, 0.18);
}
70%, 100% {
background: rgba(47, 109, 246, 0.38);
}
}
@keyframes state-play {
0%, 50% {
opacity: 1;
}
60%, 100% {
opacity: 0;
}
}
@keyframes state-pause {
0%, 50% {
opacity: 0;
}
60%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.marquee__track, .panel__toggle-knob, .panel__toggle-track,
.panel__state-text--play, .panel__state-text--pause {
animation: none !important;
}
.panel.is-demo .marquee__track {
transform: translateX(-30%);
}
.panel.is-demo .panel__toggle-knob {
transform: translateX(16px);
}
.panel.is-demo .panel__toggle-track {
background: rgba(47, 109, 246, 0.38);
}
.panel.is-demo .panel__state-text--play {
opacity: 0;
}
.panel.is-demo .panel__state-text--pause {
opacity: 1;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ReducedMotionMarquee.module.scss";
export type ReducedMotionMarqueeProps = {
items?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ReducedMotionMarquee({
items = ["공지", "업데이트", "이벤트"],
duration = 12000,
easing = "linear",
color = "#2f6df6",
}: ReducedMotionMarqueeProps) {
const [reduced, setReduced] = useState(false);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
animationPlayState: reduced ? "paused" : "running",
} as Vars;
return (
<div className={styles.panel}>
<div className={styles.marquee}>
<div className={styles.track} style={vars}>
{items.map((it) => (
<span key={it} className={styles.chip}>{it}</span>
))}
{items.map((it) => (
<span key={"dup-" + it} className={styles.chip} aria-hidden="true">{it}</span>
))}
</div>
</div>
<span className={styles.state}>{reduced ? "정지됨" : "재생 중"}</span>
<button type="button" className={styles.toggle} aria-pressed={reduced} onClick={() => setReduced((v) => !v)}>
<span className={styles.switchTrack} data-on={reduced}><span className={styles.knob} /></span>
동작 줄이기
</button>
</div>
);
}09 Bản tĩnh khi giảm chuyển động — Diện tích đo được 5.672% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS Marquee Infinite Loop — chỉ CSS, không JS
Bài viết chứa demo này
9 CSS Marquee Infinite Loop — chỉ CSS, không JSCSS marquee infinite loop là kỹ thuật giúp một dải nội dung trôi liên tục, không lộ đường nối, dựng hoàn toàn bằng CSS v Xem tất cả demo trong bài này


