다운로드 진행 상황 표시 9종 — 값이 퍼센트 단위로 갱신되는 아홉 가지 방식
다운로드 진행 상황 표시는 지금 받아지는 파일이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 여러 파일이 동시에 받아질 때 각 파일의 진행 값이 목록에서 나란히 오르고, 하나는 검증에 실패할 때마다 재시도 횟수 값이 늘며 진행 값이
아홉 항목 모두 자동 시연 루프가 도는 중에도 버튼(01·02·03·05·06·07·08·09) 또는 드래그(04, pointerdown·pointermove·pointerup) 같은 실사용자 입력에 반응해 값을 다시 계산하는 결정론적 계산입니다 — 01·02는 SIZES·SAMPLES 같은 고정 시드 배열을, 나머지는 결정적 상수만 쓸 뿐 Math.random 은 0이라 같은 순서로 누르면 항상 같은 값이 나옵니다. 화면 속 파일명·크기·전송 속도는 전부 가상 시뮬레이션이라 실제 fetch·File API·Blob 다운로드·navigator.onLine 호출은 하나도 없고, 특정 브랜드·소프트웨어·운영체제 탐색기 이름도 담지 않습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 다중 파일 진행 값 병렬 갱신 — 여러 파일이 동시에 받아질 때 각 파일의 진행 값이 목록 안에서 각각 갱신되는 방식
- 02 전송 속도 값 그래프 갱신 — 초당 전송량 값이 그래프 형태로 계속 다시 그려지는 방식
- 03 정지·재개 상태 값 전환 — 상태 값이 정지와 진행 사이를 오갈 때마다 값의 변화가 멈췄다 다시 이어지는 방식
- 04 대기열 순서 값 재배치 — 아직 시작하지 않은 항목들의 순서 값이 사용자가 정한 차례로 바뀌는 방식
- 05 압축 해제 진행 값과 형태 값 동시 변화 — 압축 해제 진행 값이 차오르는 동안 형태 값도 함께 바뀌는 방식
- 06 손상 재시도 횟수 값 — 검증에 실패하면 재시도 횟수 값이 하나씩 늘고 진행 값이 처음으로 되돌아가는 방식
- 07 완료 값 반짝 표시 — 다운로드가 끝나는 순간 완료 값이 1로 바뀌었다가 일정 시간 뒤 다시 0으로 사라지는 방식
- 08 완료 항목 위치 값 이동 — 다운로드가 끝난 항목의 위치 값이 목록의 정해진 영역으로 옮겨가는 방식
- 09 모션 감소 정적 다운로드 표시 — 모션 감소 설정에서 애니메이션 대신 진행 값의 문구만 그대로 바뀌는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
아홉 항목 모두 각 데모 화면의 버튼 클릭(04는 드래그) 이벤트로 값을 재설정·전환할 수 있고, 로드 즉시 도는 자동 시연 루프는 값을 미리 보여주는 보조 장치일 뿐 리스너 자체를 대신하지 않습니다.
| 번호 | 이름 | 리스너 | 누적면적% | 강도 | 움직인 프레임/23 |
|---|---|---|---|---|---|
| 01 | 다중 파일 진행 값 병렬 갱신 | click(일시정지 버튼) | 3.93 | 133.8 | 16 |
| 02 | 전송 속도 값 그래프 갱신 | click(일시정지 버튼) | 8.11 | 108.0 | 16 |
| 03 | 정지·재개 상태 값 전환 | click(일시정지 버튼) | 1.80 | 151.8 | 18 |
| 04 | 대기열 순서 값 재배치 | pointerdown·move·up(드래그) | 20.91 | 144.1 | 14 |
| 05 | 압축 해제 진행 값과 형태 값 동시 변화 | click(일시정지 버튼) | 3.35 | 105.4 | 23 |
| 06 | 손상 재시도 횟수 값 | click(지금 재시도 버튼) | 2.19 | 158.7 | 16 |
| 07 | 완료 값 반짝 표시 | click(다시 보기 버튼) | 5.56 | 57.4 | 17 |
| 08 | 완료 항목 위치 값 이동 | click(다시 보기 버튼) | 12.30 | 137.8 | 10 |
| 09 | 모션 감소 정적 다운로드 표시 | click(모션 감소 전환 버튼) | 0.64 | 131.3 | 18 |
09는 왼쪽 패널만 값을 움직이고 오른쪽 패널은 문구만 바꾸는 컴포넌트 편이라 면적이 작은 게 정상입니다 — 09는 면적이 아니라 두 판을 나란히 대조하는 정교함으로 재는 항목입니다. 아홉 항목이 값을 계산하는 방식을 겹치는 것끼리 묶으면 아래 여섯 무리로 정리됩니다.
값의 총합을 나누어 계산하는 값 — 01·02
01은 overall() 함수가 세 파일의 받은 바이트 합을 SIZES 세 값의 합으로 나눈 용량 가중 평균이라 큰 파일일수록 전체 진행 값에 더 크게 반영되고, 02는 movingAvg() 함수가 SAMPLES 원시 표본 중 최근 N개(5개)만 골라 그 구간의 평균을 매 표본마다 다시 나눕니다. 둘 다 나눗셈으로 값 하나를 요약하지만, 01은 정해진 세 칸을 한 번에 나누고 02는 흘러가는 표본 창을 계속 옮겨가며 나눈다는 점이 다릅니다.
멈췄다 이어지는 누적 경과시간 값 — 03
03의 elapsed() 함수는 정지 전까지 쌓인 값(pausedElapsed)에 재개 이후 흐른 시간만 더하는 방식이라, 일시정지 버튼을 눌러도 진행 값이 0으로 끊기지 않고 멈춘 자리에서 정확히 이어집니다. 자동 시연 루프는 AUTO_PAUSE_AT([0.28, 0.62]) 값에 닿을 때마다 스스로 정지·재개를 한 번씩 보여줍니다.
매 프레임 목표로 다가가는 보간 값 — 04·05
04의 tickLerp() 함수는 requestAnimationFrame 마다 목표 위치까지 남은 거리의 LERP(0.18) 비율만큼만 다가가 항목이 ROW_H(40px) 칸 사이를 미끄러지듯 움직이고, 05의 lerpPoints() 함수는 진행 값 t 하나로 여덟 개 점 좌표를 매번 직접 선형보간해 다각형을 다시 그립니다. 04는 목표가 바뀔 때마다 남은 거리를 반복해서 줄여가는 재귀적 접근이고, 05는 시작·끝 좌표 사이를 t 값 하나로 즉시 계산한다는 점이 다릅니다.
지수 함수로 늘거나 줄어드는 값 — 06·07
06의 backoffMs() 함수는 BASE_BACKOFF_MS(260ms) × BACKOFF_MULT(2)의 재시도 횟수 제곱으로 대기 값을 매 실패마다 두 배씩 불리고, 07의 sweepOpacity() 함수는 DECAY(0.55)의 회차 제곱으로 광택 세기를 매 회차 절반 가까이 줄입니다. 하나는 값이 지수로 커지는 쪽이고 하나는 지수로 작아지는 쪽이라 방향이 반대입니다.
스프링 방정식으로 오버슈트하는 값 — 08
08의 spring() 함수는 ZETA(0.42)·W(7.5) 두 상수로 질량-스프링 방정식을 매 프레임 계산해, 항목의 위치 값이 보관함 자리를 살짝 지나쳤다가 되돌아와 멈추는 오버슈트를 만듭니다. transition 으로 흉내 낸 감쇠가 아니라 코사인·지수 항이 섞인 값을 직접 계산해 그리는 방식입니다.
같은 값을 두 표현으로 나란히 보여주는 값 — 09
09는 STEP(4)씩 오르는 같은 pct 값 하나를 두 패널이 다르게 그립니다 — 왼쪽은 매 틱 transform 을 갱신해 막대가 실제로 움직이고, 오른쪽은 애초에 transform 을 쓰지 않고 문구(pctB)만 교체하도록 짜여 있어 값은 같아도 눈에 보이는 변화가 다릅니다.
패턴별 코드
아래 아홉 조각은 각 항목의 index.html 안 계산부를 그대로 옮긴 것으로, 자리표시자 하나 없이 지금 데모에서 실제로 도는 코드입니다.
01다중 파일 진행 값 병렬 갱신 — 여러 파일이 동시에 받아질 때 각 파일의 진행 값이 목록 안에서 각각 갱신되는 방식
일시정지 버튼을 누르면 세 파일이 SPEEDS 배열의 틱별 속도로 동시에 진행 값을 올리다가 셋 다 SIZES 만큼 받아지면 HOLD_MS(700ms) 뒤 done 배열이 [0, 0, 0]으로 되돌아가 처음부터 다시 시작합니다. 여러 파일 다운로드를 한 목록에서 동시에 관리하는 화면일수록 전체 진행 값을 단순 평균이 아니라 overall() 이 구하는 용량 가중 평균으로 잡아야 큰 파일의 지연이 축소되지 않습니다.
// 시드 고정: 파일별 크기(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;
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02전송 속도 값 그래프 갱신 — 초당 전송량 값이 그래프 형태로 계속 다시 그려지는 방식
일시정지 버튼을 누르면 SAMPLES 원시 표본이 TICK_MS(160ms)마다 하나씩 흘러 들어오고, movingAvg() 함수가 최근 N개(5개) 표본의 평균을 다시 계산해 dsg__avg 선을 매번 다시 그립니다. 원시선(dsg__raw)과 평균선(dsg__avg)을 나란히 두면 값이 어디까지 실측이고 어디부터 매끈해진 값인지 눈으로 구분됩니다.
// 계산 겹: 최근 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;
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03정지·재개 상태 값 전환 — 상태 값이 정지와 진행 사이를 오갈 때마다 값의 변화가 멈췄다 다시 이어지는 방식
일시정지 버튼을 누르면 setPaused(true) 가 그 순간까지의 경과 값을 pausedElapsed 에 담아 진행 값을 그 자리에서 멈추고, 다시 누르면 elapsed() 함수가 새로 흐른 시간만 더해 멈췄던 값부터 이어서 올립니다. 대용량 설치 파일처럼 다운로드 진행 상황 확인을 자주 멈췄다 이어가야 하는 화면에 맞는 계산입니다.
// 상수: 전체 소요 시간(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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04대기열 순서 값 재배치 — 아직 시작하지 않은 항목들의 순서 값이 사용자가 정한 차례로 바뀌는 방식
항목을 pointerdown 으로 잡아 pointermove 로 끌면 order 배열의 순서 값은 즉시 바뀌지만, 화면 위 위치는 tickLerp() 가 매 프레임 남은 거리의 LERP(0.18) 비율만큼만 다가가게 해 항목이 순간이동하지 않고 칸 사이를 미끄러지듯 이동합니다. pointerup·pointercancel 로 손을 떼면 dragging 이 -1 로 풀리며 나머지 항목도 같은 보간으로 새 자리를 따라갑니다.
// 계산 겹: 순서 값이 바뀌면 목표 위치로 즉시 점프하지 않고 매 프레임 남은 거리의 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);

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05압축 해제 진행 값과 형태 값 동시 변화 — 압축 해제 진행 값이 차오르는 동안 형태 값도 함께 바뀌는 방식
일시정지 버튼을 누르면 DUR_MS(2400ms) 동안 진행 값이 0에서 1로 차오르고, lerpPoints() 함수가 그 값 t 로 닫힌 상자 8점(BOX)과 펼쳐진 도형 8점(OPEN)의 좌표를 각각 선형보간해 다각형을 프레임마다 다시 그립니다. 두 형태가 겹쳐 페이드되는 게 아니라 점 좌표 자체가 이동하는 방식이라, 진행 값이 60%일 때 다각형은 두 모양의 정확히 중간 좌표에 있습니다.
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(' ');
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06손상 재시도 횟수 값 — 검증에 실패하면 재시도 횟수 값이 하나씩 늘고 진행 값이 처음으로 되돌아가는 방식
지금 재시도 버튼을 누르면 verify() 함수가 VERIFY_SEED 배열에서 그 회차의 결과를 읽어(0·1회차는 손상, 2회차부터 통과) 실패하면 retry 값이 하나 늘고 진행 값이 0으로 돌아가며, backoffMs() 가 BASE_BACKOFF_MS(260ms) × BACKOFF_MULT(2)의 재시도 제곱으로 다음 시도까지 대기 시간을 지수로 늘립니다. 불안정한 네트워크에서 다운로드 완료 알림이 늦어지는 이유를 이 계산으로 눈에 보이게 만든 셈입니다.
// 시드 고정: 회차별 검증 결과(참=검증 통과). 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));
}

