자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지
자동저장 표시는 지금 저장이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 저장 중이라는 문구가 점 세 개와 함께 반복되고, 하나는 마지막 저장 시각 값이 초 단위로 갱신되며 몇 분 전이라는 말로 바뀌고, 또 하나는 오프라인일 때 대기
아홉 항목 전부 저장 관련 버튼(또는 09는 카드 전체) click 이벤트에 반응해 값을 재설정하는 결정론적 계산이며 난수는 0 — 같은 순서로 클릭하면 항상 같은 값이 나옵니다. 이런 계산은 문서 편집기의 자동저장 기능이나 폼의 자동저장 위치 표시줄에 흔히 쓰이는 저장 상태 계산과 같은 뼈대이지만, 화면 속 저장·전송·오프라인은 전부 가상 시뮬레이션이라 실제 fetch·navigator.onLine·localStorage 호출은 하나도 없고 실서버·실계정 정보도 담지 않습니다.
01 save-status-saving-pulse
02 save-status-saved-check
03 save-status-error-retry
04 save-status-offline-queue
05 save-status-timestamp-relative
06 save-status-dot-cycle
07 save-status-conflict-banner
08 save-status-autosave-countdown
09 save-status-reduced-motion
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 저장 중 문구 펄스 — 저장이 진행되는 동안 문구의 밝기 값이 오르내리기를 반복하는 방식
- 02 저장 완료 값 반짝 — 저장이 끝나는 순간 완료 값이 1로 바뀌었다가 일정 시간 뒤 다시 0으로 사라지는 방식
- 03 저장 실패 재시도 카운트 — 저장이 실패하면 남은 재시도 횟수 값이 하나씩 줄며 문구가 흔들리는 방식
- 04 오프라인 대기 건수 — 연결이 끊긴 동안 쌓인 대기 저장 건수 값이 연결이 돌아오는 순간 0으로 줄어드는 방식
- 05 마지막 저장 시각 갱신 — 마지막으로 저장된 뒤 지난 시간 값이 초마다 갱신되며 문구로 바뀌는 방식
- 06 상태 점 색 순환 — 대기·진행·완료 세 단계에 따라 점의 색상 값이 차례로 바뀌는 방식
- 07 저장 충돌 안내 — 같은 항목이 다른 곳에서 먼저 저장되면 안내 값이 나타나 선택을 기다리는 방식
- 08 다음 자동저장 카운트다운 — 다음 저장까지 남은 시간 값이 0을 향해 줄어드는 링 방식
- 09 모션 감소 정적 저장 표시 — 모션 감소 설정에서 애니메이션 대신 문구 값만 그대로 바뀌는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
아홉 항목 모두 버튼(01 지금 저장·02 변경 저장·03 다시 저장·04 연결 전환·05 지금 저장·06 다음 단계·07 내 변경 유지·새로고침 중 하나·08 지금 저장·09 카드 전체) click 이벤트로 값을 재설정·전환할 수 있고, 로드 즉시 도는 자동 시연 루프는 값을 미리 보여주는 보조 장치일 뿐 리스너 자체를 대신하지 않습니다.
| 번호 | 이름 | 리스너 | 누적면적% | 강도 | 움직인 프레임/23 |
|---|---|---|---|---|---|
| 01 | 저장 중 문구 펄스 | click(지금 저장 버튼) | 0.41 | 39.1 | 19 |
| 02 | 저장 완료 값 반짝 | click(변경 저장 버튼) | 0.64 | 53.4 | 11 |
| 03 | 저장 실패 재시도 카운트 | click(다시 저장 버튼) | 1.65 | 109.5 | 10 |
| 04 | 오프라인 대기 건수 | click(연결 전환 버튼) | 1.76 | 148.7 | 22 |
| 05 | 마지막 저장 시각 갱신 | click(지금 저장 버튼) | 0.82 | 104.4 | 21 |
| 06 | 상태 점 색 순환 | click(다음 단계 버튼) | 0.89 | 60.4 | 18 |
| 07 | 저장 충돌 안내 | click(선택 버튼 — 내 변경 유지·새로고침) | 11.11 | 127.8 | 10 |
| 08 | 다음 자동저장 카운트다운 | click(지금 저장 버튼) | 2.63 | 158.9 | 18 |
| 09 | 모션 감소 정적 저장 표시 | click(카드 — 두 패널 값 동시 갱신) | 2.27 | 57.7 | 16 |
09는 컴포넌트 편 성격이 강해 면적이 작은 것이 정상입니다 — 면적이 아니라 스프링·물성 한 겹 같은 정교함으로 재는 항목입니다. 아홉 항목이 쓰는 계산식을 겹치는 것끼리 묶으면 아래 다섯 무리로 정리됩니다.
감쇠 사인으로 밝기가 오르내리는 값 — 01·09
01과 09는 같은 계산을 공유합니다 — 0.5 + 0.5×sin(t·2π) 로 만든 사인 파형이 저장 중 문구·배지의 밝기(opacity) 값을 PULSE_PERIOD_MS 주기로 오르내리게 합니다. 01은 이 파형을 점 세 개에 DOT_STAGGER_MS 만큼 위상을 어긋내 순서대로 밝히고, 09는 같은 파형을 쓰는 일반 모션 패널(A)과 파형 없이 클래스만 즉시 바뀌는 패널(B)을 나란히 놓아 두 계산의 차이를 눈으로 대조합니다.
시간 비율로 그려지는 값 — 02·08
02는 DRAW_MS 동안 흐른 시간의 비율(t)을 SVG 체크 경로의 stroke-dashoffset 값(길이×(1−t))으로 바꿔 체크 표시를 그리고, 08은 반대로 남은 초의 비율을 원형 링의 stroke-dashoffset 값(둘레×(1−remaining/CYCLE_SEC))으로 바꿔 줄어드는 링을 그립니다. 둘 다 getTotalLength·2πR 로 구한 실제 길이에 비율 하나만 곱해 그리기 진행을 표현합니다.
기하급수·문턱으로 늘거나 줄어드는 값 — 03·04
03은 실패 순번을 지수의 자리에 넣어 BACKOFF_BASE_MS × 2^순번 으로 다음 시도까지 대기 값을 두 배씩 불리고, 04는 실패 대신 오프라인 지속시간을 셈해 대기 건수 값이 ADD_MS 간격으로 하나씩 늘다 MAX_QUEUE 문턱에 닿는 순간 온라인으로 뒤집혀 값이 거꾸로 줄어듭니다. 하나는 대기 시간이 지수로 커지고 하나는 건수가 문턱에서 방향을 바꾼다는 점이 다릅니다.
스스로 진행하는 타이머 값 — 06·07
06은 STEP_MS 마다 stageIndex 값이 모듈로 연산으로 다음 단계를 돌고, 07은 APPEAR_MS 마다 스스로 나타나는 배너 위치 값을 스프링-감쇠 물리(−SPRING_K×변위 − SPRING_C×속도)로 목표까지 되돌립니다. 두 계산 모두 클릭이 없어도 자체 타이머 체인으로 계속 돌지만, 06은 이산적인 모듈로 점프이고 07은 연속적인 물리 적분이라는 점이 다릅니다.
경과 시간이 스스로 늘어나는 값 — 05
05만은 다섯 갈래 중 유일하게 클릭 시점을 기준으로 경과 시간 값이 프레임마다 스스로 계속 자라나며((지금−저장시각)÷DEMO_SEC_MS), 그 값이 MIN_THRESHOLD_SEC·RESET_AFTER_SEC 두 문턱을 지날 때마다 문구와 밝기가 함께 바뀝니다.
패턴별 코드
01저장 중 문구 펄스 — 저장이 진행되는 동안 문구의 밝기 값이 오르내리기를 반복하는 방식
지금 저장 버튼을 클릭하면 SAVING_MS 2400ms 동안 저장 중 문구가 유지되고, 그동안 문구와 점 세 개의 밝기 값이 PULSE_PERIOD_MS 900ms 주기의 사인 파형으로 오르내립니다. 점 세 개는 DOT_STAGGER_MS 160ms 씩 서로 위상을 어긋내 순서대로 밝아지고, SAVING_MS 가 지나면 저장됨으로 바뀌었다가 IDLE_MS 1000ms 뒤 다시 저장 중으로 돌아갑니다.
if (reduce) {
label.style.opacity = '1';
dots.forEach(function (d) { d.style.opacity = '.6'; });
} else {
var t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
var op = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2));
label.style.opacity = op.toFixed(3);
dots.forEach(function (d, i) {
var dop = 0.2 + 0.8 * Math.abs(Math.sin((el - i * DOT_STAGGER_MS) / PULSE_PERIOD_MS * Math.PI));
d.style.opacity = dop.toFixed(3);
});
}

