ダウンロード 進捗バー 9選 — 値がパーセントで更新される9つの方式
このダウンロード 進捗バー特集では、9つのうち1つは複数のファイルが同時に受信される間、各ファイルの進捗値が一覧の中で並んで上がり、1つは検証が失敗するたびに再試行回数の値が増えて進捗値が最初に戻り、もう1つは完了した項目の位置値が一覧の決まった領域へ移っていきます。
01 download-manager-multi-file-list
02 download-manager-speed-graph
03 download-manager-pause-resume
04 download-manager-queue-reorder
05 download-manager-extract-morph
06 download-manager-corrupt-retry
07 download-manager-complete-flash
08 download-manager-folder-reveal
09 download-manager-reduced-motion
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 複数ファイル進捗値の並列更新 — 複数のファイルが同時に受信される間、各ファイルの進捗値が一覧の中でそれぞれ更新される方式
- 02 転送速度値のグラフ更新 — 毎秒の転送量の値がグラフの形で描き直され続ける方式
- 03 一時停止・再開の状態値切替 — 状態値が一時停止と進行の間を切り替わるたびに、値の変化が止まりまた続く方式
- 04 待機列の順序値の並び替え — まだ開始していない項目の順序値がユーザーが決めた順番に変わる方式
- 05 解凍進捗値と形状値の同時変化 — 解凍の進捗値が満ちていく間、形状の値も一緒に変わる方式
- 06 破損時の再試行回数値 — 検証に失敗するたびに再試行回数の値が1つ増え、進捗値が最初に戻る方式
- 07 完了値のひらめき表示 — ダウンロードが終わる瞬間に完了値が1に変わり、一定時間後に0へ戻って消える方式
- 08 完了項目の位置値の移動 — ダウンロードが終わった項目の位置値が一覧の決まった領域へ移る方式
- 09 モーション削減時の静的ダウンロード表示 — モーション削減設定でアニメーションの代わりに進捗値の文言だけが変わる方式
9つのうち8つは本物のボタンへのclickで動き始め、項目04だけはpointerdown・pointermove・pointerupという実際のドラッグ操作で順序の値を直接動かします。どの値も運任せでは動かず、加重合計・移動平均・経過時間の積算・線形またはスプリングの補間・あらかじめ決めた合否の配列・指数減衰のいずれかで計算され、Math.random()は一度も使われません。ここにはfetch()もFileもBlobもnavigator.onLineも登場せず、「受信中」「解凍中」「破損」はすべてブラウザ内で再計算されるローカル変数なので、以下のどの記述も「サーバーが転送を確認している」「実際の接続を検知している」という意味では読めません。ダウンロード 進捗 表示を自分のプロジェクトのために調べているなら、以下の9つはどれも本物のバイト数ではなくただのJavaScript変数からパーセントを計算しているという点を先に押さえておくと、ダウンロード 進捗 確認の実装を安全にコピーできます。
9つのパターンの解剖 — どの値がいつ変わるか
3つの項目(01・03・09)は1つの経過値や累積値をそのまま塗りバーのscaleXに使いますが、それぞれのやり方は異なります——01は3つの独立したファイル別の値を、3つのパーセントを均等に平均するのではなくSIZESで重み付けした4つ目の合計値にまとめ、03は一時停止するたびにpausedElapsedへそれまでの経過値を貯め込んでおくので一時停止が値を0へ巻き戻すことは一度もなく、09は同じ塗りバーを2通りに描き分け——一方は毎ティックtransformを更新し、もう一方は数字だけを差し替えて一切アニメーションしません。2つの項目(02・08)は一度に多くの小さな値から1つの値を組み立てます——02の移動平均は直近N個の生サンプルを混ぜてノイズの多い線の隣にもう1本なめらかな線を描き、08は経過時間の値を直接読む代わりにスプリング方程式を毎フレーム積分します。項目05は8つの点座標を1つの進捗値に合わせて一斉にlerpし、不透明度や拡大率をトゥイーンするのではなく多角形そのものの形を作り変えます。項目06の再試行回数と項目04の順序値はどちらも小さな整数で周りの値を並べ替えたり0に戻したりしますが、06のカウントは指数バックオフの待機時間を生み出す一方、04の順序は位置がイーズしていく目標を決めるだけです。項目07は9つの中で唯一、1サイクルに1回変わる値ではなく、決まった回数だけ繰り返すパスにまたがる指数減衰を計算しています。これらはどれも1つのダウンロード完了フラグが一度だけ切り替わるようなものではなく、9つ全てがデモの動いている間ずっと自分の値を一定周期で再計算し続け、9つのうち項目04だけが時間ではなく本物のドラッグからその値を更新します。
パターン別コード
01複数ファイル進捗値の並列更新 — 複数のファイルが同時に受信される間、各ファイルの進捗値が一覧の中でそれぞれ更新される方式
3つのファイルが同時に受信されている間、各行の塗りバーはそのファイル自身のSIZESの値に対する割合までスケールし、下の.dmf__row--total行は4つ目の値——3ファイル合計の受信バイト数をSIZESの合計で割った値——を描きます。これは3つのパーセントを均等平均したものではなく、容量で重み付けした合計です。一時停止すると全ての行のタイマーが同時に止まり、裏で進み続けることはありません。

<main class="stage">
<div class="dmf is-demo" id="dmf">
<div class="dmf__row" data-i="0">
<div class="dmf__head"><span class="dmf__name">ファイル 1</span><span class="dmf__pct" id="dmfPct0">0%</span></div>
<div class="dmf__track"><div class="dmf__fill" id="dmfFill0"></div></div>
</div>
<div class="dmf__row" data-i="1">
<div class="dmf__head"><span class="dmf__name">ファイル 2</span><span class="dmf__pct" id="dmfPct1">0%</span></div>
<div class="dmf__track"><div class="dmf__fill" id="dmfFill1"></div></div>
</div>
<div class="dmf__row" data-i="2">
<div class="dmf__head"><span class="dmf__name">ファイル 3</span><span class="dmf__pct" id="dmfPct2">0%</span></div>
<div class="dmf__track"><div class="dmf__fill" id="dmfFill2"></div></div>
</div>
<div class="dmf__row dmf__row--total">
<div class="dmf__head"><span class="dmf__name">合計</span><span class="dmf__pct" id="dmfPctT">0%</span></div>
<div class="dmf__track"><div class="dmf__fill dmf__fill--total" id="dmfFillT"></div></div>
</div>
<button class="dmf__btn" id="dmfBtn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 전체 바 = 파일별 바이트 합 ÷ 크기 합(용량 가중 평균) — index.html 참조
@include stage($stage-ink);
.dmf {
width: 100%;
max-width: 240px;
display: grid;
gap: $sp-2;
}
.dmf__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 12px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
margin-bottom: $sp-1;
}
.dmf__pct { font-variant-numeric: tabular-nums; color: rgba($subject-cream, .72); }
.dmf__track {
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
overflow: hidden;
}
.dmf__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-blue-light;
transition: transform $dur-instant linear;
}
.dmf__row--total { margin-top: $sp-1; }
.dmf__row--total .dmf__fill--total { background: $subject-lilac; }
.dmf--done .dmf__row--total .dmf__fill--total { background: $subject-mint; }
.dmf__btn {
margin-top: $sp-2;
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
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) {
.dmf { max-width: 200px; gap: $sp-1; }
.dmf__row--total { margin-top: 0; }
.dmf__btn { margin-top: $sp-1; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.dmf__fill { transition: none; }
.dmf__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);
}
.dmf {
width: 100%;
max-width: 240px;
display: grid;
gap: 8px;
}
.dmf__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 12px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
margin-bottom: 4px;
}
.dmf__pct {
font-variant-numeric: tabular-nums;
color: rgba(255, 247, 230, 0.72);
}
.dmf__track {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
overflow: hidden;
}
.dmf__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #5b8cf8;
transition: transform 140ms linear;
}
.dmf__row--total {
margin-top: 4px;
}
.dmf__row--total .dmf__fill--total {
background: #b9a0ff;
}
.dmf--done .dmf__row--total .dmf__fill--total {
background: #4cd4a6;
}
.dmf__btn {
margin-top: 8px;
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #fff7e6;
color: #17141a;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.dmf__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dmf {
max-width: 200px;
gap: 4px;
}
.dmf__row--total {
margin-top: 0;
}
.dmf__btn {
margin-top: 4px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dmf__fill {
transition: none;
}
.dmf__btn {
transition: none;
}
}(function () {
var root = document.getElementById('dmf');
var btn = document.getElementById('dmfBtn');
var fills = [document.getElementById('dmfFill0'), document.getElementById('dmfFill1'), document.getElementById('dmfFill2')];
var pcts = [document.getElementById('dmfPct0'), document.getElementById('dmfPct1'), document.getElementById('dmfPct2')];
var fillT = document.getElementById('dmfFillT');
var pctT = document.getElementById('dmfPctT');
// 시드 고정: 파일별 크기(KB, 가상)와 틱마다 더할 속도 배열(KB/틱). 실 fetch·File API 0
var SIZES = [420, 980, 640];
var SPEEDS = [
[32, 40, 48, 52, 46, 38, 30, 44, 56, 60, 50, 36],
[58, 64, 60, 52, 44, 38, 46, 54, 62, 58, 48, 40],
[20, 26, 32, 38, 44, 40, 34, 28, 24, 30, 36, 42]
];
var TICK_MS = 140;
var HOLD_MS = 700;
var done = [0, 0, 0];
var tick = 0;
var paused = false;
var timer = null;
// 계산 겹: 合計 進行中 값 = 파일별 다운로드 바이트 합 ÷ 合計 크기 합(용량 가중 평균, 단순 3칸 평균 아님)
function overall() {
var d = done[0] + done[1] + done[2];
var s = SIZES[0] + SIZES[1] + SIZES[2];
return d / s;
}
function paint() {
for (var i = 0; i < 3; i++) {
var p = done[i] / SIZES[i];
fills[i].style.transform = 'scaleX(' + p.toFixed(3) + ')';
pcts[i].textContent = Math.round(p * 100) + '%';
}
var o = overall();
fillT.style.transform = 'scaleX(' + o.toFixed(3) + ')';
pctT.textContent = Math.round(o * 100) + '%';
root.classList.toggle('dmf--done', o >= 1);
}
function step() {
var allDone = true;
for (var i = 0; i < 3; i++) {
if (done[i] < SIZES[i]) {
done[i] = Math.min(SIZES[i], done[i] + SPEEDS[i][tick % SPEEDS[i].length]);
allDone = false;
}
}
tick++;
paint();
if (allDone) {
timer = setTimeout(function () { done = [0, 0, 0]; tick = 0; paint(); loop(); }, HOLD_MS);
} else {
loop();
}
}
function loop() {
if (paused) return;
timer = setTimeout(step, TICK_MS);
}
paint();
loop();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
paused = !paused;
btn.textContent = paused ? '再開' : '一時停止';
if (!paused) loop();
else if (timer) clearTimeout(timer);
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerMultiFileList.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerMultiFileList() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const fills = [0, 1, 2].map((i) => root.querySelector(`.${styles["fill" + i]}`) as HTMLElement | null);
const pcts = [0, 1, 2].map((i) => root.querySelector(`.${styles["pct" + i]}`) as HTMLElement | null);
const fillT = root.querySelector(`.${styles.fillT}`) as HTMLElement | null;
const pctT = root.querySelector(`.${styles.pctT}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!fillT || !pctT || !btn) return;
// 시드 고정: 파일별 크기(KB, 가상)와 틱마다 더할 속도 배열(KB/틱). 실 fetch·File API 0
const SIZES = [420, 980, 640];
const SPEEDS = [
[32, 40, 48, 52, 46, 38, 30, 44, 56, 60, 50, 36],
[58, 64, 60, 52, 44, 38, 46, 54, 62, 58, 48, 40],
[20, 26, 32, 38, 44, 40, 34, 28, 24, 30, 36, 42],
];
const TICK_MS = 140;
const HOLD_MS = 700;
let done = [0, 0, 0];
let tick = 0;
let paused = false;
let timer = 0;
let stopped = false;
// 계산 겹: 전체 진행 값 = 파일별 다운로드 바이트 합 ÷ 전체 크기 합(용량 가중 평균, 단순 3칸 평균 아님)
function overall() {
const d = done[0] + done[1] + done[2];
const s = SIZES[0] + SIZES[1] + SIZES[2];
return d / s;
}
function paint() {
for (let i = 0; i < 3; i++) {
const p = done[i] / SIZES[i];
const fillEl = fills[i];
const pctEl = pcts[i];
if (fillEl) fillEl.style.transform = "scaleX(" + p.toFixed(3) + ")";
if (pctEl) pctEl.textContent = Math.round(p * 100) + "%";
}
const o = overall();
fillT!.style.transform = "scaleX(" + o.toFixed(3) + ")";
pctT!.textContent = Math.round(o * 100) + "%";
root!.classList.toggle(styles["dmf--done"], o >= 1);
}
function step() {
if (stopped) return;
let allDone = true;
for (let i = 0; i < 3; i++) {
if (done[i] < SIZES[i]) {
done[i] = Math.min(SIZES[i], done[i] + SPEEDS[i][tick % SPEEDS[i].length]);
allDone = false;
}
}
tick++;
paint();
if (allDone) {
timer = window.setTimeout(() => { done = [0, 0, 0]; tick = 0; paint(); loop(); }, HOLD_MS);
} else {
loop();
}
}
function loop() {
if (paused || stopped) return;
timer = window.setTimeout(step, TICK_MS);
}
paint();
loop();
function onClick() {
root!.classList.remove(styles["is-demo"]);
paused = !paused;
btn!.textContent = paused ? "재개" : "일시정지";
if (!paused) loop();
else window.clearTimeout(timer);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dmf} ${styles["is-demo"]}`}>
<div className={styles.row}>
<div className={styles.head}><span className={styles.name}>파일 1</span><span className={`${styles.pct} ${styles.pct0}`}>0%</span></div>
<div className={styles.track}><div className={`${styles.fill} ${styles.fill0}`} /></div>
</div>
<div className={styles.row}>
<div className={styles.head}><span className={styles.name}>파일 2</span><span className={`${styles.pct} ${styles.pct1}`}>0%</span></div>
<div className={styles.track}><div className={`${styles.fill} ${styles.fill1}`} /></div>
</div>
<div className={styles.row}>
<div className={styles.head}><span className={styles.name}>파일 3</span><span className={`${styles.pct} ${styles.pct2}`}>0%</span></div>
<div className={styles.track}><div className={`${styles.fill} ${styles.fill2}`} /></div>
</div>
<div className={`${styles.row} ${styles["row--total"]}`}>
<div className={styles.head}><span className={styles.name}>전체</span><span className={`${styles.pct} ${styles.pctT}`}>0%</span></div>
<div className={styles.track}><div className={`${styles.fill} ${styles["fill--total"]} ${styles.fillT}`} /></div>
</div>
<button className={styles.btn} type="button">일시정지</button>
</div>
);
}01 · 複数ファイル進捗値の並列更新 — 複数のファイルが同時に受信される間、各ファイルの進捗値が一覧の中でそれぞれ更新される方式 — 計測 累積面積 3.927% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// シード固定: ファイルごとのサイズ(KB、仮想)とティックごとに加える速度配列(KB/ティック)。実fetch・File API 0
var SIZES = [420, 980, 640];
var SPEEDS = [
[32, 40, 48, 52, 46, 38, 30, 44, 56, 60, 50, 36],
[58, 64, 60, 52, 44, 38, 46, 54, 62, 58, 48, 40],
[20, 26, 32, 38, 44, 40, 34, 28, 24, 30, 36, 42]
];
var TICK_MS = 140;
var HOLD_MS = 700;
var done = [0, 0, 0];
var tick = 0;
var paused = false;
var timer = null;
// 計算層: 全体の進捗値 = 各ファイルの受信バイト合計 ÷ 全体サイズ合計(容量による加重平均、単純な3分の1平均ではない)
function overall() {
var d = done[0] + done[1] + done[2];
var s = SIZES[0] + SIZES[1] + SIZES[2];
return d / s;
}
数値が示すこと
23フレームの計測で、項目01はフレーム面積の3.93%・強度スコア133.8・変化フレーム16という、3本の細い塗りバーと合計バー1本にとどまる値で、画面いっぱいの場面ではありません。
02転送速度値のグラフ更新 — 毎秒の転送量の値がグラフの形で描き直され続ける方式
160msごとに固定されたSAMPLES配列から新しい生の値が1つ届き、それと同時に直近N個の平均というもう1つの値が計算されるので、SVGは2本の折れ線——ノイズの多い.dsg__rawと、その隣になめらかな平均線——を描き直します。上部のラベルが表示するのは常にこの平均値で、生の値そのものではありません。

