08Download Progress Ring That Fills to the Ratio of Bytes Received
Pressing the download button divides bytes received so far by the total size to calculate the ring's fill angle, and the ring swaps to a check icon once it reaches 100%.
Best forButton for downloading the original PDF
lineLenCIRCfinish

<main class="stage">
<div class="pvg is-demo" id="pvgRoot" tabindex="0">
<svg class="pvg__ring" viewBox="0 0 92 92" width="92" height="92" aria-hidden="true">
<circle class="pvg__track" cx="46" cy="46" r="36"></circle>
<circle class="pvg__fill" id="pvgFill" cx="46" cy="46" r="36" transform="rotate(-90 46 46)"></circle>
<line class="pvg__check" id="pvgCheckA" x1="28" y1="52" x2="40" y2="64"></line>
<line class="pvg__check" id="pvgCheckB" x1="28" y1="52" x2="64" y2="30"></line>
</svg>
<p class="pvg__pct" id="pvgPct">0%</p>
<button class="pvg__btn" id="pvgBtn" type="button">Download</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pvg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.pvg__ring { display: block; }
.pvg__track {
fill: none;
stroke: rgba(23, 20, 26, .12);
stroke-width: 7;
}
.pvg__fill {
fill: none;
stroke: $subject-blue;
stroke-width: 7;
stroke-linecap: round;
transition: opacity $dur-base ease;
}
.pvg__check {
stroke: $subject-blue-deep;
stroke-width: 7;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity $dur-base ease;
}
.pvg--done .pvg__fill { opacity: 0; }
.pvg--done .pvg__check { opacity: 1; }
.pvg__pct {
margin: 0;
font-size: 16px;
font-weight: 700;
color: $subject-ink;
transition: opacity $dur-base ease;
}
.pvg--done .pvg__pct { opacity: 0; }
.pvg__btn {
border: none;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-size: 12px;
font-weight: 700;
background: $subject-ink;
color: #fff;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.pvg__fill, .pvg__check, .pvg__pct { 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);
}
.pvg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.pvg__ring {
display: block;
}
.pvg__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 7;
}
.pvg__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 7;
stroke-linecap: round;
transition: opacity 300ms ease;
}
.pvg__check {
stroke: #0a4fe8;
stroke-width: 7;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity 300ms ease;
}
.pvg--done .pvg__fill {
opacity: 0;
}
.pvg--done .pvg__check {
opacity: 1;
}
.pvg__pct {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #17141a;
transition: opacity 300ms ease;
}
.pvg--done .pvg__pct {
opacity: 0;
}
.pvg__btn {
border: none;
border-radius: 999px;
padding: 8px 20px;
font-size: 12px;
font-weight: 700;
background: #17141a;
color: #fff;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.pvg__fill, .pvg__check, .pvg__pct {
transition: none;
}
}(function () {
// 기법: 받은 바이트 / 전체 바이트로 링의 채움 비율을 계산해 stroke-dashoffset 으로
// 옮긴다. 100%에 닿으면 링을 지우고 같은 시작점(28,52)에서 뻗어 나온 두 선분의
// stroke-dashoffset 을 0 으로 당겨 체크 아이콘을 그린다.
var root = document.getElementById('pvgRoot');
var fill = document.getElementById('pvgFill');
var pctEl = document.getElementById('pvgPct');
var btn = document.getElementById('pvgBtn');
var checkA = document.getElementById('pvgCheckA');
var checkB = document.getElementById('pvgCheckB');
var R = 36, CIRC = 2 * Math.PI * R;
fill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el) {
var x1 = +el.getAttribute('x1'), y1 = +el.getAttribute('y1'), x2 = +el.getAttribute('x2'), y2 = +el.getAttribute('y2');
return Math.hypot(x2 - x1, y2 - y1);
}
var lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2);
checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
var TOTAL_BYTES = 260000;
var SPEED = 230000;
var LOOP_MS = 2000;
var phase = 'idle', received = 0, doneAt = 0, checkAnim = null;
function render(ratio) {
fill.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2);
pctEl.textContent = Math.round(ratio * 100) + '%';
}
function startDownload(nowT) {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pvg--done');
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
btn.textContent = 'Downloading';
render(0);
}
function finish(nowT) {
phase = 'done'; doneAt = nowT;
root.classList.add('pvg--done');
btn.textContent = 'Done';
checkAnim = { start: nowT };
}
function resetIdle() {
phase = 'idle'; received = 0;
root.classList.remove('pvg--done');
btn.textContent = 'Download';
render(0);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
btn.addEventListener('click', function (e) { markActive(); if (phase !== 'loading') { resetIdle(); startDownload(lastTs || 0); } });
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault(); markActive();
if (phase !== 'loading') { resetIdle(); startDownload(lastTs || 0); }
} else if (e.key === 'Escape') {
e.preventDefault(); markActive(); resetIdle();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; render(0.5); }
var fired = {};
var SCHEDULE = [
{ t: 100, run: function (t) { if (phase === 'idle') startDownload(t); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (phase === 'loading') {
received = Math.min(TOTAL_BYTES, received + SPEED * (dt / 1000));
var ratio = received / TOTAL_BYTES;
render(ratio);
if (ratio >= 1) finish(ts);
} else if (phase === 'done') {
if (checkAnim) {
var pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
var pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 620 && !userActive) resetIdle();
}
requestAnimationFrame(tick);
}
render(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerDownloadProgressRing.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerDownloadProgressRing() {
const rootRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<SVGCircleElement>(null);
const pctRef = useRef<HTMLParagraphElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const checkARef = useRef<SVGLineElement>(null);
const checkBRef = useRef<SVGLineElement>(null);
useEffect(() => {
const root = rootRef.current, fill = fillRef.current, pctEl = pctRef.current;
const btn = btnRef.current, checkA = checkARef.current, checkB = checkBRef.current;
if (!root || !fill || !pctEl || !btn || !checkA || !checkB) return;
const R = 36, CIRC = 2 * Math.PI * R;
fill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el: SVGLineElement) {
const x1 = +el.getAttribute("x1")!, y1 = +el.getAttribute("y1")!;
const x2 = +el.getAttribute("x2")!, y2 = +el.getAttribute("y2")!;
return Math.hypot(x2 - x1, y2 - y1);
}
const lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2);
checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
const TOTAL_BYTES = 260000;
const SPEED = 230000;
const LOOP_MS = 2000;
let phase: "idle" | "loading" | "done" = "idle", received = 0, doneAt = 0;
let checkAnim: { start: number } | null = null;
function render(ratio: number) {
fill!.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2);
pctEl!.textContent = Math.round(ratio * 100) + "%";
}
function startDownload() {
if (phase === "loading") return;
phase = "loading"; received = 0;
root!.classList.remove(styles.done);
checkA!.style.strokeDashoffset = lenA.toFixed(2);
checkB!.style.strokeDashoffset = lenB.toFixed(2);
btn!.textContent = "받는 중";
render(0);
}
function finish(nowT: number) {
phase = "done"; doneAt = nowT;
root!.classList.add(styles.done);
btn!.textContent = "완료";
checkAnim = { start: nowT };
}
function resetIdle() {
phase = "idle"; received = 0;
root!.classList.remove(styles.done);
btn!.textContent = "다운로드";
render(0);
}
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onClick() { markActive(); if (phase !== "loading") { resetIdle(); startDownload(); } }
btn.addEventListener("click", onClick);
function onKeydown(e: KeyEvt) {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault(); markActive();
if (phase !== "loading") { resetIdle(); startDownload(); }
} else if (e.key === "Escape") {
e.preventDefault(); markActive(); resetIdle();
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; render(0.5); }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 100, run: () => { if (phase === "idle") startDownload(); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
if (phase === "loading") {
received = Math.min(TOTAL_BYTES, received + SPEED * (dt / 1000));
const ratio = received / TOTAL_BYTES;
render(ratio);
if (ratio >= 1) finish(ts);
} else if (phase === "done") {
if (checkAnim) {
const pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
const pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA!.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB!.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 620 && !userActive) resetIdle();
}
raf = requestAnimationFrame(tick);
}
render(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvg} is-demo`} ref={rootRef} tabIndex={0}>
<svg className={styles.ring} viewBox="0 0 92 92" width={92} height={92} aria-hidden="true">
<circle className={styles.track} cx={46} cy={46} r={36} />
<circle className={styles.fill} ref={fillRef} cx={46} cy={46} r={36} transform="rotate(-90 46 46)" />
<line className={styles.check} ref={checkARef} x1={28} y1={52} x2={40} y2={64} />
<line className={styles.check} ref={checkBRef} x1={28} y1={52} x2={64} y2={30} />
</svg>
<p className={styles.pct} ref={pctRef}>0%</p>
<button className={styles.btn} ref={btnRef} type="button">다운로드</button>
</div>
</main>
);
}08 Download Progress Ring That Fills to the Ratio of Bytes Received — Measured area 2.737% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · PDF Viewer Implementation: 9 UI Patterns
The post this demo lives in
PDF Viewer Implementation: 9 UI Patternspdf viewer implementation: Does scroll decide the page number, or the reverse? Item 02's zoom center uses pointer coordi See all demos in this post
If you like this effect - similar demos
Embeds01 YouTube responsive embed — a video frame that recomputes its ratio from the available width9 YouTube Embed Code UI Patterns (Copy-Paste)
Embeds02 Tabs that swap the same example per language9 Code Block UI Patterns — No Highlighter Library
Embeds04 Social mention cards that roll in from the side9 Activity Feed UI Widgets — Sidebar-Ready Lists