04A Chip That Counts Up the Received Size
The received-size number climbs toward its target while slowing, and the remaining time worked out from what is left shrinks beside it.
Best forShowing file size and time left
COUNT_EASETARGET_MBETA_MIN

<main class="stage">
<div class="sz is-demo" id="szRoot">
<button class="sz__btn" id="szBtn" type="button"><span id="szLabel">Download</span></button>
<div class="sz__chips">
<p class="sz__chip"><span class="sz__tag">MB</span> <b id="szGot">0.0</b><span class="sz__of">/ 48</span></p>
<p class="sz__chip"><span class="sz__tag">ETA</span> <b id="szEta">--:--</b></p>
</div>
<p class="sz__sr" id="szStatus" role="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 숫자 두 개(받은 크기 · 남은 시간)와 버튼 안 채움이 같은 계산값 하나에서 나온다.
$sz-face: $subject-lilac;
$sz-fill: $subject-mint;
@include stage($stage-orange);
.sz {
--f: 0;
width: min(100%, 280px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.sz__btn {
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 64px;
border: 0;
border-radius: $r-control;
background: linear-gradient(90deg, #{$sz-fill} calc(var(--f) * 100%), #{$sz-face} calc(var(--f) * 100%));
box-shadow: $shadow-press;
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.sz__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.sz__chips {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-2;
}
.sz__chip {
margin: 0;
box-sizing: border-box;
display: flex;
align-items: baseline;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-sm;
background: color-mix(in srgb, #{$subject-cream}, #{$sz-fill} calc(var(--f) * 100%));
color: $subject-ink;
font-size: 14px;
line-height: 24px;
}
.sz__tag { font-size: 12px; font-weight: 700; letter-spacing: .08em; }
.sz__chip b { min-width: 4ch; font-size: 20px; text-align: right; font-variant-numeric: tabular-nums; }
.sz__of { font-size: 12px; }
.sz__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.sz__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);
}
.sz {
--f: 0;
width: min(100%, 280px);
display: grid;
gap: 12px;
justify-items: center;
}
.sz__btn {
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 64px;
border: 0;
border-radius: 12px;
background: linear-gradient(90deg, #4cd4a6 calc(var(--f) * 100%), #b9a0ff calc(var(--f) * 100%));
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.sz__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sz__chips {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.sz__chip {
margin: 0;
box-sizing: border-box;
display: flex;
align-items: baseline;
gap: 4px;
padding: 8px;
border-radius: 8px;
background: color-mix(in srgb, #fff7e6, #4cd4a6 calc(var(--f) * 100%));
color: #17141a;
font-size: 14px;
line-height: 24px;
}
.sz__tag {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.sz__chip b {
min-width: 4ch;
font-size: 20px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.sz__of {
font-size: 12px;
}
.sz__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.sz__btn {
transition: none;
}
}(function () {
// 기법: 받은 크기 = TARGET_MB x (1 - (1 - u)^COUNT_EASE) 로 감속하며 올라간다 (u 는 시간 비율).
// 남은 시간 = 남은 양 / 지금까지의 평균 속도. 평균 속도가 시간에 따라 변하므로 ETA 는 시작 때 가장 크고 끝에서 0 이 된다.
var TARGET_MB = 48;
var ETA_MIN = 2;
var COUNT_EASE = 3;
var RUN_MS = 1500;
var START_MS = 200;
var CYCLE_MS = 2000;
var root = document.getElementById('szRoot');
var btn = document.getElementById('szBtn');
var labelEl = document.getElementById('szLabel');
var gotEl = document.getElementById('szGot');
var etaEl = document.getElementById('szEta');
var statusEl = document.getElementById('szStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var clock = 0;
var runStart = null;
var finished = false;
var announced = false;
var t0 = null;
function stopDemo() { root.classList.remove('is-demo'); }
function two(n) { return n < 10 ? '0' + n : String(n); }
function startOf(now) {
if (root.classList.contains('is-demo')) {
return now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS;
}
return runStart;
}
function render(now) {
var s = startOf(now);
var u = s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
if (reduce && finished) u = 1;
var got = TARGET_MB * (1 - Math.pow(1 - u, COUNT_EASE));
var eta = '--:--';
if (u > 0) {
var elapsedSec = Math.max(u, 0.004) * ETA_MIN * 60;
var avg = Math.max(got, 0.01) / elapsedSec;
var left = Math.round((TARGET_MB - got) / avg);
eta = Math.floor(left / 60) + ':' + two(left % 60);
}
root.style.setProperty('--f', (got / TARGET_MB).toFixed(3));
gotEl.textContent = got.toFixed(1);
etaEl.textContent = eta;
labelEl.textContent = u >= 1 ? 'Done' : (u > 0 ? 'Receiving' : 'Download');
if (u >= 1 && !announced) { announced = true; statusEl.textContent = 'Download complete'; }
if (u < 1 && announced) { announced = false; statusEl.textContent = ''; }
}
function activate() {
stopDemo();
if (reduce) { finished = true; render(clock); return; }
if (runStart !== null && clock - runStart < RUN_MS) return;
runStart = clock;
}
btn.addEventListener('click', activate);
function tick(ts) {
if (t0 === null) t0 = ts;
clock = ts - t0;
render(clock);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
render(0);
} else {
requestAnimationFrame(tick);
}
})();// SizeCountButton — vanilla 판(04-dlbtn-size-count-estimate) 의 React 포트.
// 받은 크기는 TARGET_MB x (1 - (1 - u)^COUNT_EASE) 로 감속하며 오르고, 남은 시간은 남은 양 / 평균 속도 로 계산한다.
import { useEffect, useRef } from "react";
import styles from "./SizeCountButton.module.scss";
const TARGET_MB = 48;
const ETA_MIN = 2;
const COUNT_EASE = 3;
const RUN_MS = 1500;
const START_MS = 200;
const CYCLE_MS = 2000;
function two(n: number): string {
return n < 10 ? "0" + n : String(n);
}
export default function SizeCountButton() {
const rootRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const gotRef = useRef<HTMLElement | null>(null);
const etaRef = useRef<HTMLElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const btn = btnRef.current;
const labelEl = labelRef.current;
const gotEl = gotRef.current;
const etaEl = etaRef.current;
const statusEl = statusRef.current;
if (!root || !btn || !labelEl || !gotEl || !etaEl || !statusEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let clock = 0;
let runStart: number | null = null;
let finished = false;
let announced = false;
let t0: number | null = null;
let raf = 0;
const startOf = (now: number): number | null => {
if (demo) return now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS;
return runStart;
};
const render = (now: number): void => {
const s = startOf(now);
let u = s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
if (reduce && finished) u = 1;
const got = TARGET_MB * (1 - Math.pow(1 - u, COUNT_EASE));
let eta = "--:--";
if (u > 0) {
const elapsedSec = Math.max(u, 0.004) * ETA_MIN * 60;
const avg = Math.max(got, 0.01) / elapsedSec;
const left = Math.round((TARGET_MB - got) / avg);
eta = Math.floor(left / 60) + ":" + two(left % 60);
}
root.style.setProperty("--f", (got / TARGET_MB).toFixed(3));
gotEl.textContent = got.toFixed(1);
etaEl.textContent = eta;
labelEl.textContent = u >= 1 ? "완료" : u > 0 ? "받는 중" : "내려받기";
if (u >= 1 && !announced) { announced = true; statusEl.textContent = "받기 완료"; }
if (u < 1 && announced) { announced = false; statusEl.textContent = ""; }
};
const activate = (): void => {
demo = false;
if (reduce) { finished = true; render(clock); return; }
if (runStart !== null && clock - runStart < RUN_MS) return;
runStart = clock;
};
btn.addEventListener("click", activate);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
clock = ts - t0;
render(clock);
raf = requestAnimationFrame(tick);
}
if (reduce) render(0);
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", activate);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sz} ref={rootRef}>
<button className={styles.sz__btn} type="button" ref={btnRef}>
<span ref={labelRef}>내려받기</span>
</button>
<div className={styles.sz__chips}>
<p className={styles.sz__chip}>
<span className={styles.sz__tag}>MB</span> <b ref={gotRef}>0.0</b>
<span className={styles.sz__of}>/ {TARGET_MB}</span>
</p>
<p className={styles.sz__chip}>
<span className={styles.sz__tag}>ETA</span> <b ref={etaRef}>--:--</b>
</p>
</div>
<p className={styles.sz__sr} role="status" ref={statusRef} />
</div>
</main>
);
}04 A Chip That Counts Up the Received Size — Measured area 18.608% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · CSS Download Button: 9 Demos With Springs and Arcs
The post this demo lives in
CSS Download Button: 9 Demos With Springs and ArcsA css download button that only swaps its label leaves a stiff arrow, steps in progress and no sign of where the file we See all demos in this post
If you like this effect - similar demos
Buttons02 Button that unlocks when the form is valid9 button state css patterns, loading to done
Buttons07 Thrown-speed decay move — motion continues at the speed value captured at release, with that value shrinking over time until it stopsButton Press Animation UI: 9 Calculation Patterns
Buttons01 Ink-diffusion Button That Spreads From the Click PointButton Click Effect CSS: 9 Canvas Pixel Patterns