<main class="stage">
<div class="dsg is-demo" id="dsg">
<div class="dsg__head">
<span class="dsg__label">転送速度</span>
<span class="dsg__val" id="dsgVal">0MB/s</span>
</div>
<svg class="dsg__graph" viewBox="0 0 220 80" preserveAspectRatio="none">
<polyline class="dsg__raw" id="dsgRaw" points=""></polyline>
<polyline class="dsg__avg" id="dsgAvg" points=""></polyline>
</svg>
<button class="dsg__btn" id="dsgBtn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 원시 표본의 이동평균(최근 N개) 폴리라인 — index.html movingAvg() 참조
@include stage($stage-paper);
.dsg {
width: 100%;
max-width: 260px;
display: grid;
gap: $sp-2;
}
.dsg__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.dsg__val { font-variant-numeric: tabular-nums; color: $subject-blue-deep; }
.dsg__graph {
width: 100%;
height: 96px;
border-radius: $r-sm;
background: $subject-cream;
}
.dsg__raw {
fill: none;
stroke: $chart-2;
stroke-width: 1;
opacity: .35;
vector-effect: non-scaling-stroke;
}
.dsg__avg {
fill: none;
stroke: $chart-1;
stroke-width: 2.4;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.dsg__btn {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $subject-cream;
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) {
.dsg { max-width: 200px; }
.dsg__graph { height: 72px; }
}
@media (prefers-reduced-motion: reduce) {
.dsg__avg, .dsg__raw { transition: none; }
.dsg__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);
}
.dsg {
width: 100%;
max-width: 260px;
display: grid;
gap: 8px;
}
.dsg__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dsg__val {
font-variant-numeric: tabular-nums;
color: #0a4fe8;
}
.dsg__graph {
width: 100%;
height: 96px;
border-radius: 8px;
background: #fff7e6;
}
.dsg__raw {
fill: none;
stroke: #c2361a;
stroke-width: 1;
opacity: 0.35;
vector-effect: non-scaling-stroke;
}
.dsg__avg {
fill: none;
stroke: #2f6df6;
stroke-width: 2.4;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.dsg__btn {
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.dsg__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dsg {
max-width: 200px;
}
.dsg__graph {
height: 72px;
}
}
@media (prefers-reduced-motion: reduce) {
.dsg__avg, .dsg__raw {
transition: none;
}
.dsg__btn {
transition: none;
}
}(function () {
var root = document.getElementById('dsg');
var val = document.getElementById('dsgVal');
var rawLine = document.getElementById('dsgRaw');
var avgLine = document.getElementById('dsgAvg');
// 시드 고정: 초당 전송량 원시 표본(MB/s ×10, 정수). 실 네트워크 측정 0
var SAMPLES = [12, 18, 15, 26, 32, 28, 40, 36, 44, 52, 48, 58, 54, 62, 46, 38, 44, 50, 42, 34, 28, 22, 16, 20];
var N = 5; // 이동평균 창(최근 N 개 표본)
var W = 220, H = 80, MAXV = 65;
var TICK_MS = 160;
var buf = [];
var i = 0;
var paused = false;
var timer = 0;
var btn = document.getElementById('dsgBtn');
function pt(idx, v, count) {
var x = count <= 1 ? W : (idx / (count - 1)) * W;
var y = H - (v / MAXV) * H;
return x.toFixed(1) + ',' + y.toFixed(1);
}
// 계산 겹: 최근 N개 원시 표본의 이동평균 — 그래프 선은 이 평균값을 잇는다(원시값 자체가 아님)
function movingAvg(arr) {
var out = [];
for (var k = 0; k < arr.length; k++) {
var s = Math.max(0, k - N + 1);
var sum = 0, c = 0;
for (var j = s; j <= k; j++) { sum += arr[j]; c++; }
out.push(sum / c);
}
return out;
}
function paint() {
var avg = movingAvg(buf);
var rawPts = [], avgPts = [];
for (var k = 0; k < buf.length; k++) {
rawPts.push(pt(k, buf[k], buf.length));
avgPts.push(pt(k, avg[k], buf.length));
}
rawLine.setAttribute('points', rawPts.join(' '));
avgLine.setAttribute('points', avgPts.join(' '));
var last = avg.length ? avg[avg.length - 1] : 0;
val.textContent = (last / 10).toFixed(1) + 'MB/s';
}
function tick() {
if (paused) return;
buf.push(SAMPLES[i % SAMPLES.length]);
if (buf.length > 14) buf.shift();
i++;
paint();
timer = setTimeout(tick, TICK_MS);
}
tick();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
paused = !paused;
btn.textContent = paused ? '再開' : '一時停止';
if (!paused) tick();
else clearTimeout(timer);
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerSpeedGraph.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerSpeedGraph() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const val = root.querySelector(`.${styles.val}`) as HTMLElement | null;
const rawLine = root.querySelector(`.${styles.raw}`) as SVGPolylineElement | null;
const avgLine = root.querySelector(`.${styles.avg}`) as SVGPolylineElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!val || !rawLine || !avgLine || !btn) return;
// 시드 고정: 초당 전송량 원시 표본(MB/s ×10, 정수). 실 네트워크 측정 0
const SAMPLES = [12, 18, 15, 26, 32, 28, 40, 36, 44, 52, 48, 58, 54, 62, 46, 38, 44, 50, 42, 34, 28, 22, 16, 20];
const N = 5;
const W = 220, H = 80, MAXV = 65;
const TICK_MS = 160;
let buf: number[] = [];
let i = 0;
let paused = false;
let timer = 0;
let stopped = false;
function pt(idx: number, v: number, count: number) {
const x = count <= 1 ? W : (idx / (count - 1)) * W;
const y = H - (v / MAXV) * H;
return x.toFixed(1) + "," + y.toFixed(1);
}
// 계산 겹: 최근 N개 원시 표본의 이동평균 — 그래프 선은 이 평균값을 잇는다(원시값 자체가 아님)
function movingAvg(arr: number[]) {
const out: number[] = [];
for (let k = 0; k < arr.length; k++) {
const s = Math.max(0, k - N + 1);
let sum = 0, c = 0;
for (let j = s; j <= k; j++) { sum += arr[j]; c++; }
out.push(sum / c);
}
return out;
}
function paint() {
const avg = movingAvg(buf);
const rawPts: string[] = [], avgPts: string[] = [];
for (let k = 0; k < buf.length; k++) {
rawPts.push(pt(k, buf[k], buf.length));
avgPts.push(pt(k, avg[k], buf.length));
}
rawLine!.setAttribute("points", rawPts.join(" "));
avgLine!.setAttribute("points", avgPts.join(" "));
const last = avg.length ? avg[avg.length - 1] : 0;
val!.textContent = (last / 10).toFixed(1) + "MB/s";
}
function tick() {
if (paused || stopped) return;
buf.push(SAMPLES[i % SAMPLES.length]);
if (buf.length > 14) buf.shift();
i++;
paint();
timer = window.setTimeout(tick, TICK_MS);
}
tick();
function onClick() {
root!.classList.remove(styles["is-demo"]);
paused = !paused;
btn!.textContent = paused ? "재개" : "일시정지";
if (!paused) tick();
else window.clearTimeout(timer);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dsg} ${styles["is-demo"]}`}>
<div className={styles.head}>
<span className={styles.label}>전송 속도</span>
<span className={styles.val}>0MB/s</span>
</div>
<svg className={styles.graph} viewBox="0 0 220 80" preserveAspectRatio="none">
<polyline className={styles.raw} points="" />
<polyline className={styles.avg} points="" />
</svg>
<button className={styles.btn} type="button">일시정지</button>
</div>
);
}02 · 転送速度値のグラフ更新 — 毎秒の転送量の値がグラフの形で描き直され続ける方式 — 計測 累積面積 8.108% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 直近N個の生サンプルの移動平均 — グラフの線はこの平均値をつなぐ(生の値そのものではない)
function movingAvg(arr) {
var out = [];
for (var k = 0; k < arr.length; k++) {
var s = Math.max(0, k - N + 1);
var sum = 0, c = 0;
for (var j = s; j <= k; j++) { sum += arr[j]; c++; }
out.push(sum / c);
}
return out;
}
数値が示すこと
23フレームの計測で、項目02はフレーム面積の8.11%・強度スコア108.0・変化フレーム16で、描き直され続けるSVGの折れ線がその面積の大半を占めています。
03一時停止・再開の状態値切替 — 状態値が一時停止と進行の間を切り替わるたびに、値の変化が止まりまた続く方式
経過時間の値はrequestAnimationFrameの中で数え続けますが、一時停止しても0には戻りません——pausedElapsedがそれまでの累積値を貯めておくので、再開すると止まったちょうどその値から続きます。このデモはTOTAL_MSの28%と62%で自動的に2回一時停止し、完了すると0に戻ってループします。

<main class="stage">
<div class="dpr is-demo" id="dpr">
<div class="dpr__head">
<span class="dpr__state" id="dprState">進行中</span>
<span class="dpr__pct" id="dprPct">0%</span>
</div>
<div class="dpr__track"><div class="dpr__fill" id="dprFill"></div></div>
<button class="dpr__btn" id="dprBtn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 경과 시간 = 정지 전 누적값 + (지금 - 재개 시각) — index.html elapsed() 참조
@include stage($stage-orange);
.dpr {
width: 100%;
max-width: 240px;
display: grid;
gap: $sp-3;
}
.dpr__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.dpr--paused .dpr__state { text-decoration: underline; text-decoration-thickness: 2px; }
.dpr__pct { font-variant-numeric: tabular-nums; color: $subject-ink; }
.dpr__track {
width: 100%;
height: $sp-3;
border-radius: $r-pill;
background: rgba($subject-ink, .16);
overflow: hidden;
}
.dpr__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-ink;
}
.dpr--paused .dpr__fill { background: $subject-orange-deep; }
.dpr__btn {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-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) {
.dpr { max-width: 200px; }
}
@media (prefers-reduced-motion: reduce) {
.dpr__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);
}
.dpr {
width: 100%;
max-width: 240px;
display: grid;
gap: 12px;
}
.dpr__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dpr--paused .dpr__state {
text-decoration: underline;
text-decoration-thickness: 2px;
}
.dpr__pct {
font-variant-numeric: tabular-nums;
color: #17141a;
}
.dpr__track {
width: 100%;
height: 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
overflow: hidden;
}
.dpr__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #17141a;
}
.dpr--paused .dpr__fill {
background: #c93000;
}
.dpr__btn {
width: 100%;
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;
}
.dpr__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dpr {
max-width: 200px;
}
}
@media (prefers-reduced-motion: reduce) {
.dpr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('dpr');
var state = document.getElementById('dprState');
var pctEl = document.getElementById('dprPct');
var fill = document.getElementById('dprFill');
var btn = document.getElementById('dprBtn');
// 상수: 合計 소요 시간(ms) · 자동 루프의 停止 구간(ms)
var TOTAL_MS = 2600;
var AUTO_PAUSE_AT = [0.28, 0.62];
var AUTO_PAUSE_MS = 420;
var paused = false;
var startedAt = performance.now();
var pausedElapsed = 0; // 정지되기 전까지 이미 쌓인 경과 값
var raf = null;
var autoIdx = 0;
// 계산 겹: 경과 시간 = 停止 전 누적값(pausedElapsed) + (지금 - 再開 시각). 재개할 때 0으로 안 끊긴다
function elapsed() {
if (paused) return pausedElapsed;
return pausedElapsed + (performance.now() - startedAt);
}
function paint() {
var e = Math.min(TOTAL_MS, elapsed());
var p = e / TOTAL_MS;
fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';
pctEl.textContent = Math.round(p * 100) + '%';
state.textContent = paused ? '停止' : (p >= 1 ? '完了' : '進行中');
root.classList.toggle('dpr--paused', paused && p < 1);
return p;
}
function setPaused(v) {
if (v === paused) return;
if (v) {
pausedElapsed = elapsed();
paused = true;
} else {
startedAt = performance.now();
paused = false;
}
}
function tick() {
var p = paint();
if (!paused && p < 1 && autoIdx < AUTO_PAUSE_AT.length && p >= AUTO_PAUSE_AT[autoIdx]) {
setPaused(true);
autoIdx++;
setTimeout(function () { if (root.classList.contains('is-demo')) setPaused(false); }, AUTO_PAUSE_MS);
}
if (p >= 1) {
setTimeout(function () {
pausedElapsed = 0; startedAt = performance.now(); paused = false; autoIdx = 0; paint();
}, 700);
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
setPaused(!paused);
btn.textContent = paused ? '再開' : '一時停止';
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerPauseResume.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerPauseResume() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const state = root.querySelector(`.${styles.state}`) as HTMLElement | null;
const pctEl = root.querySelector(`.${styles.pct}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!state || !pctEl || !fill || !btn) return;
// 상수: 전체 소요 시간(ms) · 자동 루프의 정지 구간(ms)
const TOTAL_MS = 2600;
const AUTO_PAUSE_AT = [0.28, 0.62];
const AUTO_PAUSE_MS = 420;
let paused = false;
let startedAt = performance.now();
let pausedElapsed = 0;
let raf = 0;
let autoIdx = 0;
let stopped = false;
// 계산 겹: 경과 시간 = 정지 전 누적값(pausedElapsed) + (지금 - 재개 시각). 재개할 때 0으로 안 끊긴다
function elapsed() {
if (paused) return pausedElapsed;
return pausedElapsed + (performance.now() - startedAt);
}
function paint() {
const e = Math.min(TOTAL_MS, elapsed());
const p = e / TOTAL_MS;
fill!.style.transform = "scaleX(" + p.toFixed(4) + ")";
pctEl!.textContent = Math.round(p * 100) + "%";
state!.textContent = paused ? "정지" : p >= 1 ? "완료" : "진행";
root!.classList.toggle(styles["dpr--paused"], paused && p < 1);
return p;
}
function setPaused(v: boolean) {
if (v === paused) return;
if (v) {
pausedElapsed = elapsed();
paused = true;
} else {
startedAt = performance.now();
paused = false;
}
}
function tick() {
if (stopped) return;
const p = paint();
if (!paused && p < 1 && autoIdx < AUTO_PAUSE_AT.length && p >= AUTO_PAUSE_AT[autoIdx]) {
setPaused(true);
autoIdx++;
window.setTimeout(() => { if (root!.classList.contains(styles["is-demo"])) setPaused(false); }, AUTO_PAUSE_MS);
}
if (p >= 1) {
window.setTimeout(() => {
pausedElapsed = 0; startedAt = performance.now(); paused = false; autoIdx = 0; paint();
}, 700);
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
function onClick() {
root!.classList.remove(styles["is-demo"]);
setPaused(!paused);
btn!.textContent = paused ? "재개" : "일시정지";
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dpr} ${styles["is-demo"]}`}>
<div className={styles.head}>
<span className={styles.state}>진행</span>
<span className={styles.pct}>0%</span>
</div>
<div className={styles.track}><div className={styles.fill} /></div>
<button className={styles.btn} type="button">일시정지</button>
</div>
);
}03 · 一時停止・再開の状態値切替 — 状態値が一時停止と進行の間を切り替わるたびに、値の変化が止まりまた続く方式 — 計測 累積面積 1.799% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 経過時間 = 一時停止前の累積値(pausedElapsed) + (現在 − 再開時刻) — 再開しても0に途切れない
function elapsed() {
if (paused) return pausedElapsed;
return pausedElapsed + (performance.now() - startedAt);
}
function paint() {
var e = Math.min(TOTAL_MS, elapsed());
var p = e / TOTAL_MS;
fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';
pctEl.textContent = Math.round(p * 100) + '%';
state.textContent = paused ? '停止' : (p >= 1 ? '完了' : '進行中');
root.classList.toggle('dpr--paused', paused && p < 1);
return p;
}
数値が示すこと
23フレームの計測で、項目03はフレーム面積のわずか1.80%・強度スコア151.8・変化フレーム18という、9つの中で最も小さい形——1本のバーと短い状態文言だけです。
04待機列の順序値の並び替え — まだ開始していない項目の順序値がユーザーが決めた順番に変わる方式
4つの.dqr__item行は共有のorder配列を持ち、毎フレーム目標の行位置まで一気に飛ぶのではなく残り距離のLERP(0.18)分だけ近づきます。台本化されたautoSeqが900msごとに2つの待機項目を自動で入れ替える一方、本物のpointerdown・pointermove・pointerupの並びによって来場者が行を実際にドラッグして順序を変えることもできます。

<main class="stage">
<div class="dqr is-demo" id="dqr">
<div class="dqr__item" data-order="0" id="dqrItem0">待機 A</div>
<div class="dqr__item" data-order="1" id="dqrItem1">待機 B</div>
<div class="dqr__item" data-order="2" id="dqrItem2">待機 C</div>
<div class="dqr__item" data-order="3" id="dqrItem3">待機 D</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 순서 값 → 목표 위치, 실제 위치는 매 프레임 남은 거리의 18%씩 다가가는 보간(lerp) — index.html tickLerp() 참조
@include stage($stage-ink);
.dqr {
position: relative;
width: 100%;
max-width: 220px;
height: 160px; // 4 × ROW_H(40px)
}
.dqr__item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 32px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 $sp-3;
border-radius: $r-sm;
background: $subject-blue-light;
color: $stage-ink;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: grab;
touch-action: none;
user-select: none;
will-change: transform;
}
.dqr__item--drag {
cursor: grabbing;
background: $subject-cream;
box-shadow: $shadow-raised;
z-index: 2;
}
@media (max-width: 340px) {
.dqr { max-width: 190px; }
.dqr__item { height: 30px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.dqr__item { 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);
}
.dqr {
position: relative;
width: 100%;
max-width: 220px;
height: 160px;
}
.dqr__item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 32px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 12px;
border-radius: 8px;
background: #5b8cf8;
color: #17141a;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: grab;
touch-action: none;
user-select: none;
will-change: transform;
}
.dqr__item--drag {
cursor: grabbing;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 2;
}
@media (max-width: 340px) {
.dqr {
max-width: 190px;
}
.dqr__item {
height: 30px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dqr__item {
transition: none;
}
}(function () {
var root = document.getElementById('dqr');
var items = [document.getElementById('dqrItem0'), document.getElementById('dqrItem1'), document.getElementById('dqrItem2'), document.getElementById('dqrItem3')];
// 상수: 항목 높이(px, gap 포함) · 순서→위치 보간 속도(1프레임당 남은 거리의 비율)
var ROW_H = 40;
var LERP = 0.18;
var AUTO_SWAP_MS = 900;
var order = [0, 1, 2, 3]; // order[i] = i번째 칸에 있는 항목 index
var y = [0, 1, 2, 3].map(function (i) { return i * ROW_H; }); // 각 항목의 실제(보간 중) y
var dragging = -1;
var dragStartY = 0, dragStartTop = 0;
function targetY(itemIdx) {
var slot = order.indexOf(itemIdx);
return slot * ROW_H;
}
// 계산 겹: 순서 값이 바뀌면 목표 위치로 즉시 점프하지 않고 매 프레임 남은 거리의 LERP 비율만큼 다가간다
function tickLerp() {
for (var i = 0; i < items.length; i++) {
if (i === dragging) continue;
var t = targetY(i);
y[i] += (t - y[i]) * LERP;
items[i].style.transform = 'translateY(' + y[i].toFixed(2) + 'px)';
}
requestAnimationFrame(tickLerp);
}
requestAnimationFrame(tickLerp);
function swap(a, b) {
var sa = order.indexOf(a), sb = order.indexOf(b);
order[sa] = b; order[sb] = a;
}
// 자동 루프: 대기 중인 항목의 순서 값을 주기적으로 맞바꾼다
var autoSeq = [[0, 2], [1, 3], [0, 1], [2, 3], [0, 3]];
var autoI = 0;
function autoStep() {
if (root.classList.contains('is-demo')) {
var pair = autoSeq[autoI % autoSeq.length];
swap(pair[0], pair[1]);
autoI++;
}
setTimeout(autoStep, AUTO_SWAP_MS);
}
setTimeout(autoStep, AUTO_SWAP_MS);
// 실사용자 드래그: pointerdown/move/up 으로 순서 값을 직접 바꾼다
items.forEach(function (el, idx) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = idx;
dragStartY = e.clientY;
dragStartTop = y[idx];
el.setPointerCapture(e.pointerId);
el.classList.add('dqr__item--drag');
});
el.addEventListener('pointermove', function (e) {
if (dragging !== idx) return;
var ny = dragStartTop + (e.clientY - dragStartY);
y[idx] = ny;
el.style.transform = 'translateY(' + ny.toFixed(2) + 'px)';
var slot = Math.max(0, Math.min(items.length - 1, Math.round(ny / ROW_H)));
var curSlot = order.indexOf(idx);
if (slot !== curSlot) {
var other = order[slot];
order[curSlot] = other;
order[slot] = idx;
}
});
function end(e) {
if (dragging !== idx) return;
dragging = -1;
el.classList.remove('dqr__item--drag');
}
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerQueueReorder.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerQueueReorder() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const items = [0, 1, 2, 3].map((i) => root.querySelector(`.${styles["item" + i]}`) as HTMLElement | null);
if (items.some((el) => !el)) return;
// 상수: 항목 높이(px, gap 포함) · 순서→위치 보간 속도(1프레임당 남은 거리의 비율)
const ROW_H = 40;
const LERP = 0.18;
const AUTO_SWAP_MS = 900;
const order = [0, 1, 2, 3];
const y = [0, 1, 2, 3].map((i) => i * ROW_H);
let dragging = -1;
let dragStartY = 0, dragStartTop = 0;
let stopped = false;
let rafId = 0;
let autoTimer = 0;
function targetY(itemIdx: number) {
const slot = order.indexOf(itemIdx);
return slot * ROW_H;
}
// 계산 겹: 순서 값이 바뀌면 목표 위치로 즉시 점프하지 않고 매 프레임 남은 거리의 LERP 비율만큼 다가간다
function tickLerp() {
if (stopped) return;
for (let i = 0; i < items.length; i++) {
if (i === dragging) continue;
const t = targetY(i);
y[i] += (t - y[i]) * LERP;
items[i]!.style.transform = "translateY(" + y[i].toFixed(2) + "px)";
}
rafId = requestAnimationFrame(tickLerp);
}
rafId = requestAnimationFrame(tickLerp);
function swap(a: number, b: number) {
const sa = order.indexOf(a), sb = order.indexOf(b);
order[sa] = b; order[sb] = a;
}
const autoSeq = [[0, 2], [1, 3], [0, 1], [2, 3], [0, 3]];
let autoI = 0;
function autoStep() {
if (stopped) return;
if (root!.classList.contains(styles["is-demo"])) {
const pair = autoSeq[autoI % autoSeq.length];
swap(pair[0], pair[1]);
autoI++;
}
autoTimer = window.setTimeout(autoStep, AUTO_SWAP_MS);
}
autoTimer = window.setTimeout(autoStep, AUTO_SWAP_MS);
const cleanups: Array<() => void> = [];
items.forEach((el, idx) => {
function onDown(e: PointerEvent) {
root!.classList.remove(styles["is-demo"]);
dragging = idx;
dragStartY = e.clientY;
dragStartTop = y[idx];
el!.setPointerCapture(e.pointerId);
el!.classList.add(styles["item--drag"]);
}
function onMove(e: PointerEvent) {
if (dragging !== idx) return;
const ny = dragStartTop + (e.clientY - dragStartY);
y[idx] = ny;
el!.style.transform = "translateY(" + ny.toFixed(2) + "px)";
const slot = Math.max(0, Math.min(items.length - 1, Math.round(ny / ROW_H)));
const curSlot = order.indexOf(idx);
if (slot !== curSlot) {
const other = order[slot];
order[curSlot] = other;
order[slot] = idx;
}
}
function onEnd() {
if (dragging !== idx) return;
dragging = -1;
el!.classList.remove(styles["item--drag"]);
}
el!.addEventListener("pointerdown", onDown);
el!.addEventListener("pointermove", onMove);
el!.addEventListener("pointerup", onEnd);
el!.addEventListener("pointercancel", onEnd);
cleanups.push(() => {
el!.removeEventListener("pointerdown", onDown);
el!.removeEventListener("pointermove", onMove);
el!.removeEventListener("pointerup", onEnd);
el!.removeEventListener("pointercancel", onEnd);
});
});
return () => {
stopped = true;
cancelAnimationFrame(rafId);
window.clearTimeout(autoTimer);
cleanups.forEach((fn) => fn());
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dqr} ${styles["is-demo"]}`}>
<div className={`${styles.item} ${styles.item0}`}>대기 A</div>
<div className={`${styles.item} ${styles.item1}`}>대기 B</div>
<div className={`${styles.item} ${styles.item2}`}>대기 C</div>
<div className={`${styles.item} ${styles.item3}`}>대기 D</div>
</div>
);
}04 · 待機列の順序値の並び替え — まだ開始していない項目の順序値がユーザーが決めた順番に変わる方式 — 計測 累積面積 20.906% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 順序の値が変わっても目標位置へ即ジャンプせず、毎フレーム残り距離のLERP比率だけ近づく
function tickLerp() {
for (var i = 0; i < items.length; i++) {
if (i === dragging) continue;
var t = targetY(i);
y[i] += (t - y[i]) * LERP;
items[i].style.transform = 'translateY(' + y[i].toFixed(2) + 'px)';
}
requestAnimationFrame(tickLerp);
}
requestAnimationFrame(tickLerp);
数値が示すこと
23フレームの計測で、項目04はフレーム面積の20.91%・強度スコア144.1・変化フレーム14という9つの中で最大の値です——4行が一斉に並び替わることは、単一のバーより遥かに広い面積を動かします。
05解凍進捗値と形状値の同時変化 — 解凍の進捗値が満ちていく間、形状の値も一緒に変わる方式
解凍の進捗値がrequestAnimationFrameのループの中で0から1へ満ちていく間、同じ進捗値がlerpPoints()を動かし、8つのBOXの頂点座標をそれぞれ対応するOPENの座標へ線形補間します。多角形は点ごとに形を変えていくのであって、2つの形を重ねてフェードしているのではありません。

