03정지·재개 상태 값 전환 — 상태 값이 정지와 진행 사이를 오갈 때마다 값의 변화가 멈췄다 다시 이어지는 방식
상태 값이 정지로 바뀌면 진행 값이 그 자리에서 멈추고, 다시 진행으로 바뀌면 멈췄던 값부터 이어서 올라갑니다.
쓰임대용량 설치 파일 다운로드, 모바일 데이터 절약 다운로드 화면
.dpr__stateAUTO_PAUSE_AT.dpr__fill

<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 포함 · 다운로드 진행 상황 표시 9종 — 값이 퍼센트 단위로 갱신되는 아홉 가지 방식
이 데모가 속한 글
다운로드 진행 상황 표시 9종 — 값이 퍼센트 단위로 갱신되는 아홉 가지 방식다운로드 진행 상황 표시는 지금 받아지는 파일이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 여러 파일이 동시에 받아질 때 각 파일의 진행 값이 목록에서 나란히 오 이 글의 데모 전부 보기

