02Loader an Arrived-particle Count Value Fills
The ratio of particles that have arrived at the center to the total scattered count becomes the progress value, and the center disc brightens as more particles converge.
Best forAI response wait screens and dashboard data-aggregation loaders
createRadialGradientglobalAlphaPAPER_FADE

<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="Particle swarm convergence 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">Converge</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 입자 군집 수렴 로더 — 흩어진 입자 중 중심에 도착한 개수/전체 개수가 진행률이다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-yellow);
.ld {
--paper: #{$subject-cream};
--paper-fade: #{rgba($subject-cream, .16)};
--ink: #{$subject-ink};
--dot: #{rgba($subject-ink, .38)};
--core: #{$subject-lilac};
--line: #{rgba($subject-lilac, .3)};
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-blue-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-blue-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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #fff7e6;
--paper-fade: rgba(255, 247, 230, 0.16);
--ink: #17141a;
--dot: rgba(23, 20, 26, 0.38);
--core: #b9a0ff;
--line: rgba(185, 160, 255, 0.3);
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: #0a4fe8;
}
.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 #0a4fe8;
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;
}
}// 입자 군집 Converge 로더 — 무작위로 흩어진 입자 중 중심에 도착한 개수를 전체 개수로 나눈 비율이 진행률이다.
// 흩어진 시작 위치·도착 순서는 고정 Seeds LCG 로 정해 항상 같은 그림이 나온다(Math.random 0).
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 PAPER_FADE = css.getPropertyValue('--paper-fade').trim();
var INK = css.getPropertyValue('--ink').trim();
var DOT = css.getPropertyValue('--dot').trim();
var CORE = css.getPropertyValue('--core').trim();
var LINE = css.getPropertyValue('--line').trim();
function makeRng(seed) {
var s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
var N = 42;
var LOOP = 1.9;
var rng = makeRng(20260923);
var order = [];
for (var oi = 0; oi < N; oi++) order.push(oi);
for (var oj = N - 1; oj > 0; oj--) { // Fisher-Yates — 도착 순서를 고정 시드로 뒤섞는다
var ok = Math.floor(rng() * (oj + 1));
var tmp = order[oj]; order[oj] = order[ok]; order[ok] = tmp;
}
var particles = [];
for (var i = 0; i < N; i++) {
var ang = rng() * Math.PI * 2;
var rad = 0.34 + rng() * 0.62; // 정규화 반지름(0.34~0.96) — 가장자리 쪽에 흩어진다
particles.push({
hx: 0.5 + Math.cos(ang) * rad * 0.5, // 흩어진 자리(정규화 0~1)
hy: 0.5 + Math.sin(ang) * rad * 0.5,
px: 0, py: 0,
rank: order[i],
jf: 0.6 + rng() * 1.1, // 각자 다른 흔들림 진동수
jp: rng() * Math.PI * 2
});
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var paused = false;
var t0 = 0;
var raf = 0;
var inited = false;
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);
if (!inited) {
for (var i = 0; i < N; i++) { particles[i].px = particles[i].hx * W; particles[i].py = particles[i].hy * H; }
inited = true;
}
}
function draw(sec) {
// 완전히 지우지 않고 옅은 판 색을 덧칠한다 — 입자가 지나간 자리에 꼬리가 남아
// 군집이 살아 움직이는 느낌을 준다(궤적 잔상, 매 프레임 픽셀이 크게 바뀐다).
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
var t = sec % LOOP;
var p = t / LOOP; // 0→1 진행률 — 도착 입자 비율의 목표값
var arrived = Math.floor(p * N);
var cx = W * 0.5, cy = H * 0.5;
for (var i = 0; i < N; i++) {
var q = particles[i];
var tx, ty;
if (q.rank < arrived) {
var orbit = 5 + 3 * Math.sin(sec * q.jf + q.jp);
tx = cx + Math.cos(sec * 1.4 + q.jp) * orbit;
ty = cy + Math.sin(sec * 1.4 + q.jp) * orbit;
} else {
tx = q.hx * W + Math.sin(sec * q.jf + q.jp) * 4;
ty = q.hy * H + Math.cos(sec * q.jf * 0.8 + q.jp) * 4;
}
q.px += (tx - q.px) * 0.14;
q.py += (ty - q.py) * 0.14;
}
// 도착한 입자에서 중심으로 옅은 실선
ctx.strokeStyle = LINE;
ctx.lineWidth = 1;
ctx.beginPath();
for (var j = 0; j < N; j++) {
var qj = particles[j];
if (qj.rank < arrived) { ctx.moveTo(qj.px, qj.py); ctx.lineTo(cx, cy); }
}
ctx.stroke();
// 중심 원 — 도착 비율이 밝기·반지름을 함께 키운다
var frac = arrived / N;
var coreR = 10 + frac * 22;
var glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * 1.8);
glow.addColorStop(0, CORE);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.35 + frac * 0.5;
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, coreR * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
ctx.fill();
// 입자
for (var k = 0; k < N; k++) {
var qk = particles[k];
ctx.fillStyle = qk.rank < arrived ? CORE : DOT;
ctx.beginPath();
ctx.arc(qk.px, qk.py, qk.rank < arrived ? 4 : 3.2, 0, Math.PI * 2);
ctx.fill();
}
var pct = Math.round(frac * 100);
pctEl.textContent = pct + '%';
}
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() {
t0 = performance.now();
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();
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
if (reduce) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}// ParticleSwarm — vanilla 판(run/395/demo/02-canvas-loader-particle-swarm-converge-count/index.html) 의 React 포트.
// 흩어진 입자 중 중심에 도착한 개수/전체 개수가 진행률이다. 시작 위치·도착 순서는 고정 시드 LCG.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ParticleSwarm.module.scss";
export type ParticleSwarmProps = {
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 Particle = { hx: number; hy: number; px: number; py: number; rank: number; jf: number; jp: number };
const N = 42;
const LOOP = 1.9;
const FIELD_LABEL = "입자 군집 수렴 실험판";
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
function makeParticles(): Particle[] {
const rng = makeRng(20260923);
const order: number[] = [];
for (let i = 0; i < N; i++) order.push(i);
for (let j = N - 1; j > 0; j--) {
const k = Math.floor(rng() * (j + 1));
const tmp = order[j]; order[j] = order[k]; order[k] = tmp;
}
const list: Particle[] = [];
for (let i = 0; i < N; i++) {
const ang = rng() * Math.PI * 2;
const rad = 0.34 + rng() * 0.62;
list.push({
hx: 0.5 + Math.cos(ang) * rad * 0.5,
hy: 0.5 + Math.sin(ang) * rad * 0.5,
px: 0, py: 0,
rank: order[i],
jf: 0.6 + rng() * 1.1,
jp: rng() * Math.PI * 2,
});
}
return list;
}
export default function ParticleSwarm({
duration = "1.9s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff", // vanilla $color: $subject-lilac
}: ParticleSwarmProps) {
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, inited: false }) as Box<{ w: number; h: number; dpr: number; inited: boolean }>;
const particlesRef = useRef(makeParticles()) as Box<Particle[]>;
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 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);
if (!size.inited) {
const list = particlesRef.current;
for (let i = 0; i < N; i++) { list[i].px = list[i].hx * r.width; list[i].py = list[i].hy * r.height; }
size.inited = true;
}
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER_FADE = cssv("--paper-fade"), DOT = cssv("--dot"), CORE = cssv("--core"), LINE = cssv("--line");
// 완전히 지우지 않고 옅은 판 색을 덧칠한다 — 입자 궤적 잔상이 남는다
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
const t = sec % LOOP;
const p = t / LOOP;
const arrived = Math.floor(p * N);
const cx = W * 0.5, cy = H * 0.5;
const list = particlesRef.current;
for (let i = 0; i < N; i++) {
const q = list[i];
let tx: number, ty: number;
if (q.rank < arrived) {
const orbit = 5 + 3 * Math.sin(sec * q.jf + q.jp);
tx = cx + Math.cos(sec * 1.4 + q.jp) * orbit;
ty = cy + Math.sin(sec * 1.4 + q.jp) * orbit;
} else {
tx = q.hx * W + Math.sin(sec * q.jf + q.jp) * 4;
ty = q.hy * H + Math.cos(sec * q.jf * 0.8 + q.jp) * 4;
}
q.px += (tx - q.px) * 0.14;
q.py += (ty - q.py) * 0.14;
}
ctx.strokeStyle = LINE;
ctx.lineWidth = 1;
ctx.beginPath();
for (let j = 0; j < N; j++) {
const qj = list[j];
if (qj.rank < arrived) { ctx.moveTo(qj.px, qj.py); ctx.lineTo(cx, cy); }
}
ctx.stroke();
const frac = arrived / N;
const coreR = 10 + frac * 22;
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * 1.8);
glow.addColorStop(0, CORE);
glow.addColorStop(1, "rgba(0,0,0,0)");
ctx.globalAlpha = 0.35 + frac * 0.5;
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, coreR * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
ctx.fill();
for (let k = 0; k < N; k++) {
const qk = list[k];
ctx.fillStyle = qk.rank < arrived ? CORE : DOT;
ctx.beginPath();
ctx.arc(qk.px, qk.py, qk.rank < arrived ? 4 : 3.2, 0, Math.PI * 2);
ctx.fill();
}
const pct = Math.round(frac * 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();
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();
const root0 = rootRef.current;
if (root0) {
const paper = getComputedStyle(root0).getPropertyValue("--paper").trim();
ctxRef.current.fillStyle = paper;
ctxRef.current.fillRect(0, 0, sizeRef.current.w, sizeRef.current.h);
}
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>
);
}02 Loader an Arrived-particle Count Value Fills — Measured area 15.628% · 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
Loaders05 Icon progress ring — an indicator whose circular progress value fills with the pulled distance and morphs into a checkmark once completePull to Refresh CSS: 9 Loading Patterns
Loaders05 Extraction progress and form values changing together — while the extraction progress value fills, a form value changes along with itDownload Progress Bar: 9 Loading UI Patterns
Loaders06 Charge-hold-release toggle — a value slowly fills while held and empties all at once on releaseCSS Progress Bar: 9 Game-Style Fill Patterns