自動保存表示 9選 — 値が秒ごとに更新される9つの保存状態
この自動保存表示の9選では、9つのうち1つは保存中という文言が点3つと共に繰り返され、1つは最終保存時刻の値が秒単位で更新されて何分前という表示に変わり、もう1つはオフライン時の保留件数の値がオンラインに戻った瞬間に0まで減っていきます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 保存中文言のパルス — 保存が進む間、文言の明るさの値が上下を繰り返す方式
- 02 保存完了値のひらめき — 保存が終わる瞬間に完了値が1に変わり、一定時間後に0へ戻って消える方式
- 03 保存失敗の再試行回数 — 保存が失敗するたびに残り再試行回数の値が1つ減り、文言が揺れる方式
- 04 オフライン保留件数 — 接続が切れている間に積み上がる保留件数の値が、接続が戻る瞬間に0へ減っていく方式
- 05 最終保存時刻の更新 — 保存が終わってから経過した時間の値が毎秒再計算され、文言に変わる方式
- 06 状態ドットの色循環 — 待機・進行・完了の3段階に応じてドットの色の値が順に変わる方式
- 07 保存競合の案内 — 同じ項目が他所で先に保存されると案内の値が現れ、選択を待つ方式
- 08 次回自動保存カウントダウン — 次の保存までの残り時間の値が0に向かって減っていくリング方式
- 09 モーション削減時の静的保存表示 — モーション削減設定でアニメーションの代わりに文言の値だけが変わる方式
9つ全てが同じ引き金——ボタンへの本物のclickで動き始めます(07だけは選択肢ごとに2つのボタンを持ち、09のリスナーはボタンではなくパネル全体に付いています)。そしてどの値も運任せでは動きません——サイン波、指数バックオフの式、スプリングの積分、mod式のずれ、SVGのパス長のいずれかで動き、Math.random()は一度も使われません。ここにはfetch()もnavigator.onLineもlocalStorageも登場せず、保存中・オフライン待機・競合はすべてブラウザ内で再計算されるローカル変数なので、以下のどの記述も「サーバーが保存を確認する」「実際の接続を検知している」という意味では読めません。項目01だけが3つの点の行にaria-hiddenを付けている唯一の項目で、装飾でしかない点をスクリーンリーダーから隠しています。
9つのパターンの解剖 — どの値がいつ変わるか
3つの項目(01・05・09)は経過時間をそのままサイン波の不透明度値に変えます——01は保存中に文言と点が明滅し、05は保存済みのタイムスタンプが古くなるにつれ点がフェードし、09は同じサイン波を一方の板だけに走らせ、隣の板は一切トゥイーンなしで切り替わります。2つの項目(02・08)はパーセンテージをトゥイーンする代わりにSVGの長さを描きます——02のチェックは自身のgetTotalLength()に沿って伸び、08のリングは毎ティック固定の円周から値を差し引いてカウントダウンを刻みます。項目03と項目04はどちらも値を2つの境界の間でclampしますが、03の残り再試行数は指数バックオフの待ち時間(BACKOFF_BASE_MS×2^n)に直結する一方、04の保留件数は線形なscaleXの塗りバーだけを動かします。項目06は1つのmod式オフセットで3つの点に同じ3段階カウンターを回し、点ごとに1段ずつ遅れて表示され、項目07だけが経過時間から直接値を計算するのではなく、スプリング(SPRING_K・SPRING_C)を毎フレーム積分する唯一の項目です。これは単純な自動保存トグルボタンが一度切り替わるような話ではなく、9つの値はどれもデモが動いている間ずっと同じ計算式で再計算され続けます。
パターン別コード
01保存中文言のパルス — 保存が進む間、文言の明るさの値が上下を繰り返す方式
保存が進んでいる間、文言の明るさの値は一定周期で上下し、3つの点も同じ波形を一定のずれで追いかけます。位相がSAVING_MSを超えると全ての不透明度が固定値に切り替わり、文言も保存済みの値へ入れ替わってサイン波の計算は止まります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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');
}
数値が示すこと
23フレームの計測で、項目01はフレーム面積の0.41%だけを動かし強度スコア39.1・変化フレーム19という、短い文言と3つの点だけに留まる薄いパルスです。
02保存完了値のひらめき — 保存が終わる瞬間に完了値が1に変わり、一定時間後に0へ戻って消える方式
保存が終わる瞬間に完了値が1に切り替わり、チェックマークが一瞬現れてから、定めた保持時間の後に0へ戻って消えます。チェックマークはアイコン差し替えではなく、自身のgetTotalLength()で測った経路長に沿ってストローク・オフセットが満尺から0まで動く描画です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
});
}
数値が示すこと
23フレームの計測で、項目02はフレーム面積の0.64%・強度スコア53.4・変化フレーム11という、チェックが消えた後は何も残らない短い点滅です。
03保存失敗の再試行回数 — 保存が失敗するたびに残り再試行回数の値が1つ減り、文言が揺れる方式
保存が失敗するたびに残り再試行回数の値が1つ減り、その瞬間に文言が短く左右に揺れます。再試行が0になるまでに、次の待ち時間は固定の倍増式ですでに2倍ずつ伸びており、次の試行がいつ来るかをデモが黙っているわけではありません。

