08Balls Pouring In From Above and Stacking on the Floor
New balls are created at the top on a steady interval and dropped, with each landing position recalculated so it never overlaps an earlier ball, stacking them neatly on the floor.
Best forBackground scene that fills an empty or completed state
SPAWN_INTERVAL_MSSEPARATE_ITERSHOLD_MS

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">Balls keep dropping from above at a fixed interval, and whenever two overlap, a push-apart calculation separates them so they stack up on the floor.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.bp {
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;
background: $stage-paper;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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);
}
.bp {
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);
background: #ffffff;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: SPAWN_INTERVAL_MS 마다 새 공을 위에서 떨어뜨린다. 중력으로 낙하하다 바닥이나
// 다른 공과 겹치면 두 원의 중심 거리와 반지름 합을 비교해 겹친 만큼을 질량 비로
// 나눠 밀어내는 위치 보정을 매 프레임 반복한다(단순 충돌 한 번이 아니라 여러 쌍을
// 계속 풀어 쌓임을 만든다). 개수가 MAX_BALLS 에 닿으면 잠시 멈췄다가 전부 지우고
// 다시 쌓기 시작한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var GRAVITY = 640;
var FLOOR_RESTITUTION = 0.28;
var SPAWN_INTERVAL_MS = 190;
var MAX_BALLS = 13;
var HOLD_MS = 480;
var SEPARATE_ITERS = 4;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427081);
var COLORS = ['#6ec8ff', '#4cd4a6', '#b9a0ff', '#ff9ebb', '#ffcf7a', '#8fd7ff'];
var balls = [];
var lastSpawn = -SPAWN_INTERVAL_MS;
var holdSince = null;
function spawn(nowMs) {
var r = 9 + rand() * 8;
balls.push({
x: r + rand() * (CALC_W - r * 2),
y: -r - rand() * 20,
vy: 40,
vx: (rand() - 0.5) * 20,
r: r,
color: COLORS[balls.length % COLORS.length],
squash: 0
});
}
function resetAll() {
balls = [];
lastSpawn = -SPAWN_INTERVAL_MS;
holdSince = null;
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
if (balls.length < MAX_BALLS && holdSince === null && nowMs - lastSpawn >= SPAWN_INTERVAL_MS) {
spawn(nowMs);
lastSpawn = nowMs;
}
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.vy += GRAVITY * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
b.vx -= b.vx * 0.6 * dtS;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * 0.5; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * 0.5; }
var floor = CALC_H - b.r;
if (b.y > floor) {
b.y = floor;
if (Math.abs(b.vy) > 30) b.squash = Math.min(0.8, Math.abs(b.vy) / 320);
b.vy = -b.vy * FLOOR_RESTITUTION;
if (Math.abs(b.vy) < 22) b.vy = 0;
}
b.squash = Math.max(0, b.squash - dtS * 3);
}
for (var iter = 0; iter < SEPARATE_ITERS; iter += 1) {
for (var a = 0; a < balls.length; a += 1) {
for (var c = a + 1; c < balls.length; c += 1) {
var ba = balls[a], bc = balls[c];
var dx = bc.x - ba.x, dy = bc.y - ba.y;
var dist = Math.hypot(dx, dy) || 0.001;
var minDist = ba.r + bc.r;
if (dist < minDist) {
var overlap = (minDist - dist) / 2;
var nx = dx / dist, ny = dy / dist;
ba.x -= nx * overlap; ba.y -= ny * overlap;
bc.x += nx * overlap; bc.y += ny * overlap;
if (ba.y + ba.r > CALC_H) ba.y = CALC_H - ba.r;
if (bc.y + bc.r > CALC_H) bc.y = CALC_H - bc.r;
}
}
}
}
if (balls.length >= MAX_BALLS) {
if (holdSince === null) holdSince = nowMs;
else if (nowMs - holdSince > HOLD_MS) { resetAll(); }
}
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var sqY = 1 - b.squash * 0.4, sqX = 1 + b.squash * 0.55;
ctx.save();
ctx.translate(px, py + pr * b.squash * 0.4);
ctx.scale(sqX, sqY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
ctx.fillStyle = 'rgba(23,20,26,0.5)';
ctx.font = '11px sans-serif';
ctx.fillText(balls.length + ' / ' + MAX_BALLS, 8, ch - 8);
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
resetAll();
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
stepSim(FIXED_DT, elapsed);
acc -= FIXED_DT;
}
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsSpawnCascadeStack.module.scss";
type Ball = { x: number; y: number; vy: number; vx: number; r: number; color: string; squash: number };
export default function BallPhysicsSpawnCascadeStack() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 320, CALC_H = 200;
const GRAVITY = 640;
const FLOOR_RESTITUTION = 0.28;
const SPAWN_INTERVAL_MS = 190;
const MAX_BALLS = 13;
const HOLD_MS = 480;
const SEPARATE_ITERS = 4;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427081);
const COLORS = ["#6ec8ff", "#4cd4a6", "#b9a0ff", "#ff9ebb", "#ffcf7a", "#8fd7ff"];
let balls: Ball[] = [];
let lastSpawn = -SPAWN_INTERVAL_MS;
let holdSince: number | null = null;
function spawn() {
const r = 9 + rand() * 8;
balls.push({
x: r + rand() * (CALC_W - r * 2),
y: -r - rand() * 20,
vy: 40,
vx: (rand() - 0.5) * 20,
r, color: COLORS[balls.length % COLORS.length], squash: 0
});
}
function resetAll() {
balls = [];
lastSpawn = -SPAWN_INTERVAL_MS;
holdSince = null;
}
resetAll();
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
if (balls.length < MAX_BALLS && holdSince === null && nowMs - lastSpawn >= SPAWN_INTERVAL_MS) {
spawn();
lastSpawn = nowMs;
}
for (const b of balls) {
b.vy += GRAVITY * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
b.vx -= b.vx * 0.6 * dtS;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * 0.5; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * 0.5; }
const floor = CALC_H - b.r;
if (b.y > floor) {
b.y = floor;
if (Math.abs(b.vy) > 30) b.squash = Math.min(0.8, Math.abs(b.vy) / 320);
b.vy = -b.vy * FLOOR_RESTITUTION;
if (Math.abs(b.vy) < 22) b.vy = 0;
}
b.squash = Math.max(0, b.squash - dtS * 3);
}
for (let iter = 0; iter < SEPARATE_ITERS; iter += 1) {
for (let a = 0; a < balls.length; a += 1) {
for (let c = a + 1; c < balls.length; c += 1) {
const ba = balls[a], bc = balls[c];
const dx = bc.x - ba.x, dy = bc.y - ba.y;
const dist = Math.hypot(dx, dy) || 0.001;
const minDist = ba.r + bc.r;
if (dist < minDist) {
const overlap = (minDist - dist) / 2;
const nx = dx / dist, ny = dy / dist;
ba.x -= nx * overlap; ba.y -= ny * overlap;
bc.x += nx * overlap; bc.y += ny * overlap;
if (ba.y + ba.r > CALC_H) ba.y = CALC_H - ba.r;
if (bc.y + bc.r > CALC_H) bc.y = CALC_H - bc.r;
}
}
}
}
if (balls.length >= MAX_BALLS) {
if (holdSince === null) holdSince = nowMs;
else if (nowMs - holdSince > HOLD_MS) resetAll();
}
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (const b of balls) {
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const sqY = 1 - b.squash * 0.4, sqX = 1 + b.squash * 0.55;
ctx!.save();
ctx!.translate(px, py + pr * b.squash * 0.4);
ctx!.scale(sqX, sqY);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
ctx!.fillStyle = "rgba(23,20,26,0.5)";
ctx!.font = "11px sans-serif";
ctx!.fillText(balls.length + " / " + MAX_BALLS, 8, ch - 8);
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT, elapsed); acc -= FIXED_DT; }
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>일정 간격으로 공을 위에서 계속 떨어뜨리고, 겹친 두 공은 서로 밀어내는 계산으로 떼어놓아 바닥에 쌓인다</p>
</div>
</main>
);
}08 Balls Pouring In From Above and Stacking on the Floor — Measured area 33.263% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Bouncing Ball Animation Patterns With Physics
The post this demo lives in
9 Bouncing Ball Animation Patterns With PhysicsThis bouncing ball animation set has nine builds below. Ball 01 trades velocity through a 0.82 restitution coefficient, See all demos in this post
If you like this effect - similar demos
game08 A Tree View Where Moves Branch From the Current Position and Branch Thickness Follows the Number of Endings BelowCSS Tic Tac Toe: 9 Boards That Count Ahead
game09 A Hedge-Garden Maze Seen From Above, With Fog From Center Distance and Shadows From Height and Sun AngleMaze Generator: 9 Carve-and-Solve Scenes
game04 A Spin Transfer That Curves the Ball From Paddle SpeedBrick Breaker Game: 9 Canvas Scenes