<main class="stage">
<div class="ssp is-demo" id="ssp">
<div class="ssp__card">
<span class="ssp__dot" id="sspDot"></span>
<span class="ssp__label" id="sspLabel">저장 중</span>
<span class="ssp__dots" aria-hidden="true">
<span class="ssp__d" id="sspD0"></span><span class="ssp__d" id="sspD1"></span><span class="ssp__d" id="sspD2"></span>
</span>
</div>
<button class="ssp__btn" id="sspBtn" type="button">지금 저장</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 문구 밝기 값 = 경과시간÷주기 위상의 sin 진폭, 점 세 개는 위상을 어긋내 순차 점멸 — index.html 참조
@include stage($stage-yellow);
.ssp__card {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-5;
border-radius: $r-card;
background: $stage-ink;
}
.ssp__dot {
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: $subject-blue-light;
flex: 0 0 auto;
}
.ssp--done .ssp__dot { background: $subject-mint; }
.ssp__label {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
min-width: 64px;
}
.ssp__dots {
display: flex;
gap: 4px;
}
.ssp__d {
width: 4px;
height: 4px;
border-radius: $r-pill;
background: $subject-cream;
}
.ssp__btn {
margin-top: $sp-4;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
.ssp { display: grid; justify-items: center; }
@media (max-width: 340px) {
.ssp__card { padding: $sp-2 $sp-4; }
.ssp__label { font-size: 13px; min-width: 56px; }
}
@media (prefers-reduced-motion: reduce) {
.ssp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ssp__card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-radius: 16px;
background: #17141a;
}
.ssp__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #5b8cf8;
flex: 0 0 auto;
}
.ssp--done .ssp__dot {
background: #4cd4a6;
}
.ssp__label {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
min-width: 64px;
}
.ssp__dots {
display: flex;
gap: 4px;
}
.ssp__d {
width: 4px;
height: 4px;
border-radius: 999px;
background: #fff7e6;
}
.ssp__btn {
margin-top: 16px;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ssp__btn:active {
transform: scale(0.96);
}
.ssp {
display: grid;
justify-items: center;
}
@media (max-width: 340px) {
.ssp__card {
padding: 8px 16px;
}
.ssp__label {
font-size: 13px;
min-width: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.ssp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ssp');
var btn = document.getElementById('sspBtn');
var label = document.getElementById('sspLabel');
var dot = document.getElementById('sspDot');
var dots = [document.getElementById('sspD0'), document.getElementById('sspD1'), document.getElementById('sspD2')];
// 상수: 펄스 한 주기 · 점 세 개의 위상 차 · 저장 중 유지시간 · 저장됨 유지시간
var PULSE_PERIOD_MS = 900;
var DOT_STAGGER_MS = 160;
var SAVING_MS = 2400;
var IDLE_MS = 1000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var phase = 'saving';
var phaseStart = null;
// 계산 겹: 문구 밝기 값 = 경과시간을 주기로 나눈 위상의 sin 진폭, 점마다 위상을 어긋내 순차로 밝아진다
function raf(ts) {
if (phaseStart === null) phaseStart = ts;
var el = ts - phaseStart;
if (phase === 'saving') {
if (reduce) {
label.style.opacity = '1';
dots.forEach(function (d) { d.style.opacity = '.6'; });
} else {
var t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
var op = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2));
label.style.opacity = op.toFixed(3);
dots.forEach(function (d, i) {
var dop = 0.2 + 0.8 * Math.abs(Math.sin((el - i * DOT_STAGGER_MS) / PULSE_PERIOD_MS * Math.PI));
d.style.opacity = dop.toFixed(3);
});
}
if (el > SAVING_MS) {
phase = 'idle';
phaseStart = ts;
label.textContent = '저장됨';
label.style.opacity = '1';
dots.forEach(function (d) { d.style.opacity = '0'; });
root.classList.add('ssp--done');
}
} else {
if (el > IDLE_MS) {
phase = 'saving';
phaseStart = ts;
label.textContent = '저장 중';
root.classList.remove('ssp--done');
}
}
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
phase = 'saving';
phaseStart = null;
label.textContent = '저장 중';
root.classList.remove('ssp--done');
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusSavingPulse.module.scss";
type Box<T> = { current: T };
export default function SaveStatusSavingPulse() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const dotEls = Array.from(root.querySelectorAll(`.${styles.d}`)) as HTMLElement[];
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!label || !btn || dotEls.length < 3) return;
// 상수: 펄스 한 주기 · 점 세 개의 위상 차 · 저장 중 유지시간 · 저장됨 유지시간
const PULSE_PERIOD_MS = 900;
const DOT_STAGGER_MS = 160;
const SAVING_MS = 2400;
const IDLE_MS = 1000;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let phase: "saving" | "idle" = "saving";
let phaseStart: number | null = null;
let rafId = 0;
// 계산 겹: 문구 밝기 값 = 경과시간을 주기로 나눈 위상의 sin 진폭, 점마다 위상을 어긋내 순차로 밝아진다
function raf(ts: number) {
if (phaseStart === null) phaseStart = ts;
const el = ts - phaseStart;
if (phase === "saving") {
if (reduce) {
label!.style.opacity = "1";
dotEls.forEach((d) => { d.style.opacity = ".6"; });
} else {
const t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
const op = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2));
label!.style.opacity = op.toFixed(3);
dotEls.forEach((d, i) => {
const dop = 0.2 + 0.8 * Math.abs(Math.sin((el - i * DOT_STAGGER_MS) / PULSE_PERIOD_MS * Math.PI));
d.style.opacity = dop.toFixed(3);
});
}
if (el > SAVING_MS) {
phase = "idle"; phaseStart = ts;
label!.textContent = "저장됨";
label!.style.opacity = "1";
dotEls.forEach((d) => { d.style.opacity = "0"; });
root!.classList.add(styles["ssp--done"]);
}
} else {
if (el > IDLE_MS) {
phase = "saving"; phaseStart = ts;
label!.textContent = "저장 중";
root!.classList.remove(styles["ssp--done"]);
}
}
rafId = requestAnimationFrame(raf);
}
rafId = requestAnimationFrame(raf);
function onClick() {
root!.classList.remove(styles["is-demo"]);
phase = "saving"; phaseStart = null;
label!.textContent = "저장 중";
root!.classList.remove(styles["ssp--done"]);
}
btn.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ssp} ${styles["is-demo"]}`}>
<div className={styles.card}>
<span className={styles.dot} />
<span className={styles.label}>저장 중</span>
<span className={styles.dots} aria-hidden="true">
<span className={styles.d} />
<span className={styles.d} />
<span className={styles.d} />
</span>
</div>
<button className={styles.btn} type="button">지금 저장</button>
</div>
);
}01 · 저장 중 문구 펄스 — 저장이 진행되는 동안 문구의 밝기 값이 오르내리기를 반복하는 방식 — 측정 누적면적 0.408% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02저장 완료 값 반짝 — 저장이 끝나는 순간 완료 값이 1로 바뀌었다가 일정 시간 뒤 다시 0으로 사라지는 방식
변경 저장 버튼을 클릭하면 draw() 가 SVG 체크 경로의 실제 길이(getTotalLength)를 기준으로 DRAW_MS 300ms 동안 stroke-dashoffset 값을 길이×(1−t) 로 줄여 체크 표시를 그립니다. 다 그려지면 HOLD_MS 380ms 유지된 뒤 FADE_MS 200ms 에 걸쳐 사라지고, GAP_MS 260ms 뒤 같은 그리기가 반복됩니다.
function draw(cb) {
if (reduce) { check.style.strokeDashoffset = '0'; cb(); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / DRAW_MS);
check.style.strokeDashoffset = (len * (1 - t)).toFixed(2);
if (t < 1) requestAnimationFrame(frame); else cb();
}
requestAnimationFrame(frame);
}

<main class="stage">
<div class="ssc is-demo" id="ssc">
<div class="ssc__badge" id="sscBadge">
<svg class="ssc__ring" viewBox="0 0 40 40" width="40" height="40">
<circle class="ssc__circle" cx="20" cy="20" r="17"></circle>
<polyline class="ssc__check" id="sscCheck" points="12,21 17,26 28,13"></polyline>
</svg>
</div>
<span class="ssc__label" id="sscLabel">저장 완료</span>
<button class="ssc__btn" id="sscBtn" type="button">변경 저장</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: SVG 경로 실측 길이(getTotalLength) 기준 stroke-dashoffset 산술로 체크를 그린다 — index.html 참조
@include stage($stage-paper);
.ssc {
display: grid;
justify-items: center;
gap: $sp-3;
}
.ssc__badge {
width: 56px;
height: 56px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(.7);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.ssc__badge--show {
opacity: 1;
transform: scale(1);
}
.ssc__circle {
fill: $subject-mint;
opacity: .16;
}
.ssc__check {
fill: none;
stroke: $subject-mint;
stroke-width: 3.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ssc__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
.ssc__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.ssc__badge { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.ssc__badge { transition: none; }
.ssc__btn { 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);
}
.ssc {
display: grid;
justify-items: center;
gap: 12px;
}
.ssc__badge {
width: 56px;
height: 56px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.7);
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ssc__badge--show {
opacity: 1;
transform: scale(1);
}
.ssc__circle {
fill: #4cd4a6;
opacity: 0.16;
}
.ssc__check {
fill: none;
stroke: #4cd4a6;
stroke-width: 3.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ssc__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
.ssc__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ssc__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.ssc__badge {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.ssc__badge {
transition: none;
}
.ssc__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ssc');
var btn = document.getElementById('sscBtn');
var badge = document.getElementById('sscBadge');
var check = document.getElementById('sscCheck');
// 상수: 체크 선이 그려지는 시간 · 다 그려진 뒤 머무는 시간 · 사라지는 시간 · 다음 저장까지 간격
var DRAW_MS = 300;
var HOLD_MS = 380;
var FADE_MS = 200;
var GAP_MS = 260;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: getTotalLength() 로 실제 경로 길이를 재고, stroke-dashoffset = 길이×(1−t) 로 그려 나간다
var len = check.getTotalLength();
check.style.strokeDasharray = String(len);
check.style.strokeDashoffset = String(len);
var timer = null;
function draw(cb) {
if (reduce) { check.style.strokeDashoffset = '0'; cb(); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / DRAW_MS);
check.style.strokeDashoffset = (len * (1 - t)).toFixed(2);
if (t < 1) requestAnimationFrame(frame); else cb();
}
requestAnimationFrame(frame);
}
function cycle() {
badge.classList.add('ssc__badge--show');
draw(function () {
timer = setTimeout(function () {
badge.classList.remove('ssc__badge--show');
timer = setTimeout(function () {
check.style.strokeDashoffset = String(len);
timer = setTimeout(cycle, GAP_MS);
}, reduce ? 0 : FADE_MS);
}, HOLD_MS);
});
}
cycle();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
check.style.strokeDashoffset = String(len);
badge.classList.remove('ssc__badge--show');
requestAnimationFrame(cycle);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusSavedCheck.module.scss";
type Box<T> = { current: T };
export default function SaveStatusSavedCheck() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const badge = root.querySelector(`.${styles.badge}`) as HTMLElement | null;
const check = root.querySelector(`.${styles.check}`) as SVGPolylineElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!badge || !check || !btn) return;
// 상수: 체크 선이 그려지는 시간 · 다 그려진 뒤 머무는 시간 · 사라지는 시간 · 다음 저장까지 간격
const DRAW_MS = 300;
const HOLD_MS = 380;
const FADE_MS = 200;
const GAP_MS = 260;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: getTotalLength() 로 실제 경로 길이를 재고, stroke-dashoffset = 길이×(1−t) 로 그려 나간다
const len = check.getTotalLength();
check.style.strokeDasharray = String(len);
check.style.strokeDashoffset = String(len);
let timer = 0;
let rafId = 0;
let stopped = false;
function draw(cb: () => void) {
if (reduce) { check!.style.strokeDashoffset = "0"; cb(); return; }
let start: number | null = null;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / DRAW_MS);
check!.style.strokeDashoffset = (len * (1 - t)).toFixed(2);
if (t < 1) rafId = requestAnimationFrame(frame); else cb();
}
rafId = requestAnimationFrame(frame);
}
function cycle() {
if (stopped) return;
badge!.classList.add(styles["badge--show"]);
draw(() => {
timer = window.setTimeout(() => {
badge!.classList.remove(styles["badge--show"]);
timer = window.setTimeout(() => {
check!.style.strokeDashoffset = String(len);
timer = window.setTimeout(cycle, GAP_MS);
}, reduce ? 0 : FADE_MS);
}, HOLD_MS);
});
}
cycle();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
check!.style.strokeDashoffset = String(len);
badge!.classList.remove(styles["badge--show"]);
rafId = requestAnimationFrame(cycle);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ssc} ${styles["is-demo"]}`}>
<div className={styles.badge}>
<svg className={styles.ring} viewBox="0 0 40 40" width="40" height="40">
<circle className={styles.circle} cx="20" cy="20" r="17" />
<polyline className={styles.check} points="12,21 17,26 28,13" />
</svg>
</div>
<span className={styles.label}>저장 완료</span>
<button className={styles.btn} type="button">변경 저장</button>
</div>
);
}02 · 저장 완료 값 반짝 — 저장이 끝나는 순간 완료 값이 1로 바뀌었다가 일정 시간 뒤 다시 0으로 사라지는 방식 — 측정 누적면적 0.64% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03저장 실패 재시도 카운트 — 저장이 실패하면 남은 재시도 횟수 값이 하나씩 줄며 문구가 흔들리는 방식
다시 저장 버튼을 클릭할 때마다 fail() 이 실행되어 남은 재시도 값(MAX_RETRY 3에서 시작)이 하나씩 줄고, 그 순간 shake() 가 감쇠 사인 곡선(AMP 5 × sin(t·2π·3) × (1−t))으로 SHAKE_MS 260ms 동안 카드를 짧게 흔듭니다. 다음 실패까지 대기 시간은 BACKOFF_BASE_MS 260 × 2^순번 으로 두 배씩 늘어나다 재시도가 0이 되면 재시도 소진 문구로 바뀝니다.
function shake(cb) {
if (reduce) { cb(); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / SHAKE_MS);
var x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
card.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (t < 1) requestAnimationFrame(frame); else { card.style.transform = 'translateX(0)'; cb(); }
}
requestAnimationFrame(frame);
}