<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つ減り、文言が揺れる方式 — 計測 累積面積 1.646% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
});
}
数値が示すこと
23フレームの計測で、項目03はフレーム面積の1.65%・強度スコア109.5・変化フレーム10という、失敗件数の周りの数文字だけに触れる鋭く短い揺れです。
04オフライン保留件数 — 接続が切れている間に積み上がる保留件数の値が、接続が戻る瞬間に0へ減っていく方式
接続が切れている間、保留件数の値は1つずつ増え、接続が戻った瞬間にその値が順番に0まで減っていきます。同じ件数に連動する塗りバーはMath.min/Math.maxで0と上限の間に固定され、どちらの端も超えて動くことはありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
}
数値が示すこと
23フレームの計測で、項目04はフレーム面積の1.76%・強度スコア148.7・変化フレーム22という、小さなバーと数字でありながら溜まって流れる間はほぼ全フレームで動き続ける項目です。
05最終保存時刻の更新 — 保存が終わってから経過した時間の値が毎秒再計算され、文言に変わる方式
保存が終わった後、経過時間の値は毎秒再計算され、その値は何分前という文言へ変わり続けます。同じ経過時間に連動する鮮度の値は、タイムスタンプが古くなるほど状態ドットを固定の下限へ向けてフェードさせますが、モーション削減時はその代わりに不透明度を最大で固定します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
数値が示すこと
23フレームの計測で、項目05はフレーム面積の0.82%・強度スコア104.4・変化フレーム21という、フレーム面積自体はほとんど変わらないのにほぼ毎フレーム再計算が走る値です。
06状態ドットの色循環 — 待機・進行・完了の3段階に応じてドットの色の値が順に変わる方式
段階が待機から進行へ、進行から完了へ移るたびに、ドットの色の値が順番に変わります。3つのドットは1つの段階カウンターをmod式で共有しているため、特定の1つが大半の変化を担うのではなく、3つが1段ずつずれて入れ替わります。

