08Particle Background Scaling Up and Down by Simulated Distance
Every particle's z value drifts slightly over time and is re-mapped to a screen size each frame, so an approaching particle keeps growing and a receding one keeps shrinking.
Best forSpace or tech-themed intro background
sizeForDepthzAdvanceSpeedPARTICLE_Z_MAX

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">Every particle's z value drifts slightly over time and is re-mapped to a screen size each frame, so an approaching particle keeps growing and a receding one keeps shrinking</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.d3 {
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;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { 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);
}
.d3 {
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;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 입자마다 zAdvanceSpeed 로 z값이 PARTICLE_Z_MAX 에서 0 쪽으로 시간에 따라 줄어들다가
// 나머지 연산으로 다시 멀리 순환하고, sizeForDepth 가 그 z값을 매 프레임 화면 크기·화면 좌표로
// 다시 환산한다 — 가까워질수록(z 작을수록) 입자가 커지고 화면 중심에서 바깥으로 퍼진다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var CX = CALC_W * 0.5, CY = CALC_H * 0.5;
var PARTICLE_Z_MAX = 120;
var PARTICLE_COUNT = 34;
var FOCAL = 60;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429081);
var particles = [];
for (var i = 0; i < PARTICLE_COUNT; i += 1) {
particles.push({
worldX: (rnd() - 0.5) * 140,
worldY: (rnd() - 0.5) * 90,
zOffset: rnd() * PARTICLE_Z_MAX,
speedScale: 0.6 + rnd() * 0.8,
color: i % 3 === 0 ? '#6ec8ff' : (i % 3 === 1 ? '#b9a0ff' : '#4cd4a6')
});
}
function zAdvanceSpeed(p) {
return 0.012 * p.speedScale;
}
function zAt(p, t) {
var raw = (p.zOffset - t * zAdvanceSpeed(p)) % PARTICLE_Z_MAX;
if (raw < 0) raw += PARTICLE_Z_MAX;
return raw + 1;
}
function sizeForDepth(z) {
var v = (FOCAL / z) * 3.4;
return Math.max(0.8, Math.min(7, v));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var z = zAt(p, t);
var scale = FOCAL / z;
var sizePx = sizeForDepth(z);
var alpha = Math.max(0.18, Math.min(1, scale * 0.55));
var dx = (CX + p.worldX * scale * 0.34) * sx;
var dy = (CY + p.worldY * scale * 0.34) * sy;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(dx, dy, sizePx * sx, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
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;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 130;
for (var w = 0; w < warmSteps; w += 1) elapsed += 20;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dScaleDepthSizing.module.scss";
type Particle = {
worldX: number;
worldY: number;
zOffset: number;
speedScale: number;
color: string;
};
export default function Depth3dScaleDepthSizing() {
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 = 160, CALC_H = 100;
const CX = CALC_W * 0.5, CY = CALC_H * 0.5;
const PARTICLE_Z_MAX = 120;
const PARTICLE_COUNT = 34;
const FOCAL = 60;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429081);
const particles: Particle[] = [];
for (let i = 0; i < PARTICLE_COUNT; i += 1) {
particles.push({
worldX: (rnd() - 0.5) * 140,
worldY: (rnd() - 0.5) * 90,
zOffset: rnd() * PARTICLE_Z_MAX,
speedScale: 0.6 + rnd() * 0.8,
color: i % 3 === 0 ? "#6ec8ff" : (i % 3 === 1 ? "#b9a0ff" : "#4cd4a6")
});
}
function zAdvanceSpeed(p: Particle) {
return 0.012 * p.speedScale;
}
function zAt(p: Particle, t: number) {
let raw = (p.zOffset - t * zAdvanceSpeed(p)) % PARTICLE_Z_MAX;
if (raw < 0) raw += PARTICLE_Z_MAX;
return raw + 1;
}
function sizeForDepth(z: number) {
const v = (FOCAL / z) * 3.4;
return Math.max(0.8, Math.min(7, v));
}
let elapsed = 0;
let cw = 480, ch = 300;
function drawScene(t: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const z = zAt(p, t);
const scale = FOCAL / z;
const sizePx = sizeForDepth(z);
const alpha = Math.max(0.18, Math.min(1, scale * 0.55));
const dx = (CX + p.worldX * scale * 0.34) * sx;
const dy = (CY + p.worldY * scale * 0.34) * sy;
ctx!.globalAlpha = alpha;
ctx!.fillStyle = p.color;
ctx!.beginPath();
ctx!.arc(dx, dy, sizePx * sx, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalAlpha = 1;
}
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;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 130;
for (let w = 0; w < warmSteps; w += 1) elapsed += 20;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>입자마다 z값을 시간에 따라 조금씩 바꾸고 그 값을 화면 크기로 매 프레임 다시 환산해, 다가오는 입자는 점점 커지고 멀어지는 입자는 점점 작아진다</p>
</div>
</main>
);
}08 Particle Background Scaling Up and Down by Simulated Distance — Measured area 30.615% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS 3D Background Animation Depth Layers
The post this demo lives in
9 CSS 3D Background Animation Depth LayersThis css 3d background animation set has nine layers below. Layer 02 loses focus with distance, layer 07 loses shadow st See all demos in this post
If you like this effect - similar demos
Interactions06 Exploded view — a hover that reuses depth order as spread distanceIsometric CSS: 9 True-Projection 3D Dioramas
Interactions09 Hero Deck Scene Combining Rotation, Spring, and Depth StackCard Swipe UI: 9 Physics-Based Deck Patterns
Interactions06 Nested Popovers Whose Shadow and Size Step Up with Each DepthCSS Popover: 9 Ways to Anchor, Flip and Spring