<main class="stage">
<div class="ser is-demo" id="ser">
<div class="ser__card">
<span class="ser__label" id="serLabel">저장 실패</span>
<span class="ser__count" id="serCount">남은 재시도 3</span>
</div>
<button class="ser__btn" id="serBtn" type="button">다시 저장</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 흔들림 = 감쇠 사인(sin(t·2π·3)×(1−t)), 대기 = BACKOFF_BASE_MS×2^(실패 순번) — index.html 참조
@include stage($stage-orange);
.ser {
display: grid;
justify-items: center;
gap: $sp-4;
}
.ser__card {
display: grid;
justify-items: center;
gap: $sp-1;
padding: $sp-4 $sp-6;
border-radius: $r-card;
background: $subject-cream;
min-width: 160px;
}
.ser__label {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $error;
}
.ser--out .ser__label { color: $stage-ink; }
.ser__count {
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
font-variant-numeric: tabular-nums;
}
.ser--out .ser__count { color: $error; }
.ser__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.ser__card { padding: $sp-3 $sp-5; min-width: 140px; }
}
@media (prefers-reduced-motion: reduce) {
.ser__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ser {
display: grid;
justify-items: center;
gap: 16px;
}
.ser__card {
display: grid;
justify-items: center;
gap: 4px;
padding: 16px 24px;
border-radius: 16px;
background: #fff7e6;
min-width: 160px;
}
.ser__label {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #c2361a;
}
.ser--out .ser__label {
color: #17141a;
}
.ser__count {
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
font-variant-numeric: tabular-nums;
}
.ser--out .ser__count {
color: #c2361a;
}
.ser__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ser__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.ser__card {
padding: 12px 20px;
min-width: 140px;
}
}
@media (prefers-reduced-motion: reduce) {
.ser__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ser');
var btn = document.getElementById('serBtn');
var card = root.querySelector('.ser__card');
var label = document.getElementById('serLabel');
var countEl = document.getElementById('serCount');
// 상수: 최대 재시도 횟수 · 흔들림 진폭 · 흔들림 지속시간 · 재시도 사이 대기 배증 기준(가속 ms)
var MAX_RETRY = 3;
var AMP = 5;
var SHAKE_MS = 260;
var BACKOFF_BASE_MS = 260;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var retries = MAX_RETRY;
var timer = null;
// 계산 겹: 흔들림 = 감쇠 사인(sin(t·2π·3)×(1−t)), 대기 = BACKOFF_BASE_MS×2^(실패 순번)
function shake(cb) {
if (reduce) { cb(); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / SHAKE_MS);
var x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
card.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (t < 1) requestAnimationFrame(frame); else { card.style.transform = 'translateX(0)'; cb(); }
}
requestAnimationFrame(frame);
}
function fail() {
retries -= 1;
label.textContent = '저장 실패';
root.classList.add('ser--fail');
shake(function () {
if (retries > 0) {
countEl.textContent = '남은 재시도 ' + retries;
var wait = BACKOFF_BASE_MS * Math.pow(2, MAX_RETRY - retries - 1);
timer = setTimeout(fail, wait);
} else {
countEl.textContent = '재시도 소진';
root.classList.add('ser--out');
timer = setTimeout(reset, 900);
}
});
}
function reset() {
retries = MAX_RETRY;
label.textContent = '저장 실패';
countEl.textContent = '남은 재시도 ' + retries;
root.classList.remove('ser--out');
root.classList.remove('ser--fail');
timer = setTimeout(fail, BACKOFF_BASE_MS);
}
timer = setTimeout(fail, BACKOFF_BASE_MS);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
reset();
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusErrorRetry.module.scss";
type Box<T> = { current: T };
export default function SaveStatusErrorRetry() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const card = root.querySelector(`.${styles.card}`) as HTMLElement | null;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!card || !label || !countEl || !btn) return;
// 상수: 최대 재시도 횟수 · 흔들림 진폭 · 흔들림 지속시간 · 재시도 사이 대기 배증 기준(가속 ms)
const MAX_RETRY = 3;
const AMP = 5;
const SHAKE_MS = 260;
const BACKOFF_BASE_MS = 260;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let retries = MAX_RETRY;
let timer = 0;
let rafId = 0;
let stopped = false;
// 계산 겹: 흔들림 = 감쇠 사인(sin(t·2π·3)×(1−t)), 대기 = BACKOFF_BASE_MS×2^(실패 순번)
function shake(cb: () => void) {
if (reduce) { cb(); return; }
let start: number | null = null;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / SHAKE_MS);
const x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
card!.style.transform = "translateX(" + x.toFixed(2) + "px)";
if (t < 1) rafId = requestAnimationFrame(frame); else { card!.style.transform = "translateX(0)"; cb(); }
}
rafId = requestAnimationFrame(frame);
}
function fail() {
if (stopped) return;
retries -= 1;
label!.textContent = "저장 실패";
root!.classList.add(styles["ser--fail"]);
shake(() => {
if (retries > 0) {
countEl!.textContent = "남은 재시도 " + retries;
const wait = BACKOFF_BASE_MS * Math.pow(2, MAX_RETRY - retries - 1);
timer = window.setTimeout(fail, wait);
} else {
countEl!.textContent = "재시도 소진";
root!.classList.add(styles["ser--out"]);
timer = window.setTimeout(reset, 900);
}
});
}
function reset() {
retries = MAX_RETRY;
label!.textContent = "저장 실패";
countEl!.textContent = "남은 재시도 " + retries;
root!.classList.remove(styles["ser--out"]);
root!.classList.remove(styles["ser--fail"]);
timer = window.setTimeout(fail, BACKOFF_BASE_MS);
}
timer = window.setTimeout(fail, BACKOFF_BASE_MS);
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
reset();
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ser} ${styles["is-demo"]}`}>
<div className={styles.card}>
<span className={styles.label}>저장 실패</span>
<span className={styles.count}>남은 재시도 3</span>
</div>
<button className={styles.btn} type="button">다시 저장</button>
</div>
);
}03 · 저장 실패 재시도 카운트 — 저장이 실패하면 남은 재시도 횟수 값이 하나씩 줄며 문구가 흔들리는 방식 — 측정 누적면적 1.646% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04오프라인 대기 건수 — 연결이 끊긴 동안 쌓인 대기 저장 건수 값이 연결이 돌아오는 순간 0으로 줄어드는 방식
연결 전환 버튼을 클릭해 오프라인으로 바꾸면 ADD_MS 260ms 마다 대기 건수 값이 하나씩 늘어 막대 채움이 count ÷ MAX_QUEUE 비율로 커지고, MAX_QUEUE 6에 닿으면 스스로 온라인으로 바뀌어 DRAIN_MS 150ms 마다 그 값이 다시 0까지 줄어듭니다. 연결·해제는 실제 navigator.onLine 이 아니라 온라인 여부를 담은 불리언 변수 하나로만 흉내 낸 값입니다.
function tick() {
if (!online) {
count = Math.min(MAX_QUEUE, count + 1);
paint();
if (count >= MAX_QUEUE) { online = true; paint(); timer = setTimeout(tick, DRAIN_MS); return; }
timer = setTimeout(tick, ADD_MS);
} else {
count = Math.max(0, count - 1);
paint();
if (count <= 0) { timer = setTimeout(function () { online = false; paint(); timer = setTimeout(tick, ADD_MS); }, 500); return; }
timer = setTimeout(tick, DRAIN_MS);
}
}

<main class="stage">
<div class="soq is-demo" id="soq">
<div class="soq__row">
<span class="soq__label" id="soqLabel">오프라인</span>
<span class="soq__count" id="soqCount">0건 대기</span>
</div>
<div class="soq__track"><div class="soq__fill" id="soqFill"></div></div>
<button class="soq__btn" id="soqBtn" type="button">연결 전환</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 막대 채움 값 = 대기 건수 ÷ 최대 건수(scaleX 산술) — index.html 참조
@include stage($stage-ink);
.soq {
width: 100%;
max-width: 260px;
display: grid;
justify-items: center;
gap: $sp-3;
}
.soq__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.soq__count { font-variant-numeric: tabular-nums; color: rgba($subject-cream, .8); }
.soq--online .soq__label { color: $subject-mint; }
.soq__track {
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
overflow: hidden;
}
.soq__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-blue-light;
transition: transform $dur-quick $ease-out, background $dur-base $ease-out;
}
.soq--online .soq__fill { background: $subject-mint; }
.soq__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-cream;
color: $stage-ink;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.soq { max-width: 200px; }
}
@media (prefers-reduced-motion: reduce) {
.soq__fill { transition: none; }
.soq__btn { transition: none; }
}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);
}
.soq {
width: 100%;
max-width: 260px;
display: grid;
justify-items: center;
gap: 12px;
}
.soq__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.soq__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 247, 230, 0.8);
}
.soq--online .soq__label {
color: #4cd4a6;
}
.soq__track {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
overflow: hidden;
}
.soq__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #5b8cf8;
transition: transform 220ms ease-out, background 300ms ease-out;
}
.soq--online .soq__fill {
background: #4cd4a6;
}
.soq__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #fff7e6;
color: #17141a;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.soq__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.soq {
max-width: 200px;
}
}
@media (prefers-reduced-motion: reduce) {
.soq__fill {
transition: none;
}
.soq__btn {
transition: none;
}
}(function () {
var root = document.getElementById('soq');
var btn = document.getElementById('soqBtn');
var label = document.getElementById('soqLabel');
var countEl = document.getElementById('soqCount');
var fill = document.getElementById('soqFill');
// 상수: 대기 칸 최대 값 · 쌓이는 간격(가속 ms) · 줄어드는 간격(가속 ms)
var MAX_QUEUE = 6;
var ADD_MS = 260;
var DRAIN_MS = 150;
var online = false;
var count = 0;
var timer = null;
// 계산 겹: 막대 채움 값 = 대기 건수 ÷ 최대 건수 — scaleX 산술
function paint() {
countEl.textContent = count + '건 대기';
fill.style.transform = 'scaleX(' + (count / MAX_QUEUE).toFixed(3) + ')';
label.textContent = online ? '온라인' : '오프라인';
root.classList.toggle('soq--online', online);
}
function tick() {
if (!online) {
count = Math.min(MAX_QUEUE, count + 1);
paint();
if (count >= MAX_QUEUE) { online = true; paint(); timer = setTimeout(tick, DRAIN_MS); return; }
timer = setTimeout(tick, ADD_MS);
} else {
count = Math.max(0, count - 1);
paint();
if (count <= 0) { timer = setTimeout(function () { online = false; paint(); timer = setTimeout(tick, ADD_MS); }, 500); return; }
timer = setTimeout(tick, DRAIN_MS);
}
}
timer = setTimeout(tick, ADD_MS);
paint();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
online = !online;
paint();
timer = setTimeout(tick, online ? DRAIN_MS : ADD_MS);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusOfflineQueue.module.scss";
type Box<T> = { current: T };
export default function SaveStatusOfflineQueue() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!label || !countEl || !fill || !btn) return;
// 상수: 대기 칸 최대 값 · 쌓이는 간격(가속 ms) · 줄어드는 간격(가속 ms)
const MAX_QUEUE = 6;
const ADD_MS = 260;
const DRAIN_MS = 150;
let online = false;
let count = 0;
let timer = 0;
let stopped = false;
// 계산 겹: 막대 채움 값 = 대기 건수 ÷ 최대 건수 — scaleX 산술
function paint() {
countEl!.textContent = count + "건 대기";
fill!.style.transform = "scaleX(" + (count / MAX_QUEUE).toFixed(3) + ")";
label!.textContent = online ? "온라인" : "오프라인";
root!.classList.toggle(styles["soq--online"], online);
}
function tick() {
if (stopped) return;
if (!online) {
count = Math.min(MAX_QUEUE, count + 1);
paint();
if (count >= MAX_QUEUE) { online = true; paint(); timer = window.setTimeout(tick, DRAIN_MS); return; }
timer = window.setTimeout(tick, ADD_MS);
} else {
count = Math.max(0, count - 1);
paint();
if (count <= 0) {
timer = window.setTimeout(() => { online = false; paint(); timer = window.setTimeout(tick, ADD_MS); }, 500);
return;
}
timer = window.setTimeout(tick, DRAIN_MS);
}
}
timer = window.setTimeout(tick, ADD_MS);
paint();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
online = !online;
paint();
timer = window.setTimeout(tick, online ? DRAIN_MS : ADD_MS);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.soq} ${styles["is-demo"]}`}>
<div className={styles.row}>
<span className={styles.label}>오프라인</span>
<span className={styles.count}>0건 대기</span>
</div>
<div className={styles.track}><div className={styles.fill} /></div>
<button className={styles.btn} type="button">연결 전환</button>
</div>
);
}04 · 오프라인 대기 건수 — 연결이 끊긴 동안 쌓인 대기 저장 건수 값이 연결이 돌아오는 순간 0으로 줄어드는 방식 — 측정 누적면적 1.761% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05마지막 저장 시각 갱신 — 마지막으로 저장된 뒤 지난 시간 값이 초마다 갱신되며 문구로 바뀌는 방식
지금 저장 버튼을 클릭한 시각을 기준으로 raf() 가 매 프레임 (지금−저장시각)÷DEMO_SEC_MS 90 으로 경과 초를 다시 계산해, MIN_THRESHOLD_SEC 60초 미만이면 몇 초 전, 넘으면 몇 분 전 문구로 값을 바꿉니다. 점의 밝기는 1 − min(1, 경과÷RESET_AFTER_SEC 150) 신선도 값으로 서서히 낮아지다가 150초에 닿으면 저장 시각이 다시 지금으로 리셋됩니다.
function raf(ts) {
var elapsedSec = Math.floor((ts - savedAt) / DEMO_SEC_MS);
if (elapsedSec >= RESET_AFTER_SEC) { savedAt = ts; elapsedSec = 0; }
var phrase;
if (elapsedSec < 5) phrase = '방금 저장됨';
else if (elapsedSec < MIN_THRESHOLD_SEC) phrase = elapsedSec + '초 전 저장됨';
else phrase = Math.floor(elapsedSec / 60) + '분 전 저장됨';
label.textContent = phrase;
var fresh = 1 - Math.min(1, elapsedSec / RESET_AFTER_SEC);
dot.style.opacity = reduce ? '1' : (0.35 + 0.65 * fresh).toFixed(3);
requestAnimationFrame(raf);
}

