09동작 줄임 값을 감지하면 즉시 문구로 바뀌는 처리중 배지
시스템의 동작 줄임 설정 값을 감지해, 그 값이 켜져 있으면 위 여덟 패턴 대신 애니메이션 없이 '처리 중' 문구 배지가 바로 나타나고, 꺼져 있을 때만 원래 속도로 움직인다.
쓰임접근성을 지키는 전 항목 공통 안전장치
SWITCH_ATspringBOUNCE_HZ

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="동작 줄임 배지 전환 실험판" tabindex="0">
<div class="ld__scene">
<div class="ld__dots" id="dots" aria-hidden="true">
<i></i><i></i><i></i>
</div>
<div class="ld__badge" id="badge">
<b class="ld__badgeDot" aria-hidden="true"></b>
<span id="badgeText">처리 중</span>
</div>
</div>
<p class="ld__state" id="state">동작 줄임: 꺼짐 · 움직이는 중</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// reduced-motion-static-badge — 동작 줄임 설정 값을 감지해, 그 값이 켜져 있으면 애니메이션 없이
// '처리 중' 문구 배지가 즉시 나타나고, 꺼져 있을 때만 원래 속도로 움직인다. 이 항목 자체가
// 접근성 정지 프레임의 주제 — 루프 절반은 바운스 점(사인), 절반은 스프링 오버슛 배지. @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.ld {
--dot: #{$subject-blue-light};
--badge-bg: #{$subject-cream};
--badge-fg: #{$stage-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.ld__field {
position: relative; height: 208px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: $sp-3;
border-radius: $r-card; overflow: hidden;
background: rgba($subject-cream, .06);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .18);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ld__scene { position: relative; width: 100%; height: 64px; display: flex; align-items: center; justify-content: center; }
.ld__dots {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center; gap: $sp-2;
}
.ld__dots > i {
width: 14px; height: 14px; border-radius: 50%;
background: var(--dot);
}
.ld__badge {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center; gap: $sp-2;
opacity: 0; transform: scale(.7);
}
.ld__badgeDot { width: 10px; height: 10px; border-radius: 50%; background: var(--badge-fg); }
.ld__badge span {
padding: $sp-2 $sp-4; border-radius: $r-pill;
background: var(--badge-bg); color: var(--badge-fg);
font-weight: 700; font-size: 15px;
}
.ld__state {
margin: 0; font-size: 12px; font-weight: 600; color: rgba($subject-cream, .78);
}
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; gap: $sp-2; }
.ld__scene { height: 44px; }
.ld__dots > i { width: 10px; height: 10px; }
.ld__state { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: 1 !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);
}
.ld {
--dot: #5b8cf8;
--badge-bg: #fff7e6;
--badge-fg: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 247, 230, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.18);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ld__scene {
position: relative;
width: 100%;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.ld__dots {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.ld__dots > i {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--dot);
}
.ld__badge {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transform: scale(0.7);
}
.ld__badgeDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--badge-fg);
}
.ld__badge span {
padding: 8px 16px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 15px;
}
.ld__state {
margin: 0;
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.78);
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
gap: 8px;
}
.ld__scene {
height: 44px;
}
.ld__dots > i {
width: 10px;
height: 10px;
}
.ld__state {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 1 !important;
}
}(function () {
var LOOP = 2.0;
var SWITCH_AT = LOOP * 0.5; // 동작 줄임 값이 켜졌다고 보는 시각
var SPRING_MS = 0.36; // 배지가 나타날 때 스프링 오버슛 시간
var BOUNCE_HZ = 2.4; // 점 세 개 바운스 진동수
var root = document.getElementById('ld');
var field = document.getElementById('field');
var dots = document.getElementById('dots').children;
var badge = document.getElementById('badge');
var stateEl = document.getElementById('state');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
// 임계 감쇠 스프링 근사(오버슛 포함): t=0 에서 0, t=1 부근에서 1을 살짝 넘겼다 정착
function spring(t) {
var x = clamp01(t);
return 1 - Math.exp(-6 * x) * Math.cos(7 * x);
}
function draw(sec) {
var t = sec % LOOP;
var on = t >= SWITCH_AT; // 동작 줄임 값(시뮬레이션)
if (on) {
var localT = (t - SWITCH_AT) / SPRING_MS;
var s = spring(localT);
badge.style.opacity = '1';
badge.style.transform = 'scale(' + (0.7 + 0.3 * s).toFixed(3) + ')';
dots.length && (root.querySelector('.ld__dots').style.opacity = '0');
stateEl.textContent = '동작 줄임: 켜짐 · 처리 중 배지';
} else {
badge.style.opacity = '0';
badge.style.transform = 'scale(.7)';
root.querySelector('.ld__dots').style.opacity = '1';
for (var i = 0; i < dots.length; i++) {
var phase = i * (Math.PI * 2 / 3);
var y = Math.sin(t * BOUNCE_HZ * Math.PI * 2 + phase);
dots[i].style.transform = 'translateY(' + (y * 7).toFixed(1) + 'px)';
dots[i].style.opacity = (0.55 + 0.45 * (0.5 + 0.5 * y)).toFixed(2);
}
stateEl.textContent = '동작 줄임: 꺼짐 · 움직이는 중';
}
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000 % LOOP);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(SWITCH_AT + SPRING_MS); // 배지가 이미 자리 잡은 정지 프레임
} else {
raf = requestAnimationFrame(frame);
}
})();// ReduceBadge — vanilla 판(run/403/demo/09-ai-processing-loading-reduced-motion-static-badge/index.html) 의 React 포트.
// 동작 줄임 설정 값을 감지해, 그 값이 켜져 있으면 애니메이션 없이 '처리 중' 문구 배지가 즉시
// 나타나고, 꺼져 있을 때만 원래 속도로 움직인다. 이 항목 자체가 접근성 정지 프레임의 주제다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ReduceBadge.module.scss";
export type ReduceBadgeProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const SWITCH_AT = LOOP * 0.5;
const SPRING_MS = 0.36;
const BOUNCE_HZ = 2.4;
const FIELD_LABEL = "동작 줄임 배지 전환 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function spring(t: number): number {
const x = clamp01(t);
return 1 - Math.exp(-6 * x) * Math.cos(7 * x);
}
export default function ReduceBadge({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: ReduceBadgeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const dotsWrapRef = useRef<HTMLDivElement | null>(null);
const dotRefs = useRef<(HTMLElement | null)[]>([]) as Box<(HTMLElement | null)[]>;
const badgeRef = useRef<HTMLDivElement | null>(null);
const stateRef = useRef<HTMLParagraphElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function draw(sec: number): void {
const dotsWrap = dotsWrapRef.current;
const badge = badgeRef.current;
const stateEl = stateRef.current;
if (!dotsWrap || !badge) return;
const t = sec % LOOP;
const on = t >= SWITCH_AT;
if (on) {
const localT = (t - SWITCH_AT) / SPRING_MS;
const s = spring(localT);
badge.style.opacity = "1";
badge.style.transform = "scale(" + (0.7 + 0.3 * s).toFixed(3) + ")";
dotsWrap.style.opacity = "0";
if (stateEl) stateEl.textContent = "동작 줄임: 켜짐 · 처리 중 배지";
} else {
badge.style.opacity = "0";
badge.style.transform = "scale(.7)";
dotsWrap.style.opacity = "1";
for (let i = 0; i < dotRefs.current.length; i++) {
const phase = i * (Math.PI * 2 / 3);
const y = Math.sin(t * BOUNCE_HZ * Math.PI * 2 + phase);
const el = dotRefs.current[i];
if (el) {
el.style.transform = "translateY(" + (y * 7).toFixed(1) + "px)";
el.style.opacity = (0.55 + 0.45 * (0.5 + 0.5 * y)).toFixed(2);
}
}
if (stateEl) stateEl.textContent = "동작 줄임: 꺼짐 · 움직이는 중";
}
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000 % LOOP);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(SWITCH_AT + SPRING_MS);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.scene}>
<div className={styles.dots} ref={dotsWrapRef} aria-hidden="true">
<i ref={(el: HTMLElement | null) => { dotRefs.current[0] = el; }}></i>
<i ref={(el: HTMLElement | null) => { dotRefs.current[1] = el; }}></i>
<i ref={(el: HTMLElement | null) => { dotRefs.current[2] = el; }}></i>
</div>
<div className={styles.badge} ref={badgeRef}>
<b className={styles.badgeDot} aria-hidden="true"></b>
<span>처리 중</span>
</div>
</div>
<p className={styles.state} ref={stateRef}>동작 줄임: 꺼짐 · 움직이는 중</p>
</div>
</div>
</main>
);
}09 동작 줄임 값을 감지하면 즉시 문구로 바뀌는 처리중 배지 — 측정 누적면적 2.808% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · AI 로딩 애니메이션 9가지 — 값 하나로 도는 처리중 표시
이 데모가 속한 글
AI 로딩 애니메이션 9가지 — 값 하나로 도는 처리중 표시궤도 반경 값이 크면 입자가 넓게 흩어지는데 값이 0에 가까워지면 코어로 모이고, 그 사이 후광 링의 색상 값은 완료 색 쪽으로 넘어가며, 토큰 순번 값이 오를 때마다 미리보기 글자가 하나씩 늘어난다. 이 아홉 가지 이 글의 데모 전부 보기


