09Processing Badge That Swaps to Static Text When a Reduced-motion Value Is Detected
The system's reduced-motion setting value is detected, and when it is on a plain 'Processing' text badge appears instantly in place of the eight patterns above, which only animate at normal speed when it is off.
Best forA shared accessibility safeguard across every item above
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>Click or press the space bar to pause or restart</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="Reduced-motion badge-switch test panel" 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">Processing</span>
</div>
</div>
<p class="ld__state" id="state">Reduced motion: off · moving</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 = 'Reduced motion: on · processing badge';
} 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 = 'Reduced motion: off · moving';
}
}
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 Processing Badge That Swaps to Static Text When a Reduced-motion Value Is Detected — Measured area 2.808% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · AI Loading Animation: 9 Patterns by One Value
The post this demo lives in
AI Loading Animation: 9 Patterns by One ValueOne ai loading animation value: A high orbit-radius value scatters particles wide; near zero they snap onto the core, hu See all demos in this post
If you like this effect - similar demos
Loaders09 Reduced-motion static badge9 CSS AI Chat Loading Animations to Copy-Paste
Loaders09 Loading Button That Freezes to a Single Static Dot for Reduced MotionLoading Button UI — 9 States, Spinner to Success
Loaders09 Chart Loading That Skips Computation and Swaps Instantly Under Reduced MotionChart Loading Animation — 9 Computed Patterns