<main class="stage">
<div class="str is-demo" id="str">
<span class="str__dot" id="strDot"></span>
<span class="str__label" id="strLabel">방금 저장됨</span>
<button class="str__btn" id="strBtn" type="button">지금 저장</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 경과 초 = (지금−저장시각)÷가속ms, 초·분 환산 문구 + 신선도 투명도 = 1−min(1,경과÷기준) — index.html 참조
@include stage($stage-yellow);
.str {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-5;
border-radius: $r-pill;
background: $stage-ink;
}
.str__dot {
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: $subject-mint;
flex: 0 0 auto;
}
.str__label {
font: 600 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
min-width: 128px;
}
.str__btn {
margin-left: $sp-2;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-3;
background: $subject-cream;
color: $stage-ink;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.str { padding: $sp-2 $sp-4; flex-wrap: wrap; justify-content: center; }
.str__label { font-size: 12px; min-width: 96px; }
}
@media (prefers-reduced-motion: reduce) {
.str__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.str {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-radius: 999px;
background: #17141a;
}
.str__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #4cd4a6;
flex: 0 0 auto;
}
.str__label {
font: 600 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
min-width: 128px;
}
.str__btn {
margin-left: 8px;
border: 0;
border-radius: 12px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.str__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.str {
padding: 8px 16px;
flex-wrap: wrap;
justify-content: center;
}
.str__label {
font-size: 12px;
min-width: 96px;
}
}
@media (prefers-reduced-motion: reduce) {
.str__btn {
transition: none;
}
}(function () {
var root = document.getElementById('str');
var btn = document.getElementById('strBtn');
var dot = document.getElementById('strDot');
var label = document.getElementById('strLabel');
// 상수: 데모 안에서 "1초"를 표현하는 실제 ms(가속) · 분으로 넘어가는 기준 초 · 리셋되는 기준 초
var DEMO_SEC_MS = 90;
var MIN_THRESHOLD_SEC = 60;
var RESET_AFTER_SEC = 150;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var savedAt = performance.now();
// 계산 겹: 경과 초 = (지금 − 저장시각)÷가속ms, 초·분 환산으로 문구를 만들고 신선도(투명도)는 1 − min(1, 경과÷기준)
function raf(ts) {
var elapsedSec = Math.floor((ts - savedAt) / DEMO_SEC_MS);
if (elapsedSec >= RESET_AFTER_SEC) { savedAt = ts; elapsedSec = 0; }
var phrase;
if (elapsedSec < 5) phrase = '방금 저장됨';
else if (elapsedSec < MIN_THRESHOLD_SEC) phrase = elapsedSec + '초 전 저장됨';
else phrase = Math.floor(elapsedSec / 60) + '분 전 저장됨';
label.textContent = phrase;
var fresh = 1 - Math.min(1, elapsedSec / RESET_AFTER_SEC);
dot.style.opacity = reduce ? '1' : (0.35 + 0.65 * fresh).toFixed(3);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
savedAt = performance.now();
label.textContent = '방금 저장됨';
dot.style.opacity = '1';
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusTimestampRelative.module.scss";
type Box<T> = { current: T };
export default function SaveStatusTimestampRelative() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const dot = root.querySelector(`.${styles.dot}`) as HTMLElement | null;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!dot || !label || !btn) return;
// 상수: 데모 안에서 "1초"를 표현하는 실제 ms(가속) · 분으로 넘어가는 기준 초 · 리셋되는 기준 초
const DEMO_SEC_MS = 90;
const MIN_THRESHOLD_SEC = 60;
const RESET_AFTER_SEC = 150;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let savedAt = performance.now();
let rafId = 0;
// 계산 겹: 경과 초 = (지금 − 저장시각)÷가속ms, 초·분 환산으로 문구를 만들고 신선도(투명도)는 1 − min(1, 경과÷기준)
function raf(ts: number) {
let elapsedSec = Math.floor((ts - savedAt) / DEMO_SEC_MS);
if (elapsedSec >= RESET_AFTER_SEC) { savedAt = ts; elapsedSec = 0; }
let phrase: string;
if (elapsedSec < 5) phrase = "방금 저장됨";
else if (elapsedSec < MIN_THRESHOLD_SEC) phrase = elapsedSec + "초 전 저장됨";
else phrase = Math.floor(elapsedSec / 60) + "분 전 저장됨";
label!.textContent = phrase;
const fresh = 1 - Math.min(1, elapsedSec / RESET_AFTER_SEC);
dot!.style.opacity = reduce ? "1" : (0.35 + 0.65 * fresh).toFixed(3);
rafId = requestAnimationFrame(raf);
}
rafId = requestAnimationFrame(raf);
function onClick() {
root!.classList.remove(styles["is-demo"]);
savedAt = performance.now();
label!.textContent = "방금 저장됨";
dot!.style.opacity = "1";
}
btn.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.str} ${styles["is-demo"]}`}>
<span className={styles.dot} />
<span className={styles.label}>방금 저장됨</span>
<button className={styles.btn} type="button">지금 저장</button>
</div>
);
}05 · 마지막 저장 시각 갱신 — 마지막으로 저장된 뒤 지난 시간 값이 초마다 갱신되며 문구로 바뀌는 방식 — 측정 누적면적 0.822% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06상태 점 색 순환 — 대기·진행·완료 세 단계에 따라 점의 색상 값이 차례로 바뀌는 방식
다음 단계 버튼을 클릭하거나 STEP_MS 480ms 마다 stageIndex 값이 STAGE_COUNT 3으로 나눈 나머지로 넘어가고, 점 i번째의 표시 단계는 (stageIndex − i + 3) % 3 모듈로 계산으로 정해져 점마다 한 박자씩 늦게 따라갑니다. 계산된 단계 값은 CLASSES 배열의 대기·진행·완료 클래스 이름으로 바로 매핑됩니다.
function paint() {
label.textContent = NAMES[stageIndex];
dots.forEach(function (d, i) {
var s = ((stageIndex - i) % STAGE_COUNT + STAGE_COUNT) % STAGE_COUNT;
d.className = 'sdc__dot ' + CLASSES[s];
});
}
paint();
function step() {
stageIndex = (stageIndex + 1) % STAGE_COUNT;
paint();
timer = setTimeout(step, STEP_MS);
}
timer = setTimeout(step, STEP_MS);

<main class="stage">
<div class="sdc is-demo" id="sdc">
<div class="sdc__dots">
<span class="sdc__dot" id="sdcDot0"></span>
<span class="sdc__dot" id="sdcDot1"></span>
<span class="sdc__dot" id="sdcDot2"></span>
</div>
<span class="sdc__label" id="sdcLabel">대기</span>
<button class="sdc__btn" id="sdcBtn" type="button">다음 단계</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: i번째 점의 단계 = (현재단계 − i + 단계수) mod 단계수 — 값이 점마다 어긋나며 뒤따라간다(index.html 참조)
@include stage($stage-paper);
.sdc {
display: grid;
justify-items: center;
gap: $sp-4;
}
.sdc__dots {
display: flex;
gap: $sp-3;
}
.sdc__dot {
width: $sp-4;
height: $sp-4;
border-radius: $r-pill;
background: rgba($stage-ink, .14);
transition: background $dur-base $ease-out, transform $dur-base $ease-pop;
}
.sdc__dot--idle { background: rgba($stage-ink, .14); transform: scale(1); }
.sdc__dot--busy { background: $subject-blue-deep; transform: scale(1.18); }
.sdc__dot--done { background: $subject-mint; transform: scale(1); }
.sdc__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
.sdc__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.sdc__dot { width: $sp-3; height: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sdc__dot { transition: none; }
.sdc__btn { 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);
}
.sdc {
display: grid;
justify-items: center;
gap: 16px;
}
.sdc__dots {
display: flex;
gap: 12px;
}
.sdc__dot {
width: 16px;
height: 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
transition: background 300ms ease-out, transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sdc__dot--idle {
background: rgba(23, 20, 26, 0.14);
transform: scale(1);
}
.sdc__dot--busy {
background: #0a4fe8;
transform: scale(1.18);
}
.sdc__dot--done {
background: #4cd4a6;
transform: scale(1);
}
.sdc__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
.sdc__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sdc__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.sdc__dot {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sdc__dot {
transition: none;
}
.sdc__btn {
transition: none;
}
}(function () {
var root = document.getElementById('sdc');
var btn = document.getElementById('sdcBtn');
var dots = [document.getElementById('sdcDot0'), document.getElementById('sdcDot1'), document.getElementById('sdcDot2')];
var label = document.getElementById('sdcLabel');
// 상수: 단계 수(대기·진행·완료) · 단계가 넘어가는 간격(가속 ms)
var STAGE_COUNT = 3;
var STEP_MS = 480;
var NAMES = ['대기', '진행', '완료'];
var CLASSES = ['sdc__dot--idle', 'sdc__dot--busy', 'sdc__dot--done'];
var stageIndex = 0;
var timer = null;
// 계산 겹: i번째 점의 표시 단계 = (현재 단계 − i + 단계수) mod 단계수 — 값이 점마다 어긋나며 뒤따라간다
function paint() {
label.textContent = NAMES[stageIndex];
dots.forEach(function (d, i) {
var s = ((stageIndex - i) % STAGE_COUNT + STAGE_COUNT) % STAGE_COUNT;
d.className = 'sdc__dot ' + CLASSES[s];
});
}
paint();
function step() {
stageIndex = (stageIndex + 1) % STAGE_COUNT;
paint();
timer = setTimeout(step, STEP_MS);
}
timer = setTimeout(step, STEP_MS);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
stageIndex = (stageIndex + 1) % STAGE_COUNT;
paint();
timer = setTimeout(step, STEP_MS);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusDotCycle.module.scss";
type Box<T> = { current: T };
export default function SaveStatusDotCycle() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const dotEls = Array.from(root.querySelectorAll(`.${styles.dot}`)) as HTMLElement[];
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (dotEls.length < 3 || !label || !btn) return;
// 상수: 단계 수(대기·진행·완료) · 단계가 넘어가는 간격(가속 ms)
const STAGE_COUNT = 3;
const STEP_MS = 480;
const NAMES = ["대기", "진행", "완료"];
const CLASSES = [styles["dot--idle"], styles["dot--busy"], styles["dot--done"]];
let stageIndex = 0;
let timer = 0;
// 계산 겹: i번째 점의 표시 단계 = (현재 단계 − i + 단계수) mod 단계수 — 값이 점마다 어긋나며 뒤따라간다
function paint() {
label!.textContent = NAMES[stageIndex];
dotEls.forEach((d, i) => {
const s = ((stageIndex - i) % STAGE_COUNT + STAGE_COUNT) % STAGE_COUNT;
d.className = styles.dot + " " + CLASSES[s];
});
}
paint();
function step() {
stageIndex = (stageIndex + 1) % STAGE_COUNT;
paint();
timer = window.setTimeout(step, STEP_MS);
}
timer = window.setTimeout(step, STEP_MS);
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
stageIndex = (stageIndex + 1) % STAGE_COUNT;
paint();
timer = window.setTimeout(step, STEP_MS);
}
btn.addEventListener("click", onClick);
return () => {
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.sdc} ${styles["is-demo"]}`}>
<div className={styles.dots}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
<span className={styles.label}>대기</span>
<button className={styles.btn} type="button">다음 단계</button>
</div>
);
}06 · 상태 점 색 순환 — 대기·진행·완료 세 단계에 따라 점의 색상 값이 차례로 바뀌는 방식 — 측정 누적면적 0.888% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07저장 충돌 안내 — 같은 항목이 다른 곳에서 먼저 저장되면 안내 값이 나타나 선택을 기다리는 방식
APPEAR_MS 1400ms 마다 스스로 저장 충돌 배너가 나타나고, 배너 위치 값은 속도 += (−SPRING_K 200×변위 − SPRING_C 19×속도)×dt 스프링-감쇠 물리로 목표 위치까지 적분됩니다. 내 변경 유지·새로고침 두 버튼 중 하나를 클릭하지 않으면 AUTO_RESOLVE_MS 850ms 뒤 스스로 닫히고 다음 충돌을 준비합니다.
function tick(ts, last) {
var dt = Math.min(32, ts - last) / 1000;
var disp = pos - target;
var acc = -SPRING_K * disp - SPRING_C * vel;
vel += acc * dt;
pos += vel * dt;
banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
rafId = requestAnimationFrame(function (t) { tick(t, ts); });
} else {
pos = target;
banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
}
}
function spring(to) {
target = to;
if (reduce) { pos = to; vel = 0; banner.style.transform = 'translateY(' + pos + '%)'; return; }
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(function (ts) { tick(ts, ts - 16); });
}

