03Loader a Gravity Drop-height Value Stacks
The number of blocks is set by the progress value and each drops from the top under gravity, its bounce height decaying on every floor hit, so the stacked count reads as progress.
Best forGame resource-loading screens and onboarding wait screens
DAMPINGGRAVITYarcTo

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>Click or press the space bar to pause or restart</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="Gravity block stacking test panel" tabindex="0">
<canvas class="ld__cv" id="cv"></canvas>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">Stack</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 중력 블록 로더 — 블록 8개를 하나씩 떨어뜨려 감쇠 상수(0.92)로 튕김을 줄이며 쌓는다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-orange-deep;
@include stage($stage-orange);
.ld {
--paper: #{$subject-cream};
--ink: #{$subject-ink};
--floor: #{rgba($subject-ink, .12)};
--c1: #{$subject-blue};
--c2: #{$subject-mint};
--c3: #{$subject-pink};
--c4: #{$subject-lilac};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-orange-deep; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-orange-deep; outline-offset: 2px; }
.ld__cv { display: block; width: 100%; height: 100%; }
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
// reduce — 루프를 시작하지 않고 진행률 62% 언저리에서 멈춘 한 장만 그린다.
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: .92 !important; }
}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);
}
.ld {
--paper: #fff7e6;
--ink: #17141a;
--floor: rgba(23, 20, 26, 0.12);
--c1: #2f6df6;
--c2: #4cd4a6;
--c3: #ff9ebb;
--c4: #b9a0ff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c93000;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
.ld__cv {
display: block;
width: 100%;
height: 100%;
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}// 중력 블록 로더 — 블록 8개를 위에서 하나씩 중력 가속도로 떨어뜨리고, 바닥에 닿을 때마다
// 튕기는 높이를 감쇠 상수(gravity-bounce-damping = 0.92, 훅 고정)로 줄여 쌓인 블록 수가 진행률이 된다.
var root = document.getElementById('ld');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var pctEl = document.getElementById('pct');
var css = getComputedStyle(root);
var PAPER = css.getPropertyValue('--paper').trim();
var FLOOR = css.getPropertyValue('--floor').trim();
var C1 = css.getPropertyValue('--c1').trim();
var C2 = css.getPropertyValue('--c2').trim();
var C3 = css.getPropertyValue('--c3').trim();
var C4 = css.getPropertyValue('--c4').trim();
var PALETTE = [C1, C2, C3, C4];
var BLOCKS = 8;
var GRAVITY = 1700; // px/s^2 — 캔버스 규모에 맞춘 중력 가속도
var DAMPING = 0.92; // 튕김 감쇠 상수(훅 고정값) — gravity-bounce-damping
var DROP_INTERVAL = 0.3; // 블록 사이 낙하 시작 간격(초)
var REST_V = 40; // 이 속도 밑으로 떨어지면 멈춘 것으로 본다
var LOOP = BLOCKS * DROP_INTERVAL + 0.5;
var W = 0, H = 0, dpr = 1, blockH = 0, blockW = 0, margin = 0;
var blocks = [];
function resetBlocks() {
blocks = [];
for (var i = 0; i < BLOCKS; i++) blocks.push({ y: -blockH, vy: 0, state: 'waiting' });
}
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
blockH = (H - 10) / (BLOCKS + 1.4);
blockW = Math.min(W * 0.42, blockH * 2.6);
margin = (H - 10) - BLOCKS * blockH;
resetBlocks();
}
var lastSec = null, cycleIndex = -1;
function step(sec) {
if (lastSec === null) lastSec = sec;
var dt = Math.min(0.05, Math.max(0, sec - lastSec));
lastSec = sec;
var ci = Math.floor(sec / LOOP);
if (ci !== cycleIndex) { resetBlocks(); cycleIndex = ci; }
var t = sec - ci * LOOP;
var settled = 0;
for (var i = 0; i < BLOCKS; i++) {
var b = blocks[i];
if (b.state === 'waiting') {
if (t >= i * DROP_INTERVAL) { b.state = 'falling'; b.y = -blockH; b.vy = 0; }
}
if (b.state === 'falling') {
b.vy += GRAVITY * dt;
b.y += b.vy * dt;
var floorY = (H - margin) - (i + 1) * blockH;
if (b.y >= floorY) {
b.y = floorY;
b.vy = -b.vy * DAMPING;
if (Math.abs(b.vy) < REST_V) { b.state = 'settled'; b.vy = 0; }
}
}
if (b.state === 'settled') settled++;
}
return settled;
}
function draw(sec) {
var settled = step(sec);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = FLOOR;
ctx.fillRect(0, H - margin, W, margin);
var cx = W * 0.5;
var r = 6;
for (var i = 0; i < BLOCKS; i++) {
var b = blocks[i];
if (b.state === 'waiting') continue;
var x = cx - blockW / 2;
var y = b.y;
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + blockW, y, x + blockW, y + blockH, r);
ctx.arcTo(x + blockW, y + blockH, x, y + blockH, r);
ctx.arcTo(x, y + blockH, x, y, r);
ctx.arcTo(x, y, x + blockW, y, r);
ctx.closePath();
ctx.fill();
}
var pct = Math.round(settled / BLOCKS * 100);
pctEl.textContent = pct + '%';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var paused = false;
var t0 = 0;
var raf = 0;
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() {
t0 = performance.now();
lastSec = null; cycleIndex = -1;
resetBlocks();
draw(0);
}
field.addEventListener('click', function () { togglePause(); });
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
});
window.addEventListener('resize', function () {
fit();
if (paused || reduce) draw(((performance.now() - t0) / 1000));
});
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
t0 = performance.now();
fit();
if (reduce) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}// GravityDrop — vanilla 판(run/395/demo/03-canvas-loader-gravity-drop-stack-height/index.html) 의 React 포트.
// 블록 8개를 하나씩 떨어뜨려 감쇠 상수(0.92, 훅 고정)로 튕김을 줄이며 쌓는다. 쌓인 블록 수가 진행률.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GravityDrop.module.scss";
export type GravityDropProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type BlockState = "waiting" | "falling" | "settled";
type BlockT = { y: number; vy: number; state: BlockState };
const BLOCKS = 8;
const GRAVITY = 1700;
const DAMPING = 0.92; // 튕김 감쇠 상수(훅 고정값) — gravity-bounce-damping
const DROP_INTERVAL = 0.3;
const REST_V = 40;
const LOOP = BLOCKS * DROP_INTERVAL + 0.5;
const FIELD_LABEL = "중력 블록 쌓기 실험판";
export default function GravityDrop({
duration = "2.9s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#c93000", // vanilla $color: $subject-orange-deep
}: GravityDropProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1, blockH: 0, blockW: 0, margin: 0 }) as Box<{ w: number; h: number; dpr: number; blockH: number; blockW: number; margin: number }>;
const blocksRef = useRef<BlockT[]>([]) as Box<BlockT[]>;
const timeRef = useRef({ last: null as number | null, cycle: -1 }) as Box<{ last: number | null; cycle: number }>;
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function resetBlocks(): void {
const blockH = sizeRef.current.blockH;
const list: BlockT[] = [];
for (let i = 0; i < BLOCKS; i++) list.push({ y: -blockH, vy: 0, state: "waiting" });
blocksRef.current = list;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const size = sizeRef.current;
size.w = r.width; size.h = r.height; size.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
size.blockH = (r.height - 10) / (BLOCKS + 1.4);
size.blockW = Math.min(r.width * 0.42, size.blockH * 2.6);
size.margin = (r.height - 10) - BLOCKS * size.blockH;
resetBlocks();
}
function step(sec: number): number {
const time = timeRef.current;
if (time.last === null) time.last = sec;
const dt = Math.min(0.05, Math.max(0, sec - time.last));
time.last = sec;
const ci = Math.floor(sec / LOOP);
if (ci !== time.cycle) { resetBlocks(); time.cycle = ci; }
const t = sec - ci * LOOP;
const size = sizeRef.current;
const list = blocksRef.current;
let settled = 0;
for (let i = 0; i < BLOCKS; i++) {
const b = list[i];
if (b.state === "waiting") {
if (t >= i * DROP_INTERVAL) { b.state = "falling"; b.y = -size.blockH; b.vy = 0; }
}
if (b.state === "falling") {
b.vy += GRAVITY * dt;
b.y += b.vy * dt;
const floorY = (size.h - size.margin) - (i + 1) * size.blockH;
if (b.y >= floorY) {
b.y = floorY;
b.vy = -b.vy * DAMPING;
if (Math.abs(b.vy) < REST_V) { b.state = "settled"; b.vy = 0; }
}
}
if (b.state === "settled") settled++;
}
return settled;
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const settled = step(sec);
const size = sizeRef.current;
const W = size.w, H = size.h;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), FLOOR = cssv("--floor");
const PALETTE = [cssv("--c1"), cssv("--c2"), cssv("--c3"), cssv("--c4")];
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = FLOOR;
ctx.fillRect(0, H - size.margin, W, size.margin);
const cx = W * 0.5;
const r = 6;
const list = blocksRef.current;
for (let i = 0; i < BLOCKS; i++) {
const b = list[i];
if (b.state === "waiting") continue;
const x = cx - size.blockW / 2;
const y = b.y;
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + size.blockW, y, x + size.blockW, y + size.blockH, r);
ctx.arcTo(x + size.blockW, y + size.blockH, x, y + size.blockH, r);
ctx.arcTo(x, y + size.blockH, x, y, r);
ctx.arcTo(x, y, x + size.blockW, y, r);
ctx.closePath();
ctx.fill();
}
const pct = Math.round(settled / BLOCKS * 100);
if (pctRef.current) pctRef.current.textContent = pct + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void {
t0Ref.current = performance.now();
timeRef.current.last = null;
timeRef.current.cycle = -1;
resetBlocks();
draw(0);
}
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void {
togglePause();
}
function onResize(): void {
fit();
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
t0Ref.current = performance.now();
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>적재</span></b>
</div>
</div>
</main>
);
}03 Loader a Gravity Drop-height Value Stacks — Measured area 6.757% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Loading Animation Patterns, Canvas Physics
The post this demo lives in
9 CSS Loading Animation Patterns, Canvas PhysicsA css loading animation: A 0.92 damping constant shrinks the gravity loader's bounce; the automaton loader turns one rul See all demos in this post
If you like this effect - similar demos
Loaders04 Loader Whose Stacked Rings Narrow Their Tilt Gap as the Value RisesCSS 3D Cube Animation Loaders, 9 Patterns
Loaders08 A Loader That Stacks Layers Behind the Logo to Grow Its Depth With ProgressLogo Loading Animation: 9 Progress-Driven Marks
Loaders09 Keyboard-bar hint — an indicator repositioned by a virtual keyboard height value9 Typing Indicator CSS Patterns (Chat UI)