09Loader a Survival-rule Value Fills via Cellular Automaton
A survival rule is applied to every grid cell, computing the share of lit cells each generation and advancing generations until that share reaches the progress target, filling the loader.
Best forBulk data-processing screens and background-sync loaders
BIRTHneighborCountstepGen

<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="Cellular automaton fill 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">Fill</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 셀룰러 오토마타 로더 — 씨앗 몇 개에서 "이웃 3개 이상이면 태어나고 한 번 살면 계속 산다"는
// 생존 규칙 하나로 세대를 진행시킨다. 움직임은 전부 캔버스 rAF 루프 안에 있다(@keyframes 0개 — 077 선례).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-ink);
.ld {
--paper: #{$subject-ink};
--cell1: #{$subject-mint};
--cell2: #{$subject-blue-light};
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-cream;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-mint; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-mint; 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-cream, .14);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $subject-mint; }
.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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #17141a;
--cell1: #4cd4a6;
--cell2: #5b8cf8;
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: #fff7e6;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #4cd4a6;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #4cd4a6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #4cd4a6;
}
.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;
}
}// 셀룰러 오토마타 로더 — 씨앗 몇 개에서 시작해 "이웃 3개 이상이면 태어나고, 한 번 살면 계속 산다"는
// 생존 규칙 하나로 세대를 진행시킨다. 그 세대의 살아있는 칸 비율(채워진 칸/전체 칸)이 곧 진행률이다.
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 CELL1 = css.getPropertyValue('--cell1').trim();
var CELL2 = css.getPropertyValue('--cell2').trim();
function makeRng(seed) {
var s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
var GX = 26, GY = 16;
var TOTAL = GX * GY;
var BIRTH = 3; // 생존(탄생) 규칙 상수(훅 고정값) — cellular-automaton-fill-percent, 이웃 수가 이 이상이면 태어난다
var INIT_SEEDS = 5;
var GENS = 16;
var STEP_INTERVAL = 0.15;
var LOOP = GENS * STEP_INTERVAL;
var rng = makeRng(20260927);
function setAlive(g, x, y) {
if (x < 0 || y < 0 || x >= GX || y >= GY) return;
g[y * GX + x] = 1;
}
function makeSeedGrid() {
var g = new Uint8Array(TOTAL);
for (var i = 0; i < INIT_SEEDS; i++) {
var x = Math.floor(rng() * GX), y = Math.floor(rng() * GY);
// 씨앗마다 L자 3칸 뭉치 — 홀로 떨어진 1칸은 이웃 3개 조건을 영영 못 채워 자라지 않는다
setAlive(g, x, y);
setAlive(g, x + 1, y);
setAlive(g, x, y + 1);
}
return g;
}
function neighborCount(g, x, y) {
var c = 0;
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
var nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= GX || ny >= GY) continue;
if (g[ny * GX + nx]) c++;
}
}
return c;
}
function stepGen(prev) {
var next = new Uint8Array(TOTAL);
for (var y = 0; y < GY; y++) {
for (var x = 0; x < GX; x++) {
var idx = y * GX + x;
if (prev[idx]) { next[idx] = 1; continue; }
next[idx] = neighborCount(prev, x, y) >= BIRTH ? 1 : 0;
}
}
return next;
}
var generations = [makeSeedGrid()];
for (var gi = 1; gi <= GENS; gi++) generations.push(stepGen(generations[gi - 1]));
var shimF = new Float32Array(TOTAL), shimP = new Float32Array(TOTAL);
(function () {
var srng = makeRng(778899);
for (var i = 0; i < TOTAL; i++) { shimF[i] = 1.4 + srng() * 2.2; shimP[i] = srng() * Math.PI * 2; }
})();
var W = 0, H = 0, dpr = 1, cw = 0, ch = 0, ox = 0, oy = 0;
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);
cw = W / GX; ch = H / GY;
}
function draw(sec) {
var t = sec % LOOP;
var gen = Math.min(GENS, Math.floor(t / STEP_INTERVAL));
var grid = generations[gen];
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var alive = 0;
for (var y = 0; y < GY; y++) {
for (var x = 0; x < GX; x++) {
var idx = y * GX + x;
if (!grid[idx]) continue;
alive++;
var shim = 0.58 + 0.42 * Math.sin(sec * shimF[idx] + shimP[idx]);
ctx.fillStyle = (idx % 2 === 0) ? CELL1 : CELL2;
ctx.globalAlpha = shim;
ctx.fillRect(x * cw + 1, y * ch + 1, cw - 2, ch - 2);
}
}
ctx.globalAlpha = 1;
pctEl.textContent = Math.round(alive / TOTAL * 100) + '%';
}
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();
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);
}// CellAutomaton — vanilla 판(run/395/demo/09-canvas-loader-cellular-automaton-fill-percent/index.html) 의 React 포트.
// 씨앗 몇 개에서 시작해 "이웃 3개 이상이면 태어나고, 한 번 살면 계속 산다"는 생존 규칙 하나로
// 세대를 진행시킨다. 그 세대의 살아있는 칸 비율(채워진 칸/전체 칸)이 곧 진행률이다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CellAutomaton.module.scss";
export type CellAutomatonProps = {
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 };
function makeRng(seed: number) {
let s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
const GX = 26, GY = 16;
const TOTAL = GX * GY;
const BIRTH = 3; // 생존(탄생) 규칙 상수(훅 고정값) — cellular-automaton-fill-percent, 이웃 수가 이 이상이면 태어난다
const INIT_SEEDS = 5;
const GENS = 16;
const STEP_INTERVAL = 0.15;
const LOOP = GENS * STEP_INTERVAL;
const FIELD_LABEL = "셀룰러 오토마타 채움 실험판";
function setAlive(g: Uint8Array, x: number, y: number): void {
if (x < 0 || y < 0 || x >= GX || y >= GY) return;
g[y * GX + x] = 1;
}
function makeSeedGrid(): Uint8Array {
const rng = makeRng(20260927);
const g = new Uint8Array(TOTAL);
for (let i = 0; i < INIT_SEEDS; i++) {
const x = Math.floor(rng() * GX), y = Math.floor(rng() * GY);
// 씨앗마다 L자 3칸 뭉치 — 홀로 떨어진 1칸은 이웃 3개 조건을 영영 못 채워 자라지 않는다
setAlive(g, x, y);
setAlive(g, x + 1, y);
setAlive(g, x, y + 1);
}
return g;
}
function neighborCount(g: Uint8Array, x: number, y: number): number {
let c = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= GX || ny >= GY) continue;
if (g[ny * GX + nx]) c++;
}
}
return c;
}
function stepGen(prev: Uint8Array): Uint8Array {
const next = new Uint8Array(TOTAL);
for (let y = 0; y < GY; y++) {
for (let x = 0; x < GX; x++) {
const idx = y * GX + x;
if (prev[idx]) { next[idx] = 1; continue; }
next[idx] = neighborCount(prev, x, y) >= BIRTH ? 1 : 0;
}
}
return next;
}
function makeGenerations(): Uint8Array[] {
const gens = [makeSeedGrid()];
for (let gi = 1; gi <= GENS; gi++) gens.push(stepGen(gens[gi - 1]));
return gens;
}
function makeShimmer(): { f: Float32Array; p: Float32Array } {
const rng = makeRng(778899);
const f = new Float32Array(TOTAL), p = new Float32Array(TOTAL);
for (let i = 0; i < TOTAL; i++) { f[i] = 1.4 + rng() * 2.2; p[i] = rng() * Math.PI * 2; }
return { f, p };
}
export default function CellAutomaton({
duration = "2.4s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#7de0c2", // vanilla $color: $subject-mint
}: CellAutomatonProps) {
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, cw: 0, ch: 0 }) as Box<{ w: number; h: number; dpr: number; cw: number; ch: number }>;
const gensRef = useRef<Uint8Array[]>([]) as Box<Uint8Array[]>;
const shimRef = useRef<{ f: Float32Array; p: Float32Array } | null>(null) as Box<{ f: Float32Array; p: Float32Array } | null>;
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;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) 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);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
size.cw = r.width / GX; size.ch = r.height / GY;
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const shim = shimRef.current;
if (!ctx || !root || !shim) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const gen = Math.min(GENS, Math.floor(t / STEP_INTERVAL));
const grid = gensRef.current[gen];
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), CELL1 = cssv("--cell1"), CELL2 = cssv("--cell2");
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
let alive = 0;
for (let y = 0; y < GY; y++) {
for (let x = 0; x < GX; x++) {
const idx = y * GX + x;
if (!grid[idx]) continue;
alive++;
const s = 0.58 + 0.42 * Math.sin(sec * shim.f[idx] + shim.p[idx]);
ctx.fillStyle = idx % 2 === 0 ? CELL1 : CELL2;
ctx.globalAlpha = s;
ctx.fillRect(x * size.cw + 1, y * size.ch + 1, size.cw - 2, size.ch - 2);
}
}
ctx.globalAlpha = 1;
if (pctRef.current) pctRef.current.textContent = Math.round((alive / TOTAL) * 100) + "%";
}
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;
gensRef.current = makeGenerations();
shimRef.current = makeShimmer();
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>
);
}09 Loader a Survival-rule Value Fills via Cellular Automaton — Measured area 33.618% · 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
Loaders06 Loader Whose Rotating Polygon Prism Fills Faces in the Order They PassCSS 3D Cube Animation Loaders, 9 Patterns
Loaders02 A Loader That Fills the Logo Shape With Water and a Sloshing SurfaceLogo Loading Animation: 9 Progress-Driven Marks
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