<main class="stage">
<div class="scb is-demo" id="scb">
<div class="scb__banner" id="scbBanner">
<span class="scb__msg">다른 곳에서 먼저 저장되었습니다</span>
<div class="scb__actions">
<button class="scb__choice" id="scbKeep" type="button">내 변경 유지</button>
<button class="scb__choice scb__choice--ghost" id="scbReload" type="button">새로고침</button>
</div>
</div>
<div class="scb__idle">저장됨</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분(강성·감쇠) — index.html 참조
@include stage($stage-orange);
.scb {
position: relative;
width: 100%;
max-width: 260px;
min-height: 160px;
display: grid;
align-content: end;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
}
.scb__banner {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: translateY(-60%);
display: grid;
gap: $sp-2;
background: $error;
color: $subject-cream;
padding: $sp-3 $sp-4;
text-align: center;
}
.scb__msg {
font: 700 12px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.scb__actions {
display: flex;
gap: $sp-2;
justify-content: center;
}
.scb__choice {
border: 0;
border-radius: $r-sm;
padding: $sp-1 $sp-3;
background: $subject-cream;
color: $error;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.scb__choice--ghost {
background: transparent;
color: $subject-cream;
border: 1px solid rgba($subject-cream, .6);
}
.scb__idle {
padding: $sp-6 $sp-4 $sp-5;
text-align: center;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
@media (max-width: 340px) {
.scb { max-width: 200px; min-height: 140px; }
.scb__msg { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.scb__choice { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scb {
position: relative;
width: 100%;
max-width: 260px;
min-height: 160px;
display: grid;
align-content: end;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
}
.scb__banner {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: translateY(-60%);
display: grid;
gap: 8px;
background: #c2361a;
color: #fff7e6;
padding: 12px 16px;
text-align: center;
}
.scb__msg {
font: 700 12px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.scb__actions {
display: flex;
gap: 8px;
justify-content: center;
}
.scb__choice {
border: 0;
border-radius: 8px;
padding: 4px 12px;
background: #fff7e6;
color: #c2361a;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.scb__choice--ghost {
background: transparent;
color: #fff7e6;
border: 1px solid rgba(255, 247, 230, 0.6);
}
.scb__idle {
padding: 24px 16px 20px;
text-align: center;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
@media (max-width: 340px) {
.scb {
max-width: 200px;
min-height: 140px;
}
.scb__msg {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.scb__choice {
transition: none;
}
}(function () {
var root = document.getElementById('scb');
var banner = document.getElementById('scbBanner');
var keepBtn = document.getElementById('scbKeep');
var reloadBtn = document.getElementById('scbReload');
// 상수: 스프링 강성·감쇠(질량 1) · 저절로 충돌이 나타나는 간격 · 선택 없을 때 자동 해소까지 대기
var SPRING_K = 200;
var SPRING_C = 19;
var APPEAR_MS = 1400;
var AUTO_RESOLVE_MS = 850;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pos = -60;
var vel = 0;
var target = -60;
var rafId = null;
var timer = null;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분 — 속도 += (−K×변위 − C×속도)×dt, 위치 += 속도×dt
function tick(ts, last) {
var dt = Math.min(32, ts - last) / 1000;
var disp = pos - target;
var acc = -SPRING_K * disp - SPRING_C * vel;
vel += acc * dt;
pos += vel * dt;
banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
rafId = requestAnimationFrame(function (t) { tick(t, ts); });
} else {
pos = target;
banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
}
}
function spring(to) {
target = to;
if (reduce) { pos = to; vel = 0; banner.style.transform = 'translateY(' + pos + '%)'; return; }
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(function (ts) { tick(ts, ts - 16); });
}
function showConflict() {
root.classList.add('scb--conflict');
spring(0);
timer = setTimeout(resolve, AUTO_RESOLVE_MS);
}
function resolve() {
root.classList.remove('scb--conflict');
spring(-60);
timer = setTimeout(showConflict, APPEAR_MS);
}
timer = setTimeout(showConflict, APPEAR_MS);
function choose() {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
resolve();
}
keepBtn.addEventListener('click', choose);
reloadBtn.addEventListener('click', choose);
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusConflictBanner.module.scss";
type Box<T> = { current: T };
export default function SaveStatusConflictBanner() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const banner = root.querySelector(`.${styles.banner}`) as HTMLElement | null;
const keepBtn = root.querySelector(`.${styles.choice}`) as HTMLElement | null;
const choiceEls = Array.from(root.querySelectorAll(`.${styles.choice}`)) as HTMLElement[];
const reloadBtn = choiceEls[1] as HTMLElement | undefined;
if (!banner || !keepBtn || !reloadBtn) return;
// 상수: 스프링 강성·감쇠(질량 1) · 저절로 충돌이 나타나는 간격 · 선택 없을 때 자동 해소까지 대기
const SPRING_K = 200;
const SPRING_C = 19;
const APPEAR_MS = 1400;
const AUTO_RESOLVE_MS = 850;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let pos = -60;
let vel = 0;
let target = -60;
let rafId = 0;
let timer = 0;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분 — 속도 += (−K×변위 − C×속도)×dt, 위치 += 속도×dt
function tick(ts: number, last: number) {
const dt = Math.min(32, ts - last) / 1000;
const disp = pos - target;
const acc = -SPRING_K * disp - SPRING_C * vel;
vel += acc * dt;
pos += vel * dt;
banner!.style.transform = "translateY(" + pos.toFixed(2) + "%)";
if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
rafId = requestAnimationFrame((t) => tick(t, ts));
} else {
pos = target;
banner!.style.transform = "translateY(" + pos.toFixed(2) + "%)";
}
}
function spring(to: number) {
target = to;
if (reduce) { pos = to; vel = 0; banner!.style.transform = "translateY(" + pos + "%)"; return; }
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame((ts) => tick(ts, ts - 16));
}
function showConflict() {
root!.classList.add(styles["scb--conflict"]);
spring(0);
timer = window.setTimeout(resolve, AUTO_RESOLVE_MS);
}
function resolve() {
root!.classList.remove(styles["scb--conflict"]);
spring(-60);
timer = window.setTimeout(showConflict, APPEAR_MS);
}
timer = window.setTimeout(showConflict, APPEAR_MS);
function choose() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
resolve();
}
keepBtn.addEventListener("click", choose);
reloadBtn.addEventListener("click", choose);
return () => {
window.clearTimeout(timer);
cancelAnimationFrame(rafId);
keepBtn.removeEventListener("click", choose);
reloadBtn.removeEventListener("click", choose);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.scb} ${styles["is-demo"]}`}>
<div className={styles.banner}>
<span className={styles.msg}>다른 곳에서 먼저 저장되었습니다</span>
<div className={styles.actions}>
<button className={styles.choice} type="button">내 변경 유지</button>
<button className={`${styles.choice} ${styles["choice--ghost"]}`} type="button">새로고침</button>
</div>
</div>
<div className={styles.idle}>저장됨</div>
</div>
);
}07 · 저장 충돌 안내 — 같은 항목이 다른 곳에서 먼저 저장되면 안내 값이 나타나 선택을 기다리는 방식 — 측정 누적면적 11.108% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08다음 자동저장 카운트다운 — 다음 저장까지 남은 시간 값이 0을 향해 줄어드는 링 방식
지금 저장 버튼을 클릭하기 전까지 remaining 값이 CYCLE_SEC 10초에서 DEMO_TICK_MS 130ms 간격으로 하나씩 줄고, 링의 stroke-dashoffset 값은 둘레(2πR, R 40) × (1 − remaining÷CYCLE_SEC) 비율로 함께 줄어듭니다. 0에 닿으면 저장 중 상태로 바뀐 뒤 500ms 뒤 CYCLE_SEC 값으로 다시 시작합니다.
function paint() {
numEl.textContent = String(remaining);
var offset = CIRC * (1 - remaining / CYCLE_SEC);
prog.style.strokeDashoffset = offset.toFixed(2);
root.classList.toggle('sac--saving', remaining <= 0);
}
function start() {
remaining = CYCLE_SEC;
root.classList.remove('sac--saving');
paint();
if (timerId) clearInterval(timerId);
timerId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
remaining = 0;
paint();
clearInterval(timerId);
timerId = setTimeout(start, 500);
return;
}
paint();
}, DEMO_TICK_MS);
}
start();

<main class="stage">
<div class="sac is-demo" id="sac">
<div class="sac__ringwrap">
<svg class="sac__ring" viewBox="0 0 100 100" width="120" height="120">
<circle class="sac__track" cx="50" cy="50" r="40"></circle>
<circle class="sac__prog" id="sacProg" cx="50" cy="50" r="40"></circle>
</svg>
<div class="sac__mid">
<span class="sac__num" id="sacNum">10</span>
<span class="sac__unit">초 후 저장</span>
</div>
</div>
<button class="sac__btn" id="sacBtn" type="button">지금 저장</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: stroke-dashoffset = 둘레×(1 − 남은초/전체초) — index.html 참조
@include stage($stage-paper);
.sac {
display: grid;
justify-items: center;
gap: $sp-4;
}
.sac__ringwrap {
position: relative;
display: grid;
place-items: center;
}
.sac__ring { transform: rotate(-90deg); }
.sac__track {
fill: none;
stroke: rgba($stage-ink, .12);
stroke-width: 8;
}
.sac__prog {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 8;
stroke-linecap: round;
transition: stroke $dur-base $ease-out;
}
.sac--saving .sac__prog { stroke: $subject-mint; }
.sac__mid {
position: absolute;
display: grid;
justify-items: center;
}
.sac__num {
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
.sac__unit {
font: 500 10px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .6);
}
.sac__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.sac__ring { width: 92px; height: 92px; }
.sac__num { font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.sac__prog { transition: none; }
.sac__btn { 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);
}
.sac {
display: grid;
justify-items: center;
gap: 16px;
}
.sac__ringwrap {
position: relative;
display: grid;
place-items: center;
}
.sac__ring {
transform: rotate(-90deg);
}
.sac__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 8;
}
.sac__prog {
fill: none;
stroke: #0a4fe8;
stroke-width: 8;
stroke-linecap: round;
transition: stroke 300ms ease-out;
}
.sac--saving .sac__prog {
stroke: #4cd4a6;
}
.sac__mid {
position: absolute;
display: grid;
justify-items: center;
}
.sac__num {
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.sac__unit {
font: 500 10px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.6);
}
.sac__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sac__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.sac__ring {
width: 92px;
height: 92px;
}
.sac__num {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.sac__prog {
transition: none;
}
.sac__btn {
transition: none;
}
}(function () {
var root = document.getElementById('sac');
var btn = document.getElementById('sacBtn');
var numEl = document.getElementById('sacNum');
var prog = document.getElementById('sacProg');
// 상수: 다음 저장까지 초 · 링 반지름(SVG 좌표) · 데모 안 "1초"의 실제 ms(가속)
var CYCLE_SEC = 10;
var R = 40;
var DEMO_TICK_MS = 130;
var CIRC = 2 * Math.PI * R;
prog.style.strokeDasharray = CIRC.toFixed(2);
var remaining = CYCLE_SEC;
var timerId = null;
// 계산 겹: 링 오프셋 = 둘레 × (1 − 남은초/전체초) — stroke-dashoffset 산술
function paint() {
numEl.textContent = String(remaining);
var offset = CIRC * (1 - remaining / CYCLE_SEC);
prog.style.strokeDashoffset = offset.toFixed(2);
root.classList.toggle('sac--saving', remaining <= 0);
}
function start() {
remaining = CYCLE_SEC;
root.classList.remove('sac--saving');
paint();
if (timerId) clearInterval(timerId);
timerId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
remaining = 0;
paint();
clearInterval(timerId);
timerId = setTimeout(start, 500);
return;
}
paint();
}, DEMO_TICK_MS);
}
start();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timerId) { clearInterval(timerId); clearTimeout(timerId); }
remaining = 0;
paint();
timerId = setTimeout(start, 500);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusAutosaveCountdown.module.scss";
type Box<T> = { current: T };
export default function SaveStatusAutosaveCountdown() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const numEl = root.querySelector(`.${styles.num}`) as HTMLElement | null;
const prog = root.querySelector(`.${styles.prog}`) as SVGCircleElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!numEl || !prog || !btn) return;
// 상수: 다음 저장까지 초 · 링 반지름(SVG 좌표) · 데모 안 "1초"의 실제 ms(가속)
const CYCLE_SEC = 10;
const R = 40;
const DEMO_TICK_MS = 130;
const CIRC = 2 * Math.PI * R;
prog.style.strokeDasharray = CIRC.toFixed(2);
let remaining = CYCLE_SEC;
let timerId = 0;
// 계산 겹: 링 오프셋 = 둘레 × (1 − 남은초/전체초) — stroke-dashoffset 산술
function paint() {
numEl!.textContent = String(remaining);
const offset = CIRC * (1 - remaining / CYCLE_SEC);
prog!.style.strokeDashoffset = offset.toFixed(2);
root!.classList.toggle(styles["sac--saving"], remaining <= 0);
}
function start() {
remaining = CYCLE_SEC;
root!.classList.remove(styles["sac--saving"]);
paint();
window.clearInterval(timerId);
timerId = window.setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
remaining = 0;
paint();
window.clearInterval(timerId);
timerId = window.setTimeout(start, 500);
return;
}
paint();
}, DEMO_TICK_MS);
}
start();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearInterval(timerId);
window.clearTimeout(timerId);
remaining = 0;
paint();
timerId = window.setTimeout(start, 500);
}
btn.addEventListener("click", onClick);
return () => {
window.clearInterval(timerId);
window.clearTimeout(timerId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.sac} ${styles["is-demo"]}`}>
<div className={styles.ringwrap}>
<svg className={styles.ring} viewBox="0 0 100 100" width="120" height="120">
<circle className={styles.track} cx="50" cy="50" r="40" />
<circle className={styles.prog} cx="50" cy="50" r="40" />
</svg>
<div className={styles.mid}>
<span className={styles.num}>10</span>
<span className={styles.unit}>초 후 저장</span>
</div>
</div>
<button className={styles.btn} type="button">지금 저장</button>
</div>
);
}08 · 다음 자동저장 카운트다운 — 다음 저장까지 남은 시간 값이 0을 향해 줄어드는 링 방식 — 측정 누적면적 2.631% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09모션 감소 정적 저장 표시 — 모션 감소 설정에서 애니메이션 대신 문구 값만 그대로 바뀌는 방식
카드를 클릭하면 두 패널의 문구 값이 동시에 저장 중·저장됨으로 바뀌지만, 일반 모션 패널(A)의 배지만 raf() 의 사인 파형으로 SAVING_MS 900ms 동안 밝기 값이 오르내리고 모션 감소 패널(B)의 배지는 opacity 계산 없이 클래스만 즉시 바뀝니다. 방문자의 prefers-reduced-motion 이 reduce 이면 두 패널 모두 requestAnimationFrame 대신 SAVING_MS 간격의 setInterval 로 상태 값을 즉시 스냅해 전환합니다.
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);
}

<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 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 갱신 주기·문구 전환 시점·색상 단계의 기준
갱신 주기 값 — PULSE_PERIOD_MS·DOT_STAGGER_MS·STEP_MS
01·09의 PULSE_PERIOD_MS(각각 900·500)는 밝기 사인 파형이 한 바퀴 도는 시간을 정하고, 01의 DOT_STAGGER_MS 160은 점 세 개가 서로 얼마나 어긋나 밝아지는지를, 06의 STEP_MS 480은 점 색이 다음 단계로 넘어가는 간격을 정합니다. 값이 작을수록 깜빡임·순환이 더 빠르게 느껴집니다.
문구가 바뀌는 시점 값 — SAVING_MS·IDLE_MS·MIN_THRESHOLD_SEC
01·09의 SAVING_MS(2400·900)는 저장 중 문구가 유지되는 시간이고, IDLE_MS(1000·700)는 저장됨 문구가 유지되는 시간입니다. 05의 MIN_THRESHOLD_SEC 60은 몇 초 전에서 몇 분 전으로 문구가 바뀌는 경계이고, 02의 HOLD_MS 380·GAP_MS 260은 체크 표시가 머무는 시간과 다음 반복까지의 간격입니다.
색상·단계·재시도의 기준 값 — STAGE_COUNT·MAX_RETRY·MAX_QUEUE
06의 STAGE_COUNT 3은 점 색이 대기·진행·완료 몇 단계를 도는지를 정하고, 03의 MAX_RETRY 3은 재시도 값이 0에 닿기까지 허용하는 실패 횟수를, 04의 MAX_QUEUE 6은 대기 건수 값이 온라인으로 뒤집히는 문턱을 정합니다. 07의 SPRING_K 200·SPRING_C 19는 배너가 목표 위치로 되돌아가는 세기와 감쇠를, 08의 CYCLE_SEC 10·R 40은 카운트다운 시작 값과 링의 반지름을 정합니다.
어디서 깨지는가 — 오프라인 복귀 순간·다중 탭 동시 저장·모션 감소
아홉 항목 모두 저장 중·재시도·대기 건수 같은 값을 페이지 메모리(JS 변수)에만 담아 두므로, 새로고침 한 번이면 값이 처음으로 되돌아갑니다. 04의 오프라인 전환은 실제 navigator.onLine 이벤트가 아니라 온라인 여부를 담은 불리언 변수 하나를 버튼이 뒤집는 것뿐이고, 저장·전송도 전부 가상 시뮬레이션이라 실제 fetch·XMLHttpRequest·localStorage 호출은 하나도 없습니다 — "서버에 저장된다"거나 "실제 오프라인을 감지한다"는 뜻이 아니라 데모 안에서 상태 값이 순환하는 것뿐입니다. 07의 저장 충돌 안내도 마찬가지로 다른 탭이나 다른 사용자를 실제로 감지하지 않고 APPEAR_MS 마다 스스로 충돌을 지어내는 반복일 뿐이라, 탭 두 개를 열어 동시에 눌러도 두 탭은 서로의 존재를 전혀 모른 채 각자의 변수(remaining·fails·count 등)를 따로 계산합니다. 모션 감소 쪽은 prefers-reduced-motion 이 켜지면 01·02·03·05·07 다섯 항목이 각 index.html의 if (reduce) 분기에서 사인 파형·스프링 적분을 건너뛰고 값을 즉시 최종 상태로 바꾸며, 04·06·08 세 항목은 JS 계산은 그대로 두고 style.scss의 @media (prefers-reduced-motion: reduce) 규칙으로 transition 만 꺼 CSS 전환만 사라집니다. 09는 두 방식을 나란히 보여주는 항목이라 reduce 가 켜지면 requestAnimationFrame 대신 SAVING_MS 간격의 setInterval 로 두 패널 모두 상태를 즉시 스냅해 전환합니다. 이 분기가 항목마다 다르게 갈라지는 지점을 직접 눈으로 맞춰 보려면 압축 파일을 받아 여는 편이 빠른데, 비번 3z36yr9y 로 열면 아홉 항목의 html·scss·js 원본이 그대로 들어 있습니다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 펄스 주기 두 배로 | 01 PULSE_PERIOD_MS 900 → 1800 | 펄스 주기를 900→1800ms로 늘리면 저장 중 문구가 두 배 느리게 숨쉬듯 밝아졌다 어두워진다 |
| 점 순환 속도 올리기 | 06 STEP_MS 480 → 200 | 단계 전환 간격을 480→200ms로 줄이면 점 세 개가 서로를 바짝 뒤쫓듯 빠르게 넘어간다 |
| 카운트다운 절반으로 | 08 CYCLE_SEC 10 → 5 | 카운트다운 초를 10→5로 줄이면 링이 두 배 빠르게 돌아 저장 예고가 더 급박해 보인다 |
아홉 항목의 접근성 속성은 코드를 직접 뒤진 값만 씁니다. aria-*·role·tabindex 를 붙인 곳은 01의 점 세 개 묶음에 걸린 aria-hidden="true" 하나뿐이라, 값이 바뀌는 동안 스크린 리더는 그 점들을 건너뛰고 옆의 문구 텍스트만 읽습니다. 나머지 여덟 항목은 aria 속성이 전혀 없고, 애니메이션이 도는 동안에도 버튼에 disabled 를 거는 곳이 하나도 없어 재생 중에 다시 눌러 값을 초기화할 수 있습니다.
FAQ
자동저장 표시 값은 새로고침하면 왜 초기화되나요?
아홉 항목 모두 남은 재시도·대기 건수·경과 시간 값을 서버가 아니라 페이지 메모리(JS 변수)에만 담아 두기 때문입니다. 실제 자동저장 기능이라면 이 값을 서버 세션이나 클라이언트 저장소로 남겨 새로고침에도 이어가야 하지만, 이 데모는 계산 방식만 보여주는 화면이라 값 저장소를 두지 않았습니다.
02의 체크 그리기와 08의 링 줄어들기는 같은 계산인가요?
아니요. 02는 흐른 시간의 비율을 SVG 체크 경로 길이에 곱해 길이×(1−t)로 값을 늘려가며 그리고, 08은 반대로 남은 시간의 비율을 링 둘레에 곱해 둘레×(1−remaining/CYCLE_SEC)로 값을 줄입니다. 하나는 지나간 시간(t)이 늘어날수록 그려지고, 하나는 남은 시간이 줄어들수록 사라진다는 방향이 다릅니다.
07의 스프링 값을 다른 항목에 그대로 옮겨 써도 되나요?
값 자체는 옮길 수 있지만 체감은 달라집니다. 07의 SPRING_K 200·SPRING_C 19는 배너 위치 값 하나만 되돌리도록 맞춰진 상수라, 예를 들어 저장 아이콘 디스켓 모양 배지의 크기 값처럼 변위 범위가 다른 대상에 그대로 옮기면 튀는 폭과 잦아드는 속도가 달라 어색해 보일 수 있습니다.
더 읽을거리
CSS 바이브 코딩 마이크로 인터랙션은 저장 체크 아이콘 하나가 그려지는 단일 마이크로 인터랙션을 다루는 반면, 이 편은 그 체크가 포함된 아홉 가지 저장 상태 값이 각각 어떻게 순환·전이하는지로 계산 축을 넓혀 다룹니다. 토스트 스택 UI 9종은 알림 여러 개가 쌓이고 밀려나는 위치 값을 계산하는 데 비해, 이 편은 저장이라는 한 가지 상태가 전이되는 순간의 값(펄스·비율·스프링)을 계산한다는 점에서 축이 다릅니다. 로그인 실패 횟수 제한 9종은 실패가 쌓일 때 시도 횟수·잠금 초 값이 줄어드는 계산을 다루는데, 이 편은 실패가 아니라 저장이라는 정상 흐름 위에서 값이 전이되는 순간의 계산이라는 점에서 다룹니다.









