09모션 감소 정적 저장 표시 — 모션 감소 설정에서 애니메이션 대신 문구 값만 그대로 바뀌는 방식
모션 감소가 켜져 있으면 위치나 크기 값은 움직이지 않고, 문구의 텍스트 값만 그대로 바뀝니다.
쓰임접근성 설정을 지킨 저장 상태 표시, 모션 최소화를 요청한 사용자 화면
SAVING_MSIDLE_MSPULSE_PERIOD_MS

<main class="stage">
<div class="srm is-demo" id="srm">
<div class="srm__panel">
<span class="srm__tag">일반 모션</span>
<span class="srm__badge" id="srmBadgeA"><span class="srm__dot"></span><span id="srmLabelA">저장 중</span></span>
</div>
<div class="srm__panel">
<span class="srm__tag">모션 감소</span>
<span class="srm__badge srm__badge--static" id="srmBadgeB"><span class="srm__dot"></span><span id="srmLabelB">저장 중</span></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 일반 판은 경과시간 기반 sin 진폭으로 밝기가 바뀌고, 모션 감소 판은 같은 상태 값을 트윈 없이 즉시 반영 — index.html 참조
@include stage($stage-paper);
.srm {
display: flex;
gap: $sp-5;
cursor: pointer;
}
.srm__panel {
display: grid;
justify-items: center;
gap: $sp-2;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
}
.srm__tag {
font: 600 10px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .6);
text-transform: uppercase;
letter-spacing: .04em;
}
.srm__badge {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-pill;
background: $stage-ink;
color: $subject-cream;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.srm__dot {
width: 7px;
height: 7px;
border-radius: $r-pill;
background: $subject-blue-light;
flex: 0 0 auto;
}
.srm__badge--done .srm__dot { background: $subject-mint; }
@media (max-width: 340px) {
.srm { gap: $sp-3; }
.srm__panel { padding: $sp-3 $sp-4; }
.srm__badge { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// 모션 감소 판의 값 갱신은 원래 트윈이 없다 — OS 설정이 켜지면 일반 판도 밝기 계산을 건너뛴다(index.html)
.srm__badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.srm {
display: flex;
gap: 20px;
cursor: pointer;
}
.srm__panel {
display: grid;
justify-items: center;
gap: 8px;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
}
.srm__tag {
font: 600 10px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.6);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.srm__badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.srm__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #5b8cf8;
flex: 0 0 auto;
}
.srm__badge--done .srm__dot {
background: #4cd4a6;
}
@media (max-width: 340px) {
.srm {
gap: 12px;
}
.srm__panel {
padding: 12px 16px;
}
.srm__badge {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.srm__badge {
transition: none;
}
}(function () {
var root = document.getElementById('srm');
var badgeA = document.getElementById('srmBadgeA');
var labelA = document.getElementById('srmLabelA');
var badgeB = document.getElementById('srmBadgeB');
var labelB = document.getElementById('srmLabelB');
// 상수: 저장 중 유지시간 · 저장됨 유지시간 · 일반 판 펄스 한 주기
var SAVING_MS = 900;
var IDLE_MS = 700;
var PULSE_PERIOD_MS = 500;
var phase = 'saving';
var phaseStart = null;
// 계산 겹: 일반 판은 경과시간 기반 sin 진폭으로 서서히 밝기가 바뀌고, 모션 감소 판은 같은 상태 값을 트윈 없이 즉시 반영한다
function raf(ts) {
if (phaseStart === null) phaseStart = ts;
var el = ts - phaseStart;
if (phase === 'saving') {
var t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
badgeA.style.opacity = (0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2))).toFixed(3);
if (el > SAVING_MS) {
phase = 'idle'; phaseStart = ts;
labelA.textContent = labelB.textContent = '저장됨';
badgeA.style.opacity = '1';
badgeA.classList.add('srm__badge--done'); badgeB.classList.add('srm__badge--done');
}
} else {
if (el > IDLE_MS) {
phase = 'saving'; phaseStart = ts;
labelA.textContent = labelB.textContent = '저장 중';
badgeA.classList.remove('srm__badge--done'); badgeB.classList.remove('srm__badge--done');
}
}
requestAnimationFrame(raf);
}
root.addEventListener('click', function () {
root.classList.remove('is-demo');
phase = 'saving';
phaseStart = null;
labelA.textContent = labelB.textContent = '저장 중';
badgeA.classList.remove('srm__badge--done'); badgeB.classList.remove('srm__badge--done');
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { requestAnimationFrame(raf); }
else {
// 모션 감소 설정에서는 일반 판도 펄스 계산 없이 상태만 값으로 바뀐다
setInterval(function () {
phase = phase === 'saving' ? 'idle' : 'saving';
var text = phase === 'saving' ? '저장 중' : '저장됨';
labelA.textContent = labelB.textContent = text;
badgeA.style.opacity = '1';
badgeA.classList.toggle('srm__badge--done', phase === 'idle');
badgeB.classList.toggle('srm__badge--done', phase === 'idle');
}, SAVING_MS);
}
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusReducedMotion.module.scss";
type Box<T> = { current: T };
export default function SaveStatusReducedMotion() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const badges = Array.from(root.querySelectorAll(`.${styles.badge}`)) as HTMLElement[];
const labels = Array.from(root.querySelectorAll(`.${styles.label}`)) as HTMLElement[];
const badgeA = badges[0];
const badgeB = badges[1];
const labelA = labels[0];
const labelB = labels[1];
if (!badgeA || !badgeB || !labelA || !labelB) return;
// 상수: 저장 중 유지시간 · 저장됨 유지시간 · 일반 판 펄스 한 주기
const SAVING_MS = 900;
const IDLE_MS = 700;
const PULSE_PERIOD_MS = 500;
let phase: "saving" | "idle" = "saving";
let phaseStart: number | null = null;
let rafId = 0;
let intervalId = 0;
// 계산 겹: 일반 판은 경과시간 기반 sin 진폭으로 서서히 밝기가 바뀌고, 모션 감소 판은 같은 상태 값을 트윈 없이 즉시 반영한다
function raf(ts: number) {
if (phaseStart === null) phaseStart = ts;
const el = ts - phaseStart;
if (phase === "saving") {
const t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
badgeA.style.opacity = (0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2))).toFixed(3);
if (el > SAVING_MS) {
phase = "idle"; phaseStart = ts;
labelA.textContent = labelB.textContent = "저장됨";
badgeA.style.opacity = "1";
badgeA.classList.add(styles["badge--done"]); badgeB.classList.add(styles["badge--done"]);
}
} else {
if (el > IDLE_MS) {
phase = "saving"; phaseStart = ts;
labelA.textContent = labelB.textContent = "저장 중";
badgeA.classList.remove(styles["badge--done"]); badgeB.classList.remove(styles["badge--done"]);
}
}
rafId = requestAnimationFrame(raf);
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
phase = "saving"; phaseStart = null;
labelA.textContent = labelB.textContent = "저장 중";
badgeA.classList.remove(styles["badge--done"]); badgeB.classList.remove(styles["badge--done"]);
}
root.addEventListener("click", onClick);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
rafId = requestAnimationFrame(raf);
} else {
intervalId = window.setInterval(() => {
phase = phase === "saving" ? "idle" : "saving";
const text = phase === "saving" ? "저장 중" : "저장됨";
labelA.textContent = labelB.textContent = text;
badgeA.style.opacity = "1";
badgeA.classList.toggle(styles["badge--done"], phase === "idle");
badgeB.classList.toggle(styles["badge--done"], phase === "idle");
}, SAVING_MS);
}
return () => {
cancelAnimationFrame(rafId);
window.clearInterval(intervalId);
root!.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.srm} ${styles["is-demo"]}`}>
<div className={styles.panel}>
<span className={styles.tag}>일반 모션</span>
<span className={styles.badge}>
<span className={styles.dot} />
<span className={styles.label}>저장 중</span>
</span>
</div>
<div className={styles.panel}>
<span className={styles.tag}>모션 감소</span>
<span className={styles.badge}>
<span className={styles.dot} />
<span className={styles.label}>저장 중</span>
</span>
</div>
</div>
);
}09 모션 감소 정적 저장 표시 — 모션 감소 설정에서 애니메이션 대신 문구 값만 그대로 바뀌는 방식 — 측정 누적면적 2.274% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지
이 데모가 속한 글
자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지자동저장 표시는 지금 저장이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 저장 중이라는 문구가 점 세 개와 함께 반복되고, 하나는 마지막 저장 시각 값이 초 단위로 이 글의 데모 전부 보기