<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 · 손상 재시도 횟수 값 — 검증에 실패하면 재시도 횟수 값이 하나씩 늘고 진행 값이 처음으로 되돌아가는 방식 — 측정 누적면적 2.188% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07완료 값 반짝 표시 — 다운로드가 끝나는 순간 완료 값이 1로 바뀌었다가 일정 시간 뒤 다시 0으로 사라지는 방식
다시 보기 버튼을 누르면 완료 배지가 나타나는 순간 sweepOpacity() 함수가 DECAY(0.55)의 회차 제곱으로 광택 sweep 세기를 계산해 SWEEP_COUNT(3)회 반복하는 동안 매 회차 옅어지다 사라지고, VISIBLE_MS(1500ms) 가 지나면 배지 값 자체도 사라집니다. 세 번째 sweep 은 첫 번째의 약 17%(0.55의 세제곱) 세기로만 지나갑니다.
// 상수: 완료 표시 유지 시간(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);
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08완료 항목 위치 값 이동 — 다운로드가 끝난 항목의 위치 값이 목록의 정해진 영역으로 옮겨가는 방식
다시 보기 버튼을 누르면 두 항목이 START_DELAY([0, 1100]ms)만큼 시차를 두고 spring() 이 계산한 진행도를 따라 보관함(dfr__zoneSlot)으로 이동하는데, ZETA(0.42)가 1보다 작아 위치 값이 목표를 살짝 지나쳤다가 되돌아오는 오버슈트를 만듭니다. 완료된 자료를 따로 모아 다운로드 완료 알림처럼 눈에 띄게 보여주는 화면에 어울리는 움직임입니다.
// 상수: 스프링 감쇠비(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));
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09모션 감소 정적 다운로드 표시 — 모션 감소 설정에서 애니메이션 대신 진행 값의 문구만 그대로 바뀌는 방식
모션 감소 전환 버튼을 누르면 root 에 drm--force-reduce 클래스가 토글되지만, tick() 함수 자체는 STEP(4)씩 같은 pct 값을 계속 올리고 있어 왼쪽 패널(drmFillA)은 매 틱 transform 으로 채워지는 반면 오른쪽 패널은 애초에 transform 대신 문구(pctB)만 교체하도록 짜여 있습니다. 여러 파일 다운로드 허용 화면이라도 접근성 설정을 지키려면 값 자체는 그대로 두고 표현만 바꾸면 된다는 걸 왼쪽·오른쪽 대조로 보여줍니다.
// 상수: 진행 속도(%/틱) · 틱 간격(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();

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 갱신 주기·재시도 기준·색상 단계의 기준
갱신 주기 값은 항목마다 다른 TICK_MS 가 정합니다 — 01은 140ms 마다 SPEEDS 값을 더하고, 02는 160ms 마다 새 표본을 흘리며, 06은 90ms 마다 pct 를 올리고 09는 130ms 마다 STEP(4)씩 값을 갱신합니다. 04·05·08처럼 requestAnimationFrame 으로 매 프레임 계산하는 항목은 고정 TICK_MS 없이 LERP(0.18)·ZETA(0.42) 같은 비율 상수가 갱신 폭을 정합니다.
재시도 기준은 06의 VERIFY_SEED 배열이 정합니다 — [false, false, true, true, true] 순서라 0·1회차는 반드시 손상되고 2회차부터 통과하며, 대기 시간은 backoffMs() 가 BASE_BACKOFF_MS(260ms) × BACKOFF_MULT(2)의 재시도 제곱으로 계산해 실패할 때마다 두 배씩 늘어납니다.
색상 단계의 기준은 06에서만 나타납니다 — verify() 가 실패를 반환하면 root 에 dcr--bad 클래스가 붙어 상태 문구 색이 $error 로 바뀌고, 통과하면 dcr--ok 로 바뀌어 원래 잉크색으로 돌아옵니다. 나머지 여덟 항목은 진행 값에 따라 색이 단계적으로 바뀌지 않고 한 가지 강조색을 그대로 유지합니다.
어디서 깨지는가 — 오프라인 복귀 순간·다중 탭 동시 다운로드·모션 감소
이 아홉 항목은 가상 시뮬레이션이라 오프라인 복귀 순간을 재현하지 않지만, 이 계산을 실제 다운로드 매니저 위에 얹을 때 가장 먼저 깨지는 지점이 여기입니다 — 06처럼 진행 값을 0으로 되돌리는 재시도 로직을 실제 fetch 위에 그대로 두면, 연결이 끊겼다 돌아온 순간 이미 받은 바이트까지 버리고 처음부터 다시 받게 될 수 있어 Range 헤더로 이어받기를 따로 설계해야 합니다. 아홉 항목의 html·scss·js 원본은 비번 cb2aueyy 으로 열리는 압축 파일 안에 그대로 들어 있어, 이 지점을 코드로 직접 맞춰볼 수 있습니다.
다중 탭 동시 다운로드도 마찬가지입니다. 아홉 항목 모두 진행·재시도·순서 값을 그 페이지의 JS 변수 하나에만 담아 두므로, 같은 데모를 탭 두 개로 열어 각각 버튼을 눌러도 두 탭은 서로의 값을 전혀 모른 채 따로 계산합니다 — 04의 order 배열이나 01의 done 배열을 실제 다중 탭 다운로드에 옮기려면 BroadcastChannel 이나 서버 쪽 상태 하나로 합쳐야 탭마다 진행 값이 따로 노는 문제가 사라집니다.
모션 감소는 아홉 항목 모두 style.scss 의 prefers-reduced-motion 규칙으로만 걸려 있고 index.html 안에 matchMedia 로 분기하는 JS 코드는 없는데, 규칙이 실제로 값을 조용하게 만드는지는 항목마다 다릅니다. 01·06·07·09는 fill·badge 요소 자체에 transition 이 걸려 있어 규칙이 켜지면 계산되는 값은 그대로인 채 화면 표시만 계단식으로 바뀌지만, 02·03·04·05·08은 그래프·다각형·위치 값을 그리는 요소 자체엔 애초에 transition 이 걸려 있지 않아 값 계산도 화면 표시도 reduce 여부와 무관하게 그대로 돌아갑니다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 이동평균 창 | 02 N 5 → 10 | 창을 5로 두면 그래프가 최근 표본의 튐에 민감하게 반응해 들쭉날쭉 움직이고, 10으로 늘리면 값이 매끄럽게 이어지는 대신 급격한 변화에 대한 반응이 한 박자 늦어집니다 |
| 지수 백오프 배수 | 06 BACKOFF_MULT 2 → 1.6 | 배수를 2로 두면 재시도할 때마다 대기 시간이 급격히 늘어 정지가 눈에 띄게 길어지고, 1.6으로 낮추면 대기가 완만하게 늘어 재시도가 더 잦게 이어집니다 |
| 스프링 감쇠비 | 08 ZETA 0.42 → 0.65 | 감쇠비를 0.42로 두면 항목이 보관함 자리를 지나쳤다 되돌아오는 오버슈트가 뚜렷하고, 0.65로 올리면 오버슈트가 줄어 더 차분하게 자리에 안착합니다 |
접근성은 항목마다 남기는 값이 다릅니다. 01·06·07은 reduce 가 켜지면 값 갱신 자체는 그대로 두고 화면에 그려지는 시각 표현(transition·transform)만 줄이거나 없애고, 02·03·05·08은 애초에 값을 그리는 요소 자체에 transition 이 걸려 있지 않아 reduce 여부와 무관하게 표현이 그대로 유지되며, 04는 순서 값이 바뀌는 즉시 위치가 따라가되 LERP 보간만 남고, 09는 왼쪽·오른쪽 두 판을 나란히 두어 값은 같아도 표현이 달라지는 것을 직접 비교하도록 설계했습니다.
FAQ
01에서 전체 진행 값은 왜 세 파일의 평균이 아닌가요?
세 칸을 단순히 3으로 나누면 크기가 다른 파일들의 실제 무게를 반영하지 못하기 때문입니다. overall() 함수는 done 배열의 합을 SIZES 배열의 합으로 나누는 용량 가중 평균을 쓰므로, SIZES[1] 980처럼 큰 파일의 진행이 늦어지면 전체 진행 값도 그만큼 더 크게 늦어져야 실제 남은 바이트와 어긋나지 않습니다.
04의 순서 재배치와 08의 완료 이동은 같은 방식으로 움직이나요?
아니요. 04의 tickLerp() 은 남은 거리의 LERP(0.18) 비율만큼만 매 프레임 다가가는 방식이라 목표를 지나치는 일이 없고, 08의 spring() 은 ZETA(0.42) 값 때문에 목표를 살짝 지나쳤다가 되돌아오는 오버슈트가 생깁니다. 하나는 항상 목표 쪽으로만 움직이고, 하나는 한 번 더 넘어갔다가 돌아온다는 방향성 차이가 있습니다.
06의 지수 백오프 배수를 다른 항목의 대기 시간에도 그대로 써도 되나요?
상수를 그대로 복사할 수는 있지만 결과 체감까지 같지는 않습니다. 06의 BASE_BACKOFF_MS 260·BACKOFF_MULT 2는 재시도 4회 안팎을 가정하고 맞춘 값이라, 예를 들어 훨씬 잦은 재시도가 예상되는 화면에 그대로 옮기면 대기 시간이 너무 빨리 치솟아 사용자가 오래 기다리는 것처럼 보일 수 있습니다.
더 읽을거리
여러 파일을 한 번에 올리는 업로드 진행 표시는 파일 하나가 서버로 올라가는 단일 진행 값을 계산하는 데 비해, 이 편은 여러 파일이 동시에 받아지는 동안 각각의 바이트·속도·순서·상태 값을 따로 계산한다는 점에서 축이 다릅니다. 목록 끝에서 다음 묶음을 불러오는 무한 스크롤은 스크롤 위치가 끝에 닿을 때마다 다음 묶음을 이어붙이는 값을 계산하는 반면, 이 편은 이미 정해진 파일들의 진행·재시도·순서 값이 전이되는 계산을 다룹니다. 당겨서 새로고침하는 로딩 표시는 당긴 거리 값이 문턱을 넘는 순간 새로고침을 트리거하는 계산인데, 이 편은 트리거 이후 오래 이어지는 진행 상태 자체의 값 변화를 다룬다는 점에서 다릅니다.









