03낙하 높이 값이 쌓는 중력 블록 로더
진행률만큼의 블록 수를 정해 위에서 하나씩 중력 가속도로 떨어뜨리고, 바닥에 닿을 때마다 튕기는 높이를 감쇠시켜 쌓인 블록 수로 진행률을 보여준다.
쓰임게임 리소스 로딩 화면·온보딩 대기 화면
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>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="중력 블록 쌓기 실험판" 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">적재</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 낙하 높이 값이 쌓는 중력 블록 로더 — 측정 누적면적 6.757% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로
이 데모가 속한 글
캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로로딩 애니메이션 효과 를 캔버스로 만들 때 값 하나만 정해두면 나머지는 계산이 다 합니다. 감쇠 상수 0.92 가 중력 로더의 튕김 높이를 줄이고, 셀룰러 오토마타는 생존 규칙 값 하나로 세대마다 채워진 셀 비율을 이 글의 데모 전부 보기