<main class="stage">
<div class="dem is-demo" id="dem">
<svg class="dem__shape" viewBox="0 0 100 100">
<polygon class="dem__poly" id="demPoly" points=""></polygon>
</svg>
<div class="dem__row">
<span class="dem__label">解凍中</span>
<span class="dem__pct" id="demPct">0%</span>
</div>
<div class="dem__track"><div class="dem__fill" id="demFill"></div></div>
<button class="dem__btn" id="demBtn" type="button">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 진행 값으로 8점 좌표 선형보간(lerp) — index.html lerpPoints() 참조. 크로스페이드 아님
@include stage($stage-paper);
.dem {
width: 100%;
max-width: 200px;
display: grid;
justify-items: center;
gap: $sp-2;
}
.dem__shape {
width: 120px;
height: 120px;
}
.dem__poly {
fill: $subject-lilac;
stroke: $subject-blue-deep;
stroke-width: 1.5;
stroke-linejoin: round;
}
.dem__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.dem__pct { font-variant-numeric: tabular-nums; }
.dem__track {
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: $subject-cream;
overflow: hidden;
}
.dem__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-blue-deep;
}
.dem__btn {
margin-top: $sp-1;
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $subject-cream;
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) {
.dem { max-width: 170px; }
.dem__shape { width: 96px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) {
.dem__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);
}
.dem {
width: 100%;
max-width: 200px;
display: grid;
justify-items: center;
gap: 8px;
}
.dem__shape {
width: 120px;
height: 120px;
}
.dem__poly {
fill: #b9a0ff;
stroke: #0a4fe8;
stroke-width: 1.5;
stroke-linejoin: round;
}
.dem__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dem__pct {
font-variant-numeric: tabular-nums;
}
.dem__track {
width: 100%;
height: 8px;
border-radius: 999px;
background: #fff7e6;
overflow: hidden;
}
.dem__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #0a4fe8;
}
.dem__btn {
margin-top: 4px;
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.dem__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dem {
max-width: 170px;
}
.dem__shape {
width: 96px;
height: 96px;
}
}
@media (prefers-reduced-motion: reduce) {
.dem__btn {
transition: none;
}
}(function () {
var poly = document.getElementById('demPoly');
var pctEl = document.getElementById('demPct');
var fill = document.getElementById('demFill');
// 상수: 시작 형태(닫힌 상자, 8점) · 끝 형태(펼친 도형, 같은 8점 순서) — index.html
var BOX = [[30, 25], [70, 25], [70, 40], [70, 55], [70, 75], [30, 75], [30, 55], [30, 40]];
var OPEN = [[15, 30], [85, 20], [78, 45], [88, 60], [72, 82], [28, 88], [18, 58], [10, 42]];
var DUR_MS = 2400;
var start = performance.now();
var pausedAt = 0;
var paused = false;
var btn = document.getElementById('demBtn');
// 계산 겹: 進行中 값(0~1)으로 8개 점 좌표를 각각 선형보간(lerp) — 두 도형을 겹쳐 페이드하지 않는다
function lerpPoints(t) {
var out = [];
for (var i = 0; i < BOX.length; i++) {
var x = BOX[i][0] + (OPEN[i][0] - BOX[i][0]) * t;
var y = BOX[i][1] + (OPEN[i][1] - BOX[i][1]) * t;
out.push(x.toFixed(1) + ',' + y.toFixed(1));
}
return out.join(' ');
}
function paint(t) {
poly.setAttribute('points', lerpPoints(t));
fill.style.transform = 'scaleX(' + t.toFixed(3) + ')';
pctEl.textContent = Math.round(t * 100) + '%';
}
function frame(now) {
if (!paused) {
var e = (now - start) % DUR_MS;
pausedAt = e / DUR_MS;
paint(pausedAt);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
btn.addEventListener('click', function () {
document.getElementById('dem').classList.remove('is-demo');
paused = !paused;
btn.textContent = paused ? '再開' : '一時停止';
if (!paused) start = performance.now() - pausedAt * DUR_MS;
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerExtractMorph.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerExtractMorph() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const poly = root.querySelector(`.${styles.poly}`) as SVGPolygonElement | null;
const pctEl = root.querySelector(`.${styles.pct}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!poly || !pctEl || !fill || !btn) return;
// 상수: 시작 형태(닫힌 상자, 8점) · 끝 형태(펼친 도형, 같은 8점 순서)
const BOX: [number, number][] = [[30, 25], [70, 25], [70, 40], [70, 55], [70, 75], [30, 75], [30, 55], [30, 40]];
const OPEN: [number, number][] = [[15, 30], [85, 20], [78, 45], [88, 60], [72, 82], [28, 88], [18, 58], [10, 42]];
const DUR_MS = 2400;
let start = performance.now();
let pausedAt = 0;
let paused = false;
let stopped = false;
let rafId = 0;
// 계산 겹: 진행 값(0~1)으로 8개 점 좌표를 각각 선형보간(lerp) — 두 도형을 겹쳐 페이드하지 않는다
function lerpPoints(t: number) {
const out: string[] = [];
for (let i = 0; i < BOX.length; i++) {
const x = BOX[i][0] + (OPEN[i][0] - BOX[i][0]) * t;
const y = BOX[i][1] + (OPEN[i][1] - BOX[i][1]) * t;
out.push(x.toFixed(1) + "," + y.toFixed(1));
}
return out.join(" ");
}
function paint(t: number) {
poly!.setAttribute("points", lerpPoints(t));
fill!.style.transform = "scaleX(" + t.toFixed(3) + ")";
pctEl!.textContent = Math.round(t * 100) + "%";
}
function frame(now: number) {
if (stopped) return;
if (!paused) {
const e = (now - start) % DUR_MS;
pausedAt = e / DUR_MS;
paint(pausedAt);
}
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
function onClick() {
root!.classList.remove(styles["is-demo"]);
paused = !paused;
btn!.textContent = paused ? "재개" : "일시정지";
if (!paused) start = performance.now() - pausedAt * DUR_MS;
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dem} ${styles["is-demo"]}`}>
<svg className={styles.shape} viewBox="0 0 100 100">
<polygon className={styles.poly} points="" />
</svg>
<div className={styles.row}>
<span className={styles.label}>압축 해제</span>
<span className={styles.pct}>0%</span>
</div>
<div className={styles.track}><div className={styles.fill} /></div>
<button className={styles.btn} type="button">일시정지</button>
</div>
);
}05 · 解凍進捗値と形状値の同時変化 — 解凍の進捗値が満ちていく間、形状の値も一緒に変わる方式 — 計測 累積面積 3.347% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 進捗値(0〜1)で8つの点座標をそれぞれ線形補間(lerp) — 2つの形をクロスフェードするのではない
function lerpPoints(t) {
var out = [];
for (var i = 0; i < BOX.length; i++) {
var x = BOX[i][0] + (OPEN[i][0] - BOX[i][0]) * t;
var y = BOX[i][1] + (OPEN[i][1] - BOX[i][1]) * t;
out.push(x.toFixed(1) + ',' + y.toFixed(1));
}
return out.join(' ');
}
function paint(t) {
poly.setAttribute('points', lerpPoints(t));
fill.style.transform = 'scaleX(' + t.toFixed(3) + ')';
pctEl.textContent = Math.round(t * 100) + '%';
}
数値が示すこと
23フレームの計測で、項目05はフレーム面積の3.35%・強度スコア105.4・23フレーム中23フレーム全てで変化しており、9つの中で唯一、計測した全フレームで値が変わり続けています。
06破損時の再試行回数値 — 検証に失敗するたびに再試行回数の値が1つ増え、進捗値が最初に戻る方式
5つの要素を持つVERIFY_SEED配列が各回の「合否」を決め、最初の2回は必ず失敗するので塗りが0に戻りretryが1つずつ増え、次の待機時間はBASE_BACKOFF_MSにBACKOFF_MULTを再試行回数でべき乗した値になります——固定の待ち時間ではなく指数バックオフです。

<main class="stage">
<div class="dcr is-demo" id="dcr">
<div class="dcr__head">
<span class="dcr__state" id="dcrState">ダウンロード中</span>
<span class="dcr__retry" id="dcrRetry">再試行 0</span>
</div>
<div class="dcr__track"><div class="dcr__fill" id="dcrFill"></div></div>
<button class="dcr__btn" id="dcrBtn" type="button">今すぐ再試行</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 재시도 대기 = 기저값 × 배수^재시도횟수(지수 백오프) — index.html backoffMs() 참조
@include stage($stage-yellow);
.dcr {
width: 100%;
max-width: 230px;
display: grid;
gap: $sp-3;
}
.dcr__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.dcr--bad .dcr__state { color: $error; }
.dcr--ok .dcr__state { color: $subject-ink; }
.dcr__retry { font-variant-numeric: tabular-nums; color: rgba($subject-ink, .68); }
.dcr__track {
width: 100%;
height: $sp-3;
border-radius: $r-pill;
background: rgba($subject-ink, .14);
overflow: hidden;
}
.dcr__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-ink;
transition: transform $dur-instant linear;
}
.dcr--bad .dcr__fill { background: $error; }
.dcr--ok .dcr__fill { background: $subject-mint; }
.dcr__btn {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $subject-cream;
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) {
.dcr { max-width: 195px; }
}
@media (prefers-reduced-motion: reduce) {
.dcr__fill, .dcr__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);
}
.dcr {
width: 100%;
max-width: 230px;
display: grid;
gap: 12px;
}
.dcr__head {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dcr--bad .dcr__state {
color: #c2361a;
}
.dcr--ok .dcr__state {
color: #17141a;
}
.dcr__retry {
font-variant-numeric: tabular-nums;
color: rgba(23, 20, 26, 0.68);
}
.dcr__track {
width: 100%;
height: 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
overflow: hidden;
}
.dcr__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #17141a;
transition: transform 140ms linear;
}
.dcr--bad .dcr__fill {
background: #c2361a;
}
.dcr--ok .dcr__fill {
background: #4cd4a6;
}
.dcr__btn {
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.dcr__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dcr {
max-width: 195px;
}
}
@media (prefers-reduced-motion: reduce) {
.dcr__fill, .dcr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('dcr');
var state = document.getElementById('dcrState');
var retryEl = document.getElementById('dcrRetry');
var fill = document.getElementById('dcrFill');
var btn = document.getElementById('dcrBtn');
// 시드 고정: 회차별 검증 결과(참=검증 통과). 0·1회차는 破損, 2회차부터 통과
var VERIFY_SEED = [false, false, true, true, true];
// 상수: 틱 간격 · 기저 대기(ms) · 지수 배수 — 재시도할 때마다 대기 시간이 늘어난다
var TICK_MS = 90;
var BASE_BACKOFF_MS = 260;
var BACKOFF_MULT = 2;
var MAX_PCT = 100;
var pct = 0;
var retry = 0;
var timer = null;
var btnDisabled = false;
// 계산 겹: 再試行 대기 = 기저값 × 배수^재시도횟수(지수 백오프) — 매 실패마다 대기가 길어진다
function backoffMs() {
return BASE_BACKOFF_MS * Math.pow(BACKOFF_MULT, Math.min(retry, 4));
}
function paint() {
fill.style.transform = 'scaleX(' + (pct / MAX_PCT).toFixed(3) + ')';
retryEl.textContent = '再試行 ' + retry;
}
function verify() {
var ok = VERIFY_SEED[retry % VERIFY_SEED.length];
if (ok) {
state.textContent = '完了';
root.classList.add('dcr--ok');
setTimeout(reset, 900);
} else {
state.textContent = '破損';
root.classList.add('dcr--bad');
retry++;
pct = 0;
paint();
var wait = backoffMs();
timer = setTimeout(function () {
root.classList.remove('dcr--bad');
state.textContent = 'ダウンロード中';
download();
}, wait);
}
}
function download() {
if (pct >= MAX_PCT) { verify(); return; }
pct = Math.min(MAX_PCT, pct + 9);
paint();
timer = setTimeout(download, TICK_MS);
}
function reset() {
root.classList.remove('dcr--ok', 'dcr--bad');
pct = 0; retry = 0; state.textContent = 'ダウンロード中';
paint();
download();
}
download();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
reset();
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerCorruptRetry.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerCorruptRetry() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const state = root.querySelector(`.${styles.state}`) as HTMLElement | null;
const retryEl = root.querySelector(`.${styles.retry}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!state || !retryEl || !fill || !btn) return;
// 시드 고정: 회차별 검증 결과(참=검증 통과). 0·1회차는 손상, 2회차부터 통과
const VERIFY_SEED = [false, false, true, true, true];
const TICK_MS = 90;
const BASE_BACKOFF_MS = 260;
const BACKOFF_MULT = 2;
const MAX_PCT = 100;
let pct = 0;
let retry = 0;
let timer = 0;
let stopped = false;
// 계산 겹: 재시도 대기 = 기저값 × 배수^재시도횟수(지수 백오프) — 매 실패마다 대기가 길어진다
function backoffMs() {
return BASE_BACKOFF_MS * Math.pow(BACKOFF_MULT, Math.min(retry, 4));
}
function paint() {
fill!.style.transform = "scaleX(" + (pct / MAX_PCT).toFixed(3) + ")";
retryEl!.textContent = "재시도 " + retry;
}
function verify() {
const ok = VERIFY_SEED[retry % VERIFY_SEED.length];
if (ok) {
state!.textContent = "완료";
root!.classList.add(styles["dcr--ok"]);
timer = window.setTimeout(reset, 900);
} else {
state!.textContent = "손상";
root!.classList.add(styles["dcr--bad"]);
retry++;
pct = 0;
paint();
const wait = backoffMs();
timer = window.setTimeout(() => {
root!.classList.remove(styles["dcr--bad"]);
state!.textContent = "받는 중";
download();
}, wait);
}
}
function download() {
if (stopped) return;
if (pct >= MAX_PCT) { verify(); return; }
pct = Math.min(MAX_PCT, pct + 9);
paint();
timer = window.setTimeout(download, TICK_MS);
}
function reset() {
root!.classList.remove(styles["dcr--ok"], styles["dcr--bad"]);
pct = 0; retry = 0; state!.textContent = "받는 중";
paint();
download();
}
download();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
reset();
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dcr} ${styles["is-demo"]}`}>
<div className={styles.head}>
<span className={styles.state}>받는 중</span>
<span className={styles.retry}>재시도 0</span>
</div>
<div className={styles.track}><div className={styles.fill} /></div>
<button className={styles.btn} type="button">지금 재시도</button>
</div>
);
}06 · 破損時の再試行回数値 — 検証に失敗するたびに再試行回数の値が1つ増え、進捗値が最初に戻る方式 — 計測 累積面積 2.188% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 再試行の待機 = 基準値 × 倍率^再試行回数(指数バックオフ) — 失敗するたびに待機が長くなる
function backoffMs() {
return BASE_BACKOFF_MS * Math.pow(BACKOFF_MULT, Math.min(retry, 4));
}
function paint() {
fill.style.transform = 'scaleX(' + (pct / MAX_PCT).toFixed(3) + ')';
retryEl.textContent = '再試行 ' + retry;
}
数値が示すこと
23フレームの計測で、項目06はフレーム面積の2.19%・強度スコア158.7・変化フレーム16という9つの中で最も鋭い強度です——塗りが0に戻り状態文言の色が切り替わることが、形自体は小さくても急激で高コントラストな変化として計測されています。
07完了値のひらめき表示 — ダウンロードが終わる瞬間に完了値が1に変わり、一定時間後に0へ戻って消える方式
サイクルが表示区間に入った瞬間、斜めにゆがんだ.dcf__sweepの光がSWEEP_COUNT回までバッジの上を走り、各回の不透明度はDECAYをその回数でべき乗した値になるので、1回目は明るく3回目にはほぼ消えています——線形の減衰ではなく指数の減衰です。

<main class="stage">
<div class="dcf is-demo" id="dcf">
<div class="dcf__badge" id="dcfBadge">
<span class="dcf__sweep" id="dcfSweep"></span>
<span class="dcf__text">完了</span>
</div>
<button class="dcf__btn" id="dcfBtn" type="button">もう一度見る</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 광택 sweep 세기 = 초기값 × 0.55^회차(지수 감쇠) — index.html sweepOpacity() 참조
@include stage($stage-ink);
.dcf {
width: 100%;
max-width: 220px;
display: grid;
justify-items: center;
gap: $sp-4;
}
.dcf__badge {
position: relative;
overflow: hidden;
width: 140px;
height: 56px;
border-radius: $r-card;
background: $subject-mint;
display: grid;
place-items: center;
opacity: 0;
transform: scale(.7);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.dcf--on .dcf__badge {
opacity: 1;
transform: scale(1);
}
.dcf__sweep {
position: absolute;
top: -20%;
left: 0;
width: 40%;
height: 140%;
background: linear-gradient(90deg, rgba($subject-cream, 0), rgba($subject-cream, .8), rgba($subject-cream, 0));
opacity: 0;
pointer-events: none;
}
.dcf__text {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.dcf__btn {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
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) {
.dcf { max-width: 180px; }
.dcf__badge { width: 120px; height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
.dcf__badge, .dcf__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);
}
.dcf {
width: 100%;
max-width: 220px;
display: grid;
justify-items: center;
gap: 16px;
}
.dcf__badge {
position: relative;
overflow: hidden;
width: 140px;
height: 56px;
border-radius: 16px;
background: #4cd4a6;
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);
}
.dcf--on .dcf__badge {
opacity: 1;
transform: scale(1);
}
.dcf__sweep {
position: absolute;
top: -20%;
left: 0;
width: 40%;
height: 140%;
background: linear-gradient(90deg, rgba(255, 247, 230, 0), rgba(255, 247, 230, 0.8), rgba(255, 247, 230, 0));
opacity: 0;
pointer-events: none;
}
.dcf__text {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dcf__btn {
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #fff7e6;
color: #17141a;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.dcf__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dcf {
max-width: 180px;
}
.dcf__badge {
width: 120px;
height: 48px;
}
}
@media (prefers-reduced-motion: reduce) {
.dcf__badge, .dcf__btn {
transition: none;
}
}(function () {
var root = document.getElementById('dcf');
var badge = document.getElementById('dcfBadge');
var sweep = document.getElementById('dcfSweep');
var btn = document.getElementById('dcfBtn');
// 상수: 完了 표시 유지 시간(ms) · 광택 sweep 1회 길이(ms) · 반복 횟수 · 감쇠율
var VISIBLE_MS = 1500;
var SWEEP_MS = 420;
var SWEEP_COUNT = 3;
var DECAY = 0.55; // 회차마다 강도(불투명도)가 이 비율로 줄어든다
var CYCLE_MS = 2400;
var start = performance.now();
var raf = null;
// 계산 겹: 광택 sweep 의 세기 = 초기값 × DECAY^회차(지수 감쇠) — 매 회차 옅어지다 사라진다
function sweepOpacity(passIndex) {
return Math.pow(DECAY, passIndex);
}
function frame(now) {
var e = (now - start) % CYCLE_MS;
var on = e < VISIBLE_MS;
root.classList.toggle('dcf--on', on);
if (on) {
var passLen = SWEEP_MS;
var passIdx = Math.floor(e / passLen);
var passT = (e % passLen) / passLen;
if (passIdx < SWEEP_COUNT) {
var x = -30 + passT * 160; // %
sweep.style.transform = 'translateX(' + x.toFixed(1) + '%) skewX(-18deg)';
sweep.style.opacity = sweepOpacity(passIdx).toFixed(3);
} else {
sweep.style.opacity = '0';
}
}
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
start = performance.now();
root.classList.add('dcf--on');
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerCompleteFlash.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerCompleteFlash() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const sweep = root.querySelector(`.${styles.sweep}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!sweep || !btn) return;
// 상수: 완료 표시 유지 시간(ms) · 광택 sweep 1회 길이(ms) · 반복 횟수 · 감쇠율
const VISIBLE_MS = 1500;
const SWEEP_MS = 420;
const SWEEP_COUNT = 3;
const DECAY = 0.55;
const CYCLE_MS = 2400;
let start = performance.now();
let rafId = 0;
let stopped = false;
// 계산 겹: 광택 sweep 의 세기 = 초기값 × DECAY^회차(지수 감쇠) — 매 회차 옅어지다 사라진다
function sweepOpacity(passIndex: number) {
return Math.pow(DECAY, passIndex);
}
function frame(now: number) {
if (stopped) return;
const e = (now - start) % CYCLE_MS;
const on = e < VISIBLE_MS;
root!.classList.toggle(styles["dcf--on"], on);
if (on) {
const passLen = SWEEP_MS;
const passIdx = Math.floor(e / passLen);
const passT = (e % passLen) / passLen;
if (passIdx < SWEEP_COUNT) {
const x = -30 + passT * 160;
sweep!.style.transform = "translateX(" + x.toFixed(1) + "%) skewX(-18deg)";
sweep!.style.opacity = sweepOpacity(passIdx).toFixed(3);
} else {
sweep!.style.opacity = "0";
}
}
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
function onClick() {
root!.classList.remove(styles["is-demo"]);
start = performance.now();
root!.classList.add(styles["dcf--on"]);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dcf} ${styles["is-demo"]}`}>
<div className={styles.badge}>
<span className={styles.sweep} />
<span className={styles.text}>완료</span>
</div>
<button className={styles.btn} type="button">다시 보기</button>
</div>
);
}07 · 完了値のひらめき表示 — ダウンロードが終わる瞬間に完了値が1に変わり、一定時間後に0へ戻って消える方式 — 計測 累積面積 5.561% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 光沢sweepの強さ = 初期値 × DECAY^回数(指数減衰) — 回を追うごとに薄れて消える
function sweepOpacity(passIndex) {
return Math.pow(DECAY, passIndex);
}
function frame(now) {
var e = (now - start) % CYCLE_MS;
var on = e < VISIBLE_MS;
root.classList.toggle('dcf--on', on);
if (on) {
var passLen = SWEEP_MS;
var passIdx = Math.floor(e / passLen);
var passT = (e % passLen) / passLen;
if (passIdx < SWEEP_COUNT) {
var x = -30 + passT * 160; // %
sweep.style.transform = 'translateX(' + x.toFixed(1) + '%) skewX(-18deg)';
sweep.style.opacity = sweepOpacity(passIdx).toFixed(3);
} else {
sweep.style.opacity = '0';
}
}
raf = requestAnimationFrame(frame);
}
数値が示すこと
23フレームの計測で、項目07はフレーム面積の5.56%・強度スコアわずか57.4・変化フレーム17という9つの中で最も柔らかい強度です——sweepは半透明の光であって、はっきりした境界の切り替えではありません。
08完了項目の位置値の移動 — ダウンロードが終わった項目の位置値が一覧の決まった領域へ移る方式
2枚の.dfr__itemカードはそれぞれ違う遅延で自分のリストを離れ、共有の.dfr__zoneSlotへ向かいますが、その道のり上の位置は質量-バネ方程式(ZETA 0.42、W 7.5)を毎フレーム積分して得たspring(u)の値から来ます——イーズするCSSトランジションではなく、わざとスロットを行き過ぎてから減衰して止まる値です。

<main class="stage">
<div class="dfr is-demo" id="dfr">
<div class="dfr__list">
<div class="dfr__item" id="dfrItem0">資料 1</div>
<div class="dfr__item" id="dfrItem1">資料 2</div>
</div>
<div class="dfr__zone">
<span class="dfr__zoneLabel">完了済み</span>
<div class="dfr__zoneSlot" id="dfrSlot"></div>
</div>
<button class="dfr__btn" id="dfrBtn" type="button">もう一度見る</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 위치 진행도를 질량-스프링 방정식으로 계산(오버슛 후 정착) — index.html spring() 참조
@include stage($stage-orange);
.dfr {
position: relative;
width: 100%;
max-width: 240px;
display: grid;
gap: $sp-3;
}
.dfr__list {
display: grid;
gap: $sp-2;
}
.dfr__item {
height: 32px;
border-radius: $r-sm;
background: $subject-ink;
color: $subject-cream;
display: flex;
align-items: center;
padding: 0 $sp-3;
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
will-change: transform, opacity;
}
.dfr__zone {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 2px dashed rgba($subject-ink, .35);
}
.dfr__zoneLabel {
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.dfr__zoneSlot {
width: 14px;
height: 14px;
border-radius: $r-xs;
background: $subject-mint;
}
.dfr__btn {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $subject-cream;
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) {
.dfr { max-width: 195px; }
.dfr__item { height: 28px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.dfr__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);
}
.dfr {
position: relative;
width: 100%;
max-width: 240px;
display: grid;
gap: 12px;
}
.dfr__list {
display: grid;
gap: 8px;
}
.dfr__item {
height: 32px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
display: flex;
align-items: center;
padding: 0 12px;
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
will-change: transform, opacity;
}
.dfr__zone {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
border: 2px dashed rgba(23, 20, 26, 0.35);
}
.dfr__zoneLabel {
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dfr__zoneSlot {
width: 14px;
height: 14px;
border-radius: 4px;
background: #4cd4a6;
}
.dfr__btn {
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.dfr__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.dfr {
max-width: 195px;
}
.dfr__item {
height: 28px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.dfr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('dfr');
var items = [document.getElementById('dfrItem0'), document.getElementById('dfrItem1')];
var slot = document.getElementById('dfrSlot');
var btn = document.getElementById('dfrBtn');
// 상수: 스프링 감쇠비(zeta) · 각진동수(w) · 항목별 시작 지연(ms) · 이동 소요(ms)
var ZETA = 0.42;
var W = 7.5;
var START_DELAY = [0, 1100];
var MOVE_MS = 850;
var CYCLE_MS = 2900;
// 계산 겹: 위치 진행도를 질량-스프링 방정식으로 매 프레임 계산(목표를 넘었다가 되돌아와 멈춘다) — CSS 트랜지션이 아니다
function spring(u) {
var wd = W * Math.sqrt(1 - ZETA * ZETA);
var e = Math.exp(-ZETA * W * u);
return 1 - e * (Math.cos(wd * u) + (ZETA * W / wd) * Math.sin(wd * u));
}
var cycleStart = performance.now();
function paint(el, delay) {
var t = performance.now() - cycleStart - delay;
if (t <= 0) {
el.style.transform = 'translate(0,0) scale(1)';
el.style.opacity = '1';
return;
}
var rItem = el.getBoundingClientRect();
var rSlot = slot.getBoundingClientRect();
var dx = rSlot.left + rSlot.width / 2 - (rItem.left + rItem.width / 2);
var dy = rSlot.top + rSlot.height / 2 - (rItem.top + rItem.height / 2);
var u = Math.min(t / MOVE_MS, 1) * 2.5;
var s = spring(u);
el.style.transform = 'translate(' + (dx * s).toFixed(1) + 'px,' + (dy * s).toFixed(1) + 'px) scale(' + (1 - 0.55 * Math.min(s, 1)).toFixed(3) + ')';
var fadeStart = MOVE_MS * 0.85;
el.style.opacity = t > fadeStart ? String(Math.max(0, 1 - (t - fadeStart) / (MOVE_MS * 0.3))) : '1';
}
function frame() {
var now = performance.now();
if (now - cycleStart > CYCLE_MS) {
cycleStart = now;
}
paint(items[0], START_DELAY[0]);
paint(items[1], START_DELAY[1]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
cycleStart = performance.now();
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerFolderReveal.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerFolderReveal() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const items = [0, 1].map((i) => root.querySelector(`.${styles["item" + i]}`) as HTMLElement | null);
const slot = root.querySelector(`.${styles.zoneSlot}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (items.some((el) => !el) || !slot || !btn) return;
// 상수: 스프링 감쇠비(zeta) · 각진동수(w) · 항목별 시작 지연(ms) · 이동 소요(ms)
const ZETA = 0.42;
const W = 7.5;
const START_DELAY = [0, 1100];
const MOVE_MS = 850;
const CYCLE_MS = 2900;
let stopped = false;
let rafId = 0;
let cycleStart = performance.now();
// 계산 겹: 위치 진행도를 질량-스프링 방정식으로 매 프레임 계산(목표를 넘었다가 되돌아와 멈춘다) — CSS 트랜지션이 아니다
function spring(u: number) {
const wd = W * Math.sqrt(1 - ZETA * ZETA);
const e = Math.exp(-ZETA * W * u);
return 1 - e * (Math.cos(wd * u) + (ZETA * W / wd) * Math.sin(wd * u));
}
function paint(el: HTMLElement, delay: number) {
const t = performance.now() - cycleStart - delay;
if (t <= 0) {
el.style.transform = "translate(0,0) scale(1)";
el.style.opacity = "1";
return;
}
const rItem = el.getBoundingClientRect();
const rSlot = slot!.getBoundingClientRect();
const dx = rSlot.left + rSlot.width / 2 - (rItem.left + rItem.width / 2);
const dy = rSlot.top + rSlot.height / 2 - (rItem.top + rItem.height / 2);
const u = Math.min(t / MOVE_MS, 1) * 2.5;
const s = spring(u);
el.style.transform = "translate(" + (dx * s).toFixed(1) + "px," + (dy * s).toFixed(1) + "px) scale(" + (1 - 0.55 * Math.min(s, 1)).toFixed(3) + ")";
const fadeStart = MOVE_MS * 0.85;
el.style.opacity = t > fadeStart ? String(Math.max(0, 1 - (t - fadeStart) / (MOVE_MS * 0.3))) : "1";
}
function frame() {
if (stopped) return;
const now = performance.now();
if (now - cycleStart > CYCLE_MS) {
cycleStart = now;
}
paint(items[0]!, START_DELAY[0]);
paint(items[1]!, START_DELAY[1]);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
function onClick() {
root!.classList.remove(styles["is-demo"]);
cycleStart = performance.now();
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dfr} ${styles["is-demo"]}`}>
<div className={styles.list}>
<div className={`${styles.item} ${styles.item0}`}>자료 1</div>
<div className={`${styles.item} ${styles.item1}`}>자료 2</div>
</div>
<div className={styles.zone}>
<span className={styles.zoneLabel}>완료됨</span>
<div className={styles.zoneSlot} />
</div>
<button className={styles.btn} type="button">다시 보기</button>
</div>
);
}08 · 完了項目の位置値の移動 — ダウンロードが終わった項目の位置値が一覧の決まった領域へ移る方式 — 計測 累積面積 12.302% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 定数: スプリング減衰比(zeta)・角振動数(w)・項目ごとの開始遅延(ms)・移動所要時間(ms)
var ZETA = 0.42;
var W = 7.5;
var START_DELAY = [0, 1100];
var MOVE_MS = 850;
var CYCLE_MS = 2900;
// 計算層: 位置の進捗を質量-バネ方程式で毎フレーム計算する(目標を行き過ぎてから戻って止まる) — CSSトランジションではない
function spring(u) {
var wd = W * Math.sqrt(1 - ZETA * ZETA);
var e = Math.exp(-ZETA * W * u);
return 1 - e * (Math.cos(wd * u) + (ZETA * W / wd) * Math.sin(wd * u));
}
数値が示すこと
23フレームの計測で、項目08はフレーム面積の12.30%・強度スコア137.8・変化フレームは23中10という9つの中で最も少ない変化フレーム数です——スプリングはサイクルが繰り返す前にしっかり静止するので、注目すべきはフレーム占有率ではなくオーバーシュートそのものです。
09モーション削減時の静的ダウンロード表示 — モーション削減設定でアニメーションの代わりに進捗値の文言だけが変わる方式
同じパーセントの値が2つの板をわざと違う方法で描きます——左は毎ティックscaleXのtransformを更新し、右の.drm__staticBoxは文字ノードの中の数字を差し替えるだけでtransformもレイアウトの変化も一切ありません。ボタンは.drm--force-reduceクラスを切り替えて左の板自身のトランジションを止め、OSレベルのモーション削減設定の代わりを務めます。

<main class="stage">
<div class="drm is-demo" id="drm">
<div class="drm__col">
<span class="drm__colLabel">通常モーション</span>
<div class="drm__track"><div class="drm__fill" id="drmFillA"></div></div>
<span class="drm__pct" id="drmPctA">0%</span>
</div>
<div class="drm__col drm__col--reduced">
<span class="drm__colLabel">モーション減</span>
<div class="drm__staticBox"><span class="drm__pct" id="drmPctB">0%</span></div>
</div>
<button class="drm__btn" id="drmBtn" type="button">モーション減に切替</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 진행 값 하나를 두 판이 다르게 그린다 — 왼쪽 transform 갱신 · 오른쪽 문구만 교체 — index.html paint() 참조
@include stage($stage-ink);
.drm {
width: 100%;
max-width: 260px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-3;
}
.drm__col {
display: grid;
justify-items: center;
gap: $sp-2;
}
.drm__colLabel {
font: 600 11px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($subject-cream, .8);
text-align: center;
}
.drm__track {
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
overflow: hidden;
}
.drm__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-blue-light;
transition: transform $dur-instant linear;
}
.drm--force-reduce .drm__fill { transition: none; }
.drm__pct {
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.drm__staticBox {
width: 100%;
min-height: $sp-2;
padding: $sp-1 0;
border-radius: $r-sm;
background: rgba($subject-cream, .16);
display: grid;
place-items: center;
// 의도적으로 막대·위치를 움직이지 않는다 — 모션 감소 판은 문구 교체만(둥근 칩, 진행 막대 아님)
}
.drm__btn {
grid-column: 1 / -1;
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-cream;
color: $stage-ink;
font: 700 11px/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) {
.drm { max-width: 210px; gap: $sp-2; }
.drm__colLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.drm__fill { transition: none; }
.drm__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);
}
.drm {
width: 100%;
max-width: 260px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.drm__col {
display: grid;
justify-items: center;
gap: 8px;
}
.drm__colLabel {
font: 600 11px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(255, 247, 230, 0.8);
text-align: center;
}
.drm__track {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
overflow: hidden;
}
.drm__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #5b8cf8;
transition: transform 140ms linear;
}
.drm--force-reduce .drm__fill {
transition: none;
}
.drm__pct {
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.drm__staticBox {
width: 100%;
min-height: 8px;
padding: 4px 0;
border-radius: 8px;
background: rgba(255, 247, 230, 0.16);
display: grid;
place-items: center;
}
.drm__btn {
grid-column: 1/-1;
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #fff7e6;
color: #17141a;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.drm__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.drm {
max-width: 210px;
gap: 8px;
}
.drm__colLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.drm__fill {
transition: none;
}
.drm__btn {
transition: none;
}
}(function () {
var root = document.getElementById('drm');
var fillA = document.getElementById('drmFillA');
var pctA = document.getElementById('drmPctA');
var pctB = document.getElementById('drmPctB');
var btn = document.getElementById('drmBtn');
// 상수: 進行中 속도(%/틱) · 틱 간격(ms)
var STEP = 4;
var TICK_MS = 130;
var forcedReduce = false;
var pct = 0;
// 계산 겹: 같은 進行中 값 하나를 두 판이 다르게 그린다 — 왼쪽은 매 틱 transform 갱신, 오른쪽은 문구만 교체(레이아웃·트랜지션 0)
function paint() {
fillA.style.transform = 'scaleX(' + (pct / 100).toFixed(3) + ')';
pctA.textContent = pct + '%';
pctB.textContent = pct + '%';
}
function tick() {
pct = (pct + STEP) % 101;
paint();
setTimeout(tick, TICK_MS);
}
tick();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
forcedReduce = !forcedReduce;
root.classList.toggle('drm--force-reduce', forcedReduce);
btn.textContent = forcedReduce ? '通常モーションに切替' : 'モーション減に切替';
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerReducedMotion.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerReducedMotion() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const fillA = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const pctA = root.querySelector(`.${styles.pctA}`) as HTMLElement | null;
const pctB = root.querySelector(`.${styles.pctB}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!fillA || !pctA || !pctB || !btn) return;
// 상수: 진행 속도(%/틱) · 틱 간격(ms)
const STEP = 4;
const TICK_MS = 130;
let forcedReduce = false;
let pct = 0;
let timer = 0;
let stopped = false;
// 계산 겹: 같은 진행 값 하나를 두 판이 다르게 그린다 — 왼쪽은 매 틱 transform 갱신, 오른쪽은 문구만 교체(레이아웃·트랜지션 0)
function paint() {
fillA!.style.transform = "scaleX(" + (pct / 100).toFixed(3) + ")";
pctA!.textContent = pct + "%";
pctB!.textContent = pct + "%";
}
function tick() {
if (stopped) return;
pct = (pct + STEP) % 101;
paint();
timer = window.setTimeout(tick, TICK_MS);
}
tick();
function onClick() {
root!.classList.remove(styles["is-demo"]);
forcedReduce = !forcedReduce;
root!.classList.toggle(styles["drm--force-reduce"], forcedReduce);
btn!.textContent = forcedReduce ? "일반 모션 전환" : "모션 감소 전환";
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.drm} ${styles["is-demo"]}`}>
<div className={styles.col}>
<span className={styles.colLabel}>일반 모션</span>
<div className={styles.track}><div className={styles.fill} /></div>
<span className={`${styles.pct} ${styles.pctA}`}>0%</span>
</div>
<div className={`${styles.col} ${styles["col--reduced"]}`}>
<span className={styles.colLabel}>모션 감소</span>
<div className={styles.staticBox}><span className={`${styles.pct} ${styles.pctB}`}>0%</span></div>
</div>
<button className={styles.btn} type="button">모션 감소 전환</button>
</div>
);
}09 · モーション削減時の静的ダウンロード表示 — モーション削減設定でアニメーションの代わりに進捗値の文言だけが変わる方式 — 計測 累積面積 0.643% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 定数: 進行速度(%/ティック)・ティック間隔(ms)
var STEP = 4;
var TICK_MS = 130;
var forcedReduce = false;
var pct = 0;
// 計算層: 同じ1つの進捗値を2つの板が別々に描く — 左は毎ティックtransformを更新し、右は文言だけを差し替える(レイアウト・トランジション0)
function paint() {
fillA.style.transform = 'scaleX(' + (pct / 100).toFixed(3) + ')';
pctA.textContent = pct + '%';
pctB.textContent = pct + '%';
}
function tick() {
pct = (pct + STEP) % 101;
paint();
setTimeout(tick, TICK_MS);
}
数値が示すこと
23フレームの計測で、項目09はフレーム面積のわずか0.64%・強度スコア131.3・変化フレーム18という9つの中で最小の面積で、フレーム占有率ではなく2つの板をクリックのたびに並べて比較するための最小限の値です。
値がパターンを変える基準 — 更新周期・再試行基準・色段階の基準
移動平均の窓を広げる。 項目02のNを5から10に動かすと、平均線は直近の急な変化に鋭く反応しなくなり、なめらかさと引き換えに、生サンプルの急変への反応が一拍遅れます。
指数バックオフの倍率を下げる。 項目06のBACKOFF_MULTを2から1.6に動かすと、倍率2では再試行のたびに待機時間が急激に膨らみ間の停止が目立ちますが、1.6に下げると待機は緩やかに伸びるだけなので再試行がより近い間隔で続きます。
スプリング減衰比を上げる。 項目08のZETAを0.42から0.65に動かすと、0.42ではカードが.dfr__zoneSlotを目に見えて行き過ぎてから戻ってきますが、0.65に上げるとそのオーバーシュートが小さくなり、1回目でより静かに、より目標に近い位置へ収まります。
これら9つのどれも単体では1バイトもサーバーと確認していません。アップロード プログレスバー ローダー9種 — 計算するUI(329)は1つのアップロード自身の進捗ローダーが単独で上がっていく計算軸を扱っており、ここの項目01のように複数のダウンロードが1つの重み付き合計を共有するものとは異なります。無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで(349)は一覧の下端で次の一群が読み込まれる計算軸を扱っており、この9つのどれかが100%に達するのとは違う引き金です。引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち(353)は引いた距離が更新を駆動する計算軸を扱っており、ここでは項目03自身の経過値の積算に最も近い兄弟です。この9つの計算はどれも、下のzipでcb2aueyyを打ち込んだ瞬間に開きます。
どこで壊れるか — オフライン復帰の瞬間・複数タブ同時ダウンロード・モーション削減
オフライン復帰の瞬間
9つのどれもnavigator.onLineを呼ばず、本物のonlineイベントも聞いていません。それどころか、そもそもネットワーク接続が切れるという状況自体をどれもモデル化していません——項目06のVERIFY_SEED配列は接続が戻ったかどうかではなく決まった順序で合否を決めるので、ここでの破損ファイルの再試行は接続の再接続ではなく整合性チェックの失敗の代役です。このデモは再試行が成功または失敗した後に何が起きるかは見せられますが、本物のネットワークが戻る瞬間そのものは見せられません——見せるべき本物のネットワークがそもそも存在しないからです。
複数タブ同時ダウンロード
同じ項目を別のタブで開くと、まったく独立したコピーが動き出します——9つのどれもlocalStorageやBroadcastChannel、その他タブ間で見える経路を読み書きしないため、項目01の3ファイルや項目06の再試行ループを2つのタブで同時に動かしても、互いの存在にはまったく気づきません。複数のタブでキューを共有する本物のダウンロードマネージャーには、まさにその共有チャンネルが必要で、それはここでは意図的に外されている部品です。
モーション削減
9つ全てが、それぞれ自身のSCSSにある1つの@media (prefers-reduced-motion: reduce)ブロックからモーション削減時の挙動を読み取っており、9つのどれもスクリプトの中でwindow.matchMedia()によって分岐してはいません——つまり計算そのものはOSの設定によってゲートされることは一度もなく、変わるのはtransitionの値だけです。どのブロックもボタンや塗り要素からtransitionを取り除くだけです——項目01の.dmf__fill/.dmf__btn、項目06の.dcr__fill/.dcr__btn、項目09の.drm__fill/.drm__btnなど——なので、requestAnimationFrameのループであれsetTimeoutの連鎖であれ、各値を動かしているJavaScript自体は前とまったく同じように毎回再計算され続けます。項目09だけがこれが実際に何を残すかを見せるために作られています——自身の.drm--force-reduceクラスをオンにすると、左の板の塗りは毎ティック受け取り続けるtransformの更新の間でトランジションしなくなり、もともとトランジションを持たない右の板はいつもと変わらないまま見えます。
3つのバリエーションとアクセシビリティ
| バリエーション | 変わる値 | 触れない値 |
|---|---|---|
| 移動平均の窓を広げる | N 5 → 10(項目02) |
ティックごとのSAMPLES配列そのもの |
| 指数バックオフの倍率を下げる | BACKOFF_MULT 2 → 1.6(項目06) |
VERIFY_SEEDの合否の並び |
| スプリング減衰比を上げる | ZETA 0.42 → 0.65(項目08) |
項目08自身のW(7.5) |
9つのどれもマークアップのどこにもaria-*属性もroleもtabindexも追加していません——この状態変化をスクリーンリーダーに届けるには、このコードを組み込む側が自分でライブリージョンを足す必要があります。ネイティブな部分としては、項目01から09までがそれぞれ本物の<button type="button">から発火しますが、項目04だけは例外で、ボタンを介さず行自体が直接pointerdown・pointermove・pointerupに反応します。9つのどれもdisabled属性をどの時点でも設定していません。状態変化にバンドルされたアイコン画像を差し替えている項目もありません——ここでの見た目の変化はすべて、クラス・transform・scaleX・または文字ノードを直接書き換えているだけです。
FAQ
項目04だけがクリックではなくpointerdown・pointermove・pointerupを必要とするのはなぜですか
待機列の並び替えは一瞬の操作ではなく連続したジェスチャーだからです——項目04はドラッグ中に行がどこにあるかを追い続けてorderを途中で更新する必要があり、これは1回のclickイベントでは表現できません。残り8つは押すたびに状態を1回切り替えるだけなのでclickリスナーで十分です。
この9つのパーセントは本物のファイルサイズや通信速度から来ていますか
いいえ——項目01のSIZES配列から項目02のSAMPLES配列まで、ここにある数値はすべてスクリプトに書かれた固定値です。fetch()を呼ぶことも、本物のContent-Lengthヘッダーを読むことも、実際の帯域を測ることもしていないので、各デモは学ぶための計算として見るべきで、信頼できる実際の転送としては見ないでください。
「ダウンロード マネージャーとは」何を指し、この9つはそれを再現していますか
いいえ、この9つは部品だけです——それぞれが加重合計・移動平均・スプリング・指数バックオフのどれか1つの計算だけに絞られており、共有キューや設定パネル、ファイル種別のアイコンをまとめ持つ本物のダウンロードマネージャー全体を組み立ててはいません。9つを1つのインターフェースに組み込むかどうかは別の設計判断で、zipの中身はそれを完全にあなた次第にしています。
出典: MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — transition









