07Progress Button That Piles Up Like Sand With a Progress Value
Each time the progress value climbs, one more grain of sand drops from the top of the button, falling until it hits the current pile-height value and settling, so the fill reads as the progress amount.
Best forUpload/download progress buttons
hashNoisespawnGrainupdatePile

<main class="stage">
<div class="sf is-demo" id="root">
<canvas class="sf__cv" id="cv" width="480" height="300"></canvas>
<button class="sf__btn" id="btn" type="button">Download</button>
<p class="sf__hint">Press it and the progress value rises as sand grains pile up</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-orange-deep;
@include stage($stage-orange);
.sf {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.sf__cv {
display: block;
width: 100%;
height: 100%;
}
.sf__btn {
position: absolute;
left: 50%;
top: 50%;
width: calc(300 / 480 * 100%);
height: calc(140 / 300 * 100%);
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: $r-control;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.sf__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.sf__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, .72);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.sf__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);
}
.sf {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sf__cv {
display: block;
width: 100%;
height: 100%;
}
.sf__btn {
position: absolute;
left: 50%;
top: 50%;
width: 62.5%;
height: 46.6666666667%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: 12px;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.sf__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.sf__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, 0.72);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.sf__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function hashNoise(x, y, seed) {
var v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
var W = 480, H = 300;
var SEED = 70722;
var rnd = makeLcg(SEED);
var BW = 300, BH = 140;
var BX = (W - BW) / 2, BY = (H - BH) / 2;
var COLS = 36;
var COL_W = BW / COLS;
var GRAVITY = 560; // px/s^2 — 알갱이가 떨어지는 가속
var SPAWN_Y = 12; // 캔버스 위쪽 빈 공간에서부터 떨어뜨린다
var SPAWN_INTERVAL_MS = 30;
var EASE_RATE = 5.5; // 진행 값 → 쌓인 높이 값이 따라붙는 속도
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var colJitter = [];
for (var ci = 0; ci < COLS; ci++) colJitter.push((hashNoise(ci, 7, SEED) - 0.5) * 10);
var pileHeights = new Float32Array(COLS);
var grains = [];
var targetFill = 0;
var lastSpawn = 0;
function spawnGrain() {
var col = Math.floor(rnd() * COLS);
var size = 3 + rnd() * 3;
grains.push({ x: BX + col * COL_W + COL_W / 2, y: SPAWN_Y, vy: 40 + rnd() * 30, size: size, col: col });
}
function updateGrains(dt) {
if (grains.length > 90) grains.length = 90;
for (var i = grains.length - 1; i >= 0; i--) {
var g = grains[i];
g.vy += GRAVITY * dt;
g.y += g.vy * dt;
var floorY = BY + BH - pileHeights[g.col];
if (g.y + g.size / 2 >= floorY) grains.splice(i, 1);
}
}
function updatePile(dt) {
var targetHeight = targetFill * BH;
for (var c = 0; c < COLS; c++) {
var t = Math.max(0, Math.min(BH, targetHeight + colJitter[c]));
pileHeights[c] += (t - pileHeights[c]) * Math.min(1, dt * EASE_RATE);
}
}
function render(now, dt) {
updateGrains(dt);
updatePile(dt);
if (now - lastSpawn >= SPAWN_INTERVAL_MS) {
lastSpawn = now;
spawnGrain();
}
ctx.clearRect(0, 0, W, H);
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 14);
ctx.clip();
ctx.fillStyle = '#17141a';
ctx.fillRect(BX, BY, BW, BH);
for (var c1 = 0; c1 < COLS; c1++) {
var h = pileHeights[c1];
if (h <= 0) continue;
var x = BX + c1 * COL_W;
var y = BY + BH - h;
var grad = ctx.createLinearGradient(0, y, 0, BY + BH);
grad.addColorStop(0, '#e3c48a');
grad.addColorStop(1, '#b8925c');
ctx.fillStyle = grad;
ctx.fillRect(x, y, COL_W + 1, h + 2);
}
var speckleCell = 4;
for (var sy = 0; sy < BH; sy += speckleCell) {
for (var sx = 0; sx < BW; sx += speckleCell) {
var col = Math.min(COLS - 1, Math.floor(sx / COL_W));
var topY = BH - pileHeights[col];
if (sy < topY) continue;
var n = hashNoise(sx, sy, SEED);
if (n > 0.74) {
ctx.fillStyle = 'rgba(120,90,50,' + (0.25 + n * 0.3).toFixed(3) + ')';
ctx.fillRect(BX + sx, BY + sy, speckleCell - 1, speckleCell - 1);
}
}
}
ctx.fillStyle = '#fff7e6';
ctx.font = '700 18px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Download', BW / 2 + BX, BY + 20);
ctx.restore();
for (var i2 = 0; i2 < grains.length; i2++) {
var gr = grains[i2];
ctx.beginPath();
ctx.arc(gr.x, gr.y, gr.size / 2, 0, Math.PI * 2);
ctx.fillStyle = '#d9b171';
ctx.fill();
}
}
function trigger() {
root.classList.remove('is-demo');
targetFill = targetFill > 0.5 ? 0 : 1;
if (reduceMQ.matches) render(performance.now(), 0.05);
}
btn.addEventListener('click', trigger);
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
trigger();
});
cv.addEventListener('pointerdown', function () { trigger(); });
window.addEventListener('resize', function () { render(performance.now(), 0.016); });
var lastT = null;
function frame(now) {
if (!reduceMQ.matches) {
var dt = lastT === null ? 0.016 : Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (root.classList.contains('is-demo')) {
var t = now % 900;
if (t < 500) targetFill = t / 500;
else if (t < 600) targetFill = 1;
else targetFill = Math.max(0, 1 - (t - 600) / 300);
}
render(now, dt);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
targetFill = 0.55;
var th = targetFill * BH;
for (var c2 = 0; c2 < COLS; c2++) {
pileHeights[c2] = Math.max(0, Math.min(BH, th + colJitter[c2]));
}
render(performance.now(), 0.05);
} else {
requestAnimationFrame(frame);
}
})();// SandFillButton — vanilla 판(run/404/demo/07-btn-canvas-sand-fill-progress/index.html) 의 React 포트.
// 진행 값이 오를수록 목표 높이 값을 따라 쌓인 높이 값이 붙고, 동시에 캔버스 위쪽 빈 공간에서
// 모래 알갱이가 끊임없이 떨어져 내려와 진행률이 오르는 걸 실감나게 보여준다.
import { useEffect, useRef } from "react";
import styles from "./SandFillButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Grain = { x: number; y: number; vy: number; size: number; col: number };
const W = 480, H = 300;
const SEED = 70722;
const BW = 300, BH = 140;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
const COLS = 36;
const COL_W = BW / COLS;
const GRAVITY = 560;
const SPAWN_Y = 12;
const SPAWN_INTERVAL_MS = 30;
const EASE_RATE = 5.5;
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function hashNoise(x: number, y: number, seed: number): number {
const v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
const colJitter: number[] = [];
for (let ci = 0; ci < COLS; ci++) colJitter.push((hashNoise(ci, 7, SEED) - 0.5) * 10);
export default function SandFillButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const pileRef = useRef<Float32Array>(new Float32Array(COLS)) as Box<Float32Array>;
const grainsRef = useRef<Grain[]>([]) as Box<Grain[]>;
const targetFillRef = useRef<number>(0) as Box<number>;
const lastSpawnRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
function updateGrains(dt: number): void {
const grains = grainsRef.current;
if (grains.length > 90) grains.length = 90;
const pile = pileRef.current;
for (let i = grains.length - 1; i >= 0; i--) {
const g = grains[i];
g.vy += GRAVITY * dt;
g.y += g.vy * dt;
const floorY = BY + BH - pile[g.col];
if (g.y + g.size / 2 >= floorY) grains.splice(i, 1);
}
}
function updatePile(dt: number): void {
const pile = pileRef.current;
const targetHeight = targetFillRef.current * BH;
for (let c = 0; c < COLS; c++) {
const t = Math.max(0, Math.min(BH, targetHeight + colJitter[c]));
pile[c] += (t - pile[c]) * Math.min(1, dt * EASE_RATE);
}
}
function render(now: number, dt: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const rnd = rndRef.current;
const pile = pileRef.current;
const grains = grainsRef.current;
updateGrains(dt);
updatePile(dt);
if (now - lastSpawnRef.current >= SPAWN_INTERVAL_MS) {
lastSpawnRef.current = now;
const col = Math.floor(rnd() * COLS);
const size = 3 + rnd() * 3;
grains.push({ x: BX + col * COL_W + COL_W / 2, y: SPAWN_Y, vy: 40 + rnd() * 30, size, col });
}
ctx.clearRect(0, 0, W, H);
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 14);
ctx.clip();
ctx.fillStyle = "#17141a";
ctx.fillRect(BX, BY, BW, BH);
for (let c1 = 0; c1 < COLS; c1++) {
const h = pile[c1];
if (h <= 0) continue;
const x = BX + c1 * COL_W;
const y = BY + BH - h;
const grad = ctx.createLinearGradient(0, y, 0, BY + BH);
grad.addColorStop(0, "#e3c48a");
grad.addColorStop(1, "#b8925c");
ctx.fillStyle = grad;
ctx.fillRect(x, y, COL_W + 1, h + 2);
}
const speckleCell = 4;
for (let sy = 0; sy < BH; sy += speckleCell) {
for (let sx = 0; sx < BW; sx += speckleCell) {
const col = Math.min(COLS - 1, Math.floor(sx / COL_W));
const topY = BH - pile[col];
if (sy < topY) continue;
const n = hashNoise(sx, sy, SEED);
if (n > 0.74) {
ctx.fillStyle = "rgba(120,90,50," + (0.25 + n * 0.3).toFixed(3) + ")";
ctx.fillRect(BX + sx, BY + sy, speckleCell - 1, speckleCell - 1);
}
}
}
ctx.fillStyle = "#fff7e6";
ctx.font = "700 18px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("다운로드", BW / 2 + BX, BY + 20);
ctx.restore();
for (let i2 = 0; i2 < grains.length; i2++) {
const gr = grains[i2];
ctx.beginPath();
ctx.arc(gr.x, gr.y, gr.size / 2, 0, Math.PI * 2);
ctx.fillStyle = "#d9b171";
ctx.fill();
}
}
function trigger(): void {
demoRef.current = false;
targetFillRef.current = targetFillRef.current > 0.5 ? 0 : 1;
if (reduceRef.current) render(performance.now(), 0.05);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
trigger();
}
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
const pile = pileRef.current;
targetFillRef.current = 0.55;
const th = targetFillRef.current * BH;
for (let c2 = 0; c2 < COLS; c2++) {
pile[c2] = Math.max(0, Math.min(BH, th + colJitter[c2]));
}
render(performance.now(), 0.05);
return;
}
function frame(now: number): void {
const last = lastTRef.current;
const dt = last === null ? 0.016 : Math.min(0.05, (now - last) / 1000);
lastTRef.current = now;
if (demoRef.current) {
const t = now % 900;
if (t < 500) targetFillRef.current = t / 500;
else if (t < 600) targetFillRef.current = 1;
else targetFillRef.current = Math.max(0, 1 - (t - 600) / 300);
}
render(now, dt);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.sf}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={trigger} />
<button className={styles.btn} ref={btnRef} type="button" onClick={trigger} onKeyDown={onKeyDown}>
다운로드
</button>
<p className={styles.hint}>누르면 진행 값이 오르며 모래가 알갱이째 쌓입니다</p>
</div>
</main>
);
}07 Progress Button That Piles Up Like Sand With a Progress Value — Measured area 21.685% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Button Click Effect CSS: 9 Canvas Pixel Patterns
The post this demo lives in
Button Click Effect CSS: 9 Canvas Pixel PatternsNine button click effect css demos. The button cracks like glass for one reason: a crack-angle value tied to a press-sta See all demos in this post
If you like this effect - similar demos
Buttons04 Like button with a rolling count9 button state css patterns, loading to done
Buttons06 Close button with a ring that keeps circling9 Icon Button CSS Patterns — Press, Drag, Expand
Buttons01 Long-shadow retro — a button whose trailing shadow length value extends and retracts with hover stateCTA Button Design: 9 Interaction Patterns