<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 · 状態ドットの色循環 — 待機・進行・完了の3段階に応じてドットの色の値が順に変わる方式 — 計測 累積面積 0.888% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 段階数(待機・進行・完了)・段階が進む間隔(加速ms)、そして各段階のラベル文言とCSSクラス
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);
}
数値が示すこと
23フレームの計測で、項目06はフレーム面積の0.89%・強度スコア60.4・変化フレーム18という、1つのドットが動きを独占するのではなく3つが順番に持ち回る値です。
07保存競合の案内 — 同じ項目が他所で先に保存されると案内の値が現れ、選択を待つ方式
同じ項目が他所で先に保存されたと検知すると、案内の登場値が1に変わって画面に留まり、選択が行われた後にだけ値が0に戻って消えます。そのスライドはトゥイーンではなくスプリングで、剛性と減衰の定数から速度と位置を毎フレーム積分するため、バナーはわずかに行き過ぎてから静止します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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) + '%)';
}
}
数値が示すこと
23フレームの計測で、項目07はフレーム面積の11.11%・強度スコア127.8・変化フレーム10という、9つの中で最大の面積を持つ、小さな部品ではなくバナー全体がスライドして静止する値です。
08次回自動保存カウントダウン — 次の保存までの残り時間の値が0に向かって減っていくリング方式
次の自動保存までの残り時間の値は決まった数値から始まり、固定の間隔で0に向かって減り続けます。保存アイコン風の輪郭から描いたリングは、毎ティック自身の円周から値を差し引くことで同じカウントダウンを映し、アイコン画像を差し替えているわけではありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
数値が示すこと
23フレームの計測で、項目08はフレーム面積の2.63%・強度スコア158.9・変化フレーム18という、一度に大きく動くのではなく一定の時計に沿って更新されるリングと数字です。
09モーション削減時の静的保存表示 — モーション削減設定でアニメーションの代わりに文言の値だけが変わる方式
モーション削減がオンだと、位置や大きさの値は動かず、文言のテキスト値だけがその場で変わります。左右に並ぶ2つの板は同じ位相の値を共有していますが、一方はサイン波に沿って不透明度がトゥイーンする一方、もう一方はJSの不透明度値を一切受け取らないため、専用のスタイルではなく値を渡さないことだけで静止して見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
数値が示すこと
23フレームの計測で、項目09はフレーム面積の2.27%・強度スコア57.7・変化フレーム16という、フレーム占有率ではなく2つの板を並べて比較するための最小限の値です。
値がパターンを変える基準 — 更新周期・文言切替タイミング・色段階の基準
パルス周期を2倍に。 項目01のPULSE_PERIOD_MSを900から1800に動かすと、保存中の文言と3つの点は2倍ゆっくり呼吸するようになります。これは項目09自身が持つPULSE_PERIOD_MS(500)とは別の定数で、この2項目は1つのパルスを共有していません。
点循環の速度を上げる。 項目06のSTEP_MSを480から200に動かすと、待機・進行・完了を渡り歩く3つの点ははるかに速く追いかけ合うようになりますが、どの点がどの色段階を示すかを決めるmod式そのものは変わりません。
カウントダウンを半分に。 項目08のCYCLE_SECを10から5に動かすと、次の自動保存に向けてリングは2倍速く回ります。これは項目01自身のSAVING_MSとは別の値で、保存そのものにかかって見える時間はここでは変わらず、カウントダウンが再始動する頻度だけが変わります。
この9つのどれも単体でサーバーとの実通信を確認してはいません。マイクロインタラクション CSS 9選 — 一行でコピペOK(018)は、1つの要素が1回の操作で状態を切り替える計算軸を扱っており、この9つのように1つの状態値が複数の局面を巡り続けるものとは異なります。トースト通知 UI 9選 — 積み上がるスタック実装(255)は、複数の値が積み上がり・押し出し・消えるリストの計算軸を扱っており、単一の保存表示より1段上のレイヤーです。ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式(359)は、失敗回数の値が再試行の余地をロックへ向けて絞り込む計算軸を扱っており、ここでは項目03自身の再試行カウンターに最も近い兄弟です。この9つのソースはすべて下のzipに入っており、3z36yr9yを入力すれば開きます。
どこで壊れるか — オフライン復帰の瞬間・複数タブ同時保存・モーション削減
オフライン復帰の瞬間
9つのどれもnavigator.onLineを呼ばず、本物のonlineイベントも聞いていません——項目04のonlineフラグはただの変数で、自身のボタンと、件数が0か上限に達した時にだけ切り替わります。だから実際のネットワーク切断に反応しているわけではありません。onlineがfalseの間だけ保留件数を積み上げ、クリックでtrueに変わった瞬間に減らしていくデモは、自動保存 復元の見た目をしていても、実際には計算上の算術を見せているだけで、本物のオフライン検知ではありません。
複数タブ同時保存
同じ項目を別のタブで開くと、まったく独立したコピーが動き出します——9つのどれもlocalStorageやBroadcastChannel、その他タブ間で見える経路を読み書きしないため、項目07の競合バナーや項目04の保留件数を2つのタブで同時に動かしても、互いの存在にはまったく気づきません。項目07のような本物の複数タブ競合バナーには、もう一方のタブの保存を検知するための共有チャンネルがまさに必要で、それはここでは意図的に外されている部品です。
モーション削減
6つの項目——01・02・03・05・07・09——は自分自身のスクリプトの中でwindow.matchMedia('(prefers-reduced-motion: reduce)')を読み、直接分岐しています。01と09はサイン波の不透明度をやめて固定値に切り替え、02と03は描画や揺れのトゥイーンを飛ばして終端の状態へ直行し、07はスプリングの位置を積分せずに目標値へ直接スナップします。項目04・06・08はスクリプトの中で一度もreduceを確認しません——それぞれ自身のSCSSメディアクエリにあるtransition: noneだけが唯一の譲歩で、JS側が動かす塗りバー・ドットの色切り替え・リングのオフセットは前とまったく同じように毎フレーム再計算され続け、その上に乗るCSSトランジションだけが消えます。項目09は自身の分岐の上にもう1つ挙動を重ねており、モーション削減下ではオートプレイがrequestAnimationFrameから単純なsetIntervalに切り替わり、トゥイーンを一切挟まず2つの板のテキストとクラスを同時に切り替えます。これが、2つの板を並べてクリックのたびに比較できる理由でもあります。
3つのバリエーションとアクセシビリティ
| バリエーション | 変わる値 | 触れない値 |
|---|---|---|
| パルス周期を2倍に | PULSE_PERIOD_MS 900 → 1800(項目01) |
項目09自身のPULSE_PERIOD_MS(500) |
| 点循環の速度を上げる | STEP_MS 480 → 200(項目06) |
段階の遅れを決めるmod式そのもの |
| カウントダウンを半分に | CYCLE_SEC 10 → 5(項目08) |
項目01自身のSAVING_MS |
9つの中でaria-*属性を追加しているのは項目01だけで、<span class="ssp__dots" aria-hidden="true">が3つの純粋に装飾的な点をスクリーンリーダーから隠しています。他の項目はroleもaria-liveもtabindexも一切追加していないため、この状態変化をスクリーンリーダーに届けるには、このコードを組み込む側が自分でライブリージョンを足す必要があります。9つのどれも状態変化にバンドルされたアイコン画像を差し替えてはおらず、最も近いのは項目02自身が描くチェックのパスで、getTotalLength()から作られたもので画像ファイルではありません。ネイティブな部分としては、項目01から08までがそれぞれ本物の<button type="button">から発火し(07だけは選択肢ごとに2つ)、項目09の引き金はボタン要素ではなくパネル全体へのclickリスナーで、9つのどれもdisabled属性をどの時点でも設定していません。
FAQ
01・05・08の値はrequestAnimationFrameで更新されるのに、06だけタイマーが違うのはなぜですか
項目06はSTEP_MSごとに段階のインデックスを1つ進めるだけなので、単純なsetIntervalで十分です。01・05・08はサイン波や経過時間の文言、リングのオフセットを毎回なめらかに見せる必要があるため、描画されるフレームごとにrequestAnimationFrameを使い続けます。
項目04の保留件数の値は、実際に切れた接続を反映していますか
いいえ——onlineは項目自身のボタンでしか切り替わらないただの変数なので、保留件数が積み上がって0まで流れ落ちるのはローカルなフラグの上の算術であり、本物のオフライン検知ではありません。
組み込み済みの点やチェックの代わりに、自分のアイコンに差し替えられますか
はい——点やチェック、リングのマークアップを保存アイコン フロッピーの図柄や他のアイコンに差し替えても、その上にある計算ロジックには一切触れません。どの項目も、与えられた要素のクラス・不透明度・ストロークの値のどれかを変えているだけだからです。
出典: MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — SVGGeometryElement: getTotalLength() method









