ローディング アニメーション js 9パターン — 物理演算で作る
ローディング アニメーション js の一つの姿: 減衰定数0.92が重力ローダーの跳ね上がる高さを縮め、セルオートマトンローダーは一つの生存ルール値から世代ごとに満たされるセルの割合を計算し、磁場ローダーは軌道半径の値が0に近づいた瞬間に終わる — 同じローダーなのに読み取る計算はそれぞれ違う。
01 canvas-loader-fluid-wave-fill-level
02 canvas-loader-particle-swarm-converge-count
03 canvas-loader-gravity-drop-stack-height
04 canvas-loader-voronoi-cell-grow-seed-count
05 canvas-loader-magnetic-field-pull-orbit
06 canvas-loader-noise-flow-ring-erosion
07 canvas-loader-spring-mesh-ripple-radius
08 canvas-loader-orbit-satellite-arc-sweep
09 canvas-loader-cellular-automaton-fill-percent
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 進行度の値が満ちていく液体波ローダー
- 02 到着した粒子数の値が満たす群れ収束ローダー
- 03 落下高さの値が積み上げる重力ブロックローダー
- 04 シード数の値が広げるボロノイセルローダー
- 05 軌道半径の値が縮める磁場ローダー
- 06 浸食厚さの値が削るノイズリングローダー
- 07 波紋半径の値が広がるバネ格子ローダー
- 08 角度の値が掃くように進む衛星軌道ローダー
- 09 生存ルールの値が満たすセルオートマトンローダー
9という数は適当に選んだ丸い数字ではありません。それぞれが「進行度」を違う方法で表現しています — 上昇する水面の高さ(01)、粒子の到着比率(02)、積み上がったブロックの数(03)、ボロノイ図を埋める種点の数(04)、縮んでいく軌道半径(05)、削られるノイズリングの厚さ(06)、波打つバネ格子の半径(07)、掃くように進む角度(08)、セルオートマトンの生存比率(09)です。単純な1つの数値が形を動かすところから、格子全体が近傍と相談して結果を出すところまで、段階を追って並べています。
デモ
上のグリッドは9つの独立したcanvas要素で、それぞれが自前のrequestAnimationFrameループを2Dコンテキスト上で回しています。どこかのペンを埋め込んだだけのcss ローディング アニメーションのコピペ集ではありません — 各タイルの実際のソースは、この下のパターンごとに全文が印刷されています。タイルをクリックするか、Enterキー・Spaceキーで操作するか、ウィンドウをリサイズすると、そのパターンはその瞬間の進行度値を再計算します。CSSアニメーションのtransitionendを待つ処理はどこにもありません — そもそもCSSアニメーションが下で動いていないからです。
なぜローダーは進行度を自分で計算するのか
CSSキーフレームアニメーションは、透明度・transform・枠線の太さといったプロパティ自身の値を、決まったタイミング曲線に沿って一つの数字から別の数字へ補間することしかできません。到着した粒子の数を数えたり、落下したブロックを高さ順に並べたり、格子の9つの隣接セルに「生きるべきか」を尋ねたりはできません。以下の9つはどれも、水面の高さ・到着数・跳ね返りの高さ・種点の数・軌道半径・浸食の深さ・波紋の半径・掃引角度・生存比率という、1フレームごとに読み取るプレーンなJavaScriptの数値を持っていて、その数値からcanvasのfillとstrokeで直接絵を描いています。つまり進行度はCSSのパーセンテージから読み取るのではなく、物理演算そのものから生まれます。
9つとも本物のMath.randomは使っていません。02の粒子の位相や04の種点の揺れ幅は、ページ読み込み時に一度だけ決まる値(粒子ごとの位相・周波数、あるいは種点の揺れ半径)から作られているので、リロードするたびに同じ動きが再現され、毎回バラバラに崩れることはありません。この記事の裏にある9つのファイルには、ライブラリもWebGLコンテキストもCDNスクリプトも一つもありません。
下の計測値が実際に何を数えているか
下の数値はそれぞれのデモの画面収録の実測値であり、目分量ではありません。累積面積はループ全体でキャンバスのどれだけが変化したか、平均強度は変化した箇所のピクセル差分バイトの平均値、動いたフレームは23回のフレーム間差分のうち実際にピクセルが動いた回数です。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 液体波の水位ローダー | 57.08% | 187.0 | 23/23 |
| 02 | 粒子群収束カウントローダー | 15.628% | 41.7 | 23/23 |
| 03 | 重力落下スタックローダー | 6.757% | 145.2 | 23/23 |
| 04 | ボロノイセル種点成長ローダー | 37.312% | 82.1 | 23/23 |
| 05 | 磁場軌道半径ローダー | 11.671% | 65.7 | 23/23 |
| 06 | ノイズリング浸食ローダー | 8.508% | 99.3 | 23/23 |
| 07 | バネ格子波紋半径ローダー | 22.819% | 58.1 | 23/23 |
| 08 | 衛星軌道スイープローダー | 19.327% | 51.2 | 23/23 |
| 09 | セルオートマトン充填率ローダー | 33.618% | 43.4 | 23/23 |
パターンごとのコード
以下の9つはどれも同じ形を辿ります — 進行度を0〜1の数値として読み取り、物理か場の計算を1つ実行し、結果を2Dコンテキストに描く、という形です。違うのは中間の計算だけです。コードの範囲はデモのソースからそのまま切り出しています。
01進行度の値が満ちていく液体波ローダー
進行度(0〜1)をキャンバスの水面の高さに変換し、2つの正弦波を重ねて表面を揺らすことで、値が上がるほど液体が満ちるようにローダーの内側が埋まっていきます。ファイルアップロード進捗モーダルやアプリのスプラッシュローダーに向いています — パーセンテージだけだと静止して見える場面です。タイルをクリックするか、Enterキー・Spaceキーを押すと、2つの波がもう一度表面を揺らします。

<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 *;
// 액체 파동 로더 — 진행률 0→1 을 캔버스 수면 높이로 매핑하고 사인파 두 개를 더해 표면을 흔든다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
// fluid-wave-frequency 상수(FREQ, JS)가 파동의 공간·시간 진동수를 함께 정한다 — 변형 01 의 조정 대상.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ld {
--paper: #{$subject-cream};
--ink: #{$subject-ink};
--liquid: #{$subject-blue};
--liquid-deep: #{$subject-blue-deep};
--glow: #{rgba($stage-paper, .82)};
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-blue-light; }
.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-light; 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #fff7e6;
--ink: #17141a;
--liquid: #2f6df6;
--liquid-deep: #0a4fe8;
--glow: rgba(255, 255, 255, 0.82);
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: #5b8cf8;
}
.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 #5b8cf8;
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;
}
}// 액체 波紋 로더 — 진행률(0~1)을 캔버스 수면 높이로 매핑하고 사인파 두 개를 겹쳐 표면을 흔든다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다 — @keyframes 0개라야
// 렌더러가 시계를 고정하지 않고 실시간으로 24장을 찍어 파동이 실제로 도는 모습이 담긴다(077 선례).
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 LIQUID = css.getPropertyValue('--liquid').trim();
var LIQUID_DEEP = css.getPropertyValue('--liquid-deep').trim();
var GLOW = css.getPropertyValue('--glow').trim();
var LOOP = 1.6; // 로더 한 바퀴(초) — 채웠다가 다시 빈다(반복 루프)
var FREQ = 0.045; // 波紋 진동수(px 당 라디안) — fluid-wave-frequency, 공간·시간 흔들림을 함께 정한다
var AMP1 = 7, AMP2 = 3.6;
var SPEED1 = 2.1, SPEED2 = -3.0;
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;
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로.
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);
}
function level(p) { return H * (1 - p); }
function draw(sec) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var t = sec % LOOP;
var p = t / LOOP; // 0→1 진행률 — 사면톱니(로더는 반복 루프라 프레임이 계속 차야 정상)
var y0 = level(p);
function waveY(x) {
return y0
+ AMP1 * Math.sin(x * FREQ + sec * SPEED1)
+ AMP2 * Math.sin(x * FREQ * 1.8 + sec * SPEED2);
}
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, waveY(0));
var step = 4;
for (var x = 0; x <= W; x += step) ctx.lineTo(x, waveY(x));
ctx.lineTo(W, H);
ctx.closePath();
var grad = ctx.createLinearGradient(0, y0 - 24, 0, H);
grad.addColorStop(0, LIQUID);
grad.addColorStop(1, LIQUID_DEEP);
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
for (var x2 = 0; x2 <= W; x2 += step) {
var y2 = waveY(x2);
if (x2 === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2);
}
ctx.strokeStyle = GLOW;
ctx.lineWidth = 2;
ctx.stroke();
var pct = Math.round(p * 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();
if (reduce) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}// FluidWave — vanilla 판(run/395/demo/01-canvas-loader-fluid-wave-fill-level/index.html) 의 React 포트.
// 진행률(0~1)을 캔버스 수면 높이로 매핑하고 사인파 두 개를 겹쳐 표면을 흔든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FluidWave.module.scss";
export type FluidWaveProps = {
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 };
const LOOP = 1.6; // 로더 한 바퀴(초)
const FREQ = 0.045; // 파동 진동수(px 당 라디안) — fluid-wave-frequency
const AMP1 = 7, AMP2 = 3.6;
const SPEED1 = 2.1, SPEED2 = -3.0;
const FIELD_LABEL = "액체 파동 진행 실험판";
export default function FluidWave({
duration = "1.6s", // vanilla LOOP
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: FluidWaveProps) {
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 }) as Box<{ w: number; h: number; dpr: 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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
sizeRef.current.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);
}
function level(p: number): number {
return sizeRef.current.h * (1 - p);
}
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 = cssv("--paper"), LIQUID = cssv("--liquid"), LIQUID_DEEP = cssv("--liquid-deep"), GLOW = cssv("--glow");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
const t = sec % LOOP;
const p = t / LOOP;
const y0 = level(p);
const waveY = (x: number) => y0
+ AMP1 * Math.sin(x * FREQ + sec * SPEED1)
+ AMP2 * Math.sin(x * FREQ * 1.8 + sec * SPEED2);
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, waveY(0));
const step = 4;
for (let x = 0; x <= W; x += step) ctx.lineTo(x, waveY(x));
ctx.lineTo(W, H);
ctx.closePath();
const grad = ctx.createLinearGradient(0, y0 - 24, 0, H);
grad.addColorStop(0, LIQUID);
grad.addColorStop(1, LIQUID_DEEP);
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
for (let x2 = 0; x2 <= W; x2 += step) {
const y2 = waveY(x2);
if (x2 === 0) ctx.moveTo(x2, y2); else ctx.lineTo(x2, y2);
}
ctx.strokeStyle = GLOW;
ctx.lineWidth = 2;
ctx.stroke();
const pct = Math.round(p * 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();
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>
);
}01 · 進行度の値が満ちていく液体波ローダー — 計測 累積面積 57.08% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function draw(sec) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var t = sec % LOOP;
var p = t / LOOP; // 0→1 進捗 — ノコギリ波。ループするので値は0から上がるのを繰り返す
var y0 = level(p);
function waveY(x) {
return y0
+ AMP1 * Math.sin(x * FREQ + sec * SPEED1)
+ AMP2 * Math.sin(x * FREQ * 1.8 + sec * SPEED2);
}
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, waveY(0));
var step = 4;
for (var x = 0; x <= W; x += step) ctx.lineTo(x, waveY(x));
ctx.lineTo(W, H);
ctx.closePath();
var grad = ctx.createLinearGradient(0, y0 - 24, 0, H);
grad.addColorStop(0, LIQUID);
grad.addColorStop(1, LIQUID_DEEP);
02到着した粒子数の値が満たす群れ収束ローダー
ランダムに散らばった数十個の粒子のうち中心に到着した数を全体数で割った比率を進行度とし、到着した粒子が増えるほど中心の円が明るくなっていきます。AI応答待機画面やダッシュボードのデータ集計ローダーに向いています — スピナーだけだと実際どれだけ処理が進んだか分かりません。ウィンドウをリサイズしても、群れは同じ到着比率のまま再描画されます。

<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 *;
// 입자 군집 수렴 로더 — 흩어진 입자 중 중심에 도착한 개수/전체 개수가 진행률이다.
// 움직임은 전부 캔버스 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;
}
}// 입자 군집 収束 로더 — 무작위로 흩어진 입자 중 중심에 도착한 개수를 전체 개수로 나눈 비율이 진행률이다.
// 흩어진 시작 위치·도착 순서는 고정 シード 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 · 到着した粒子数の値が満たす群れ収束ローダー — 計測 累積面積 15.628% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
03落下高さの値が積み上げる重力ブロックローダー
進行度に応じたブロック数を決めて上から重力加速度で一つずつ落とし、床に触れるたびに跳ねる高さを減衰させ、積み上がったブロック数で進行度を示します。ゲームのリソース読み込み画面やオンボーディング待機画面に向いています — 物理的に積み上がる様子はバーより実感が湧きます。タイルの上でRキーを押すと、落下を最初からやり直せます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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++;
}
04シード数の値が広げるボロノイセルローダー
進行度に比例して種点の数を増やし、毎フレームボロノイ図を再計算することで、セルが密になるほどローダーの内側が完成したモザイクで埋まっていきます。ポートフォリオページの読み込みや画像ギャラリーのプリローダーに向いています — 完成したモザイクは「単に読み込み中」より「コンテンツが揃った」感じに見えます。タイルをクリックすると、種点の成長を空の格子からやり直します。

<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 *;
// 보로노이 셀 로더 — 씨앗점 개수를 진행률에 비례해 늘리고 매 프레임 최근접-씨앗 격자를 다시 계산한다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-paper);
.ld {
--paper: #{$subject-cream};
--ink: #{$subject-ink};
--c1: #{$subject-blue};
--c2: #{$subject-mint};
--c3: #{$subject-pink};
--c4: #{$subject-lilac};
--seedpt: #{$subject-ink};
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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #fff7e6;
--ink: #17141a;
--c1: #2f6df6;
--c2: #4cd4a6;
--c3: #ff9ebb;
--c4: #b9a0ff;
--seedpt: #17141a;
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;
}
}// 보로노이 셀 로더 — 씨앗점 개수를 진행률에 비례해 늘려 매 프레임 보로노이 다이어그램을
// (격자 표본에서 가장 가까운 씨앗 찾기) 다시 계산한다. 셀이 촘촘해질수록 모자이크가 채워진다.
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 C1 = css.getPropertyValue('--c1').trim();
var C2 = css.getPropertyValue('--c2').trim();
var C3 = css.getPropertyValue('--c3').trim();
var C4 = css.getPropertyValue('--c4').trim();
var SEEDPT = css.getPropertyValue('--seedpt').trim();
var PALETTE = [C1, C2, C3, C4];
function makeRng(seed) {
var s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
var MAXSEEDS = 16;
var LOOP = 2.0;
var CELL = 9; // 격자 표본 한 칸(px) — 작을수록 더 매끄러운 다이어그램
var rng = makeRng(20260924);
var seeds = [];
for (var i = 0; i < MAXSEEDS; i++) {
seeds.push({
hx: 0.08 + rng() * 0.84, hy: 0.1 + rng() * 0.8, px: 0, py: 0,
wf: 0.7 + rng() * 1.1, wp: rng() * Math.PI * 2 // 개별 흔들림 진동수·위상(LCG 고정) — 씨앗 수가 그대로여도 경계선이 계속 움직인다
});
}
var W = 0, H = 0, dpr = 1, cols = 0, rows = 0, wobbleR = 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);
cols = Math.ceil(W / CELL);
rows = Math.ceil(H / CELL);
wobbleR = Math.min(W, H) * 0.035;
for (var i = 0; i < MAXSEEDS; i++) { seeds[i].hx0 = seeds[i].hx * W; seeds[i].hy0 = seeds[i].hy * H; }
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP; // 0→1 진행률 — 활성 씨앗 개수의 목표값
var count = Math.max(2, 2 + Math.floor(p * (MAXSEEDS - 2)));
for (var i = 0; i < MAXSEEDS; i++) {
seeds[i].px = seeds[i].hx0 + Math.sin(sec * seeds[i].wf + seeds[i].wp) * wobbleR;
seeds[i].py = seeds[i].hy0 + Math.cos(sec * seeds[i].wf * 0.86 + seeds[i].wp) * wobbleR;
}
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (var r = 0; r < rows; r++) {
var cy = r * CELL + CELL / 2;
for (var c = 0; c < cols; c++) {
var cx = c * CELL + CELL / 2;
var best = 0, bestD = Infinity;
for (var s = 0; s < count; s++) {
var dx = cx - seeds[s].px, dy = cy - seeds[s].py;
var d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = s; }
}
ctx.fillStyle = PALETTE[best % PALETTE.length];
ctx.globalAlpha = 0.55;
ctx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
}
}
ctx.globalAlpha = 1;
for (var k = 0; k < count; k++) {
ctx.fillStyle = SEEDPT;
ctx.beginPath();
ctx.arc(seeds[k].px, seeds[k].py, 3, 0, Math.PI * 2);
ctx.fill();
}
var pct = Math.round((count - 2) / (MAXSEEDS - 2) * 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();
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);
}// VoronoiGrow — vanilla 판(run/395/demo/04-canvas-loader-voronoi-cell-grow-seed-count/index.html) 의 React 포트.
// 씨앗점 개수를 진행률에 비례해 늘리고 매 프레임 최근접-씨앗 격자(보로노이)를 다시 계산한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./VoronoiGrow.module.scss";
export type VoronoiGrowProps = {
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 Seed = { hx: number; hy: number; px: number; py: number; hx0: number; hy0: number; wf: number; wp: number };
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
const MAXSEEDS = 16;
const LOOP = 2.0;
const CELL = 9;
const FIELD_LABEL = "보로노이 셀 성장 실험판";
function makeSeeds(): Seed[] {
const rng = makeRng(20260924);
const list: Seed[] = [];
for (let i = 0; i < MAXSEEDS; i++) {
list.push({
hx: 0.08 + rng() * 0.84, hy: 0.1 + rng() * 0.8, px: 0, py: 0, hx0: 0, hy0: 0,
wf: 0.7 + rng() * 1.1, wp: rng() * Math.PI * 2, // 개별 흔들림 진동수·위상(LCG 고정) — 씨앗 수가 그대로여도 경계선이 계속 움직인다
});
}
return list;
}
export default function VoronoiGrow({
duration = "2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#0a4fe8", // vanilla $color: $subject-blue-deep
}: VoronoiGrowProps) {
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, cols: 0, rows: 0, wobbleR: 0 }) as Box<{ w: number; h: number; dpr: number; cols: number; rows: number; wobbleR: number }>;
const seedsRef = useRef<Seed[]>(makeSeeds()) as Box<Seed[]>;
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);
size.cols = Math.ceil(r.width / CELL);
size.rows = Math.ceil(r.height / CELL);
size.wobbleR = Math.min(r.width, r.height) * 0.035;
const seeds = seedsRef.current;
for (let i = 0; i < MAXSEEDS; i++) { seeds[i].hx0 = seeds[i].hx * r.width; seeds[i].hy0 = seeds[i].hy * r.height; }
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const p = t / LOOP;
const count = Math.max(2, 2 + Math.floor(p * (MAXSEEDS - 2)));
const seeds = seedsRef.current;
const wobbleR = size.wobbleR;
for (let i = 0; i < MAXSEEDS; i++) {
seeds[i].px = seeds[i].hx0 + Math.sin(sec * seeds[i].wf + seeds[i].wp) * wobbleR;
seeds[i].py = seeds[i].hy0 + Math.cos(sec * seeds[i].wf * 0.86 + seeds[i].wp) * wobbleR;
}
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), SEEDPT = cssv("--seedpt");
const PALETTE = [cssv("--c1"), cssv("--c2"), cssv("--c3"), cssv("--c4")];
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (let r = 0; r < size.rows; r++) {
const cy = r * CELL + CELL / 2;
for (let c = 0; c < size.cols; c++) {
const cx = c * CELL + CELL / 2;
let best = 0, bestD = Infinity;
for (let s = 0; s < count; s++) {
const dx = cx - seeds[s].px, dy = cy - seeds[s].py;
const d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = s; }
}
ctx.fillStyle = PALETTE[best % PALETTE.length];
ctx.globalAlpha = 0.55;
ctx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
}
}
ctx.globalAlpha = 1;
for (let k = 0; k < count; k++) {
ctx.fillStyle = SEEDPT;
ctx.beginPath();
ctx.arc(seeds[k].px, seeds[k].py, 3, 0, Math.PI * 2);
ctx.fill();
}
const pct = Math.round((count - 2) / (MAXSEEDS - 2) * 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();
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>
);
}04 · シード数の値が広げるボロノイセルローダー — 計測 累積面積 37.312% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (var i = 0; i < MAXSEEDS; i++) {
seeds[i].px = seeds[i].hx0 + Math.sin(sec * seeds[i].wf + seeds[i].wp) * wobbleR;
seeds[i].py = seeds[i].hy0 + Math.cos(sec * seeds[i].wf * 0.86 + seeds[i].wp) * wobbleR;
}
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (var r = 0; r < rows; r++) {
var cy = r * CELL + CELL / 2;
for (var c = 0; c < cols; c++) {
var cx = c * CELL + CELL / 2;
var best = 0, bestD = Infinity;
for (var s = 0; s < count; s++) {
var dx = cx - seeds[s].px, dy = cy - seeds[s].py;
var d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = s; }
}
ctx.fillStyle = PALETTE[best % PALETTE.length];
ctx.globalAlpha = 0.55;
ctx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
}
}
05軌道半径の値が縮める磁場ローダー
進行度を軌道半径の値に変換し、粒子が磁場線に沿って中心に引き寄せられるように円軌道を描きながら縮まり、半径が0に近づくとローダーが終わります。カード型ローディングスピナーやヘッダーの読み込みインジケーターに向いています — 磁場線が能動的に引き寄せている印象を与えます。タイルにタブで移動してEnterキーを押すと、半径がもう一度縮んでいきます。

<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 *;
// 자기장 로더 — 입자 10개가 코어를 돌며 궤도 반지름이 진행률에 비례해 0 에 가까워진다(훅 고정).
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue-light;
@include stage($stage-ink);
.ld {
--paper: #{$subject-ink};
--paper-fade: #{rgba($subject-ink, .12)};
--core: #{$subject-cream};
--line: #{rgba($subject-cream, .28)};
--p1: #{$subject-blue-light};
--p2: #{$subject-mint};
--p3: #{$subject-pink};
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-blue-light; }
.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-blue-light; 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, .18);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .3);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $subject-blue-light; }
.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;
--paper-fade: rgba(23, 20, 26, 0.12);
--core: #fff7e6;
--line: rgba(255, 247, 230, 0.28);
--p1: #5b8cf8;
--p2: #4cd4a6;
--p3: #ff9ebb;
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: #5b8cf8;
}
.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 #5b8cf8;
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.18);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.3);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #5b8cf8;
}
.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;
}
}// 자기장 로더 — 입자 여러 개가 코어를 돈다. 軌道 반지름은 진행률에 비례해 줄어들고
// 진행률이 1 에 닿는 순간 반지름이 정확히 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 CORE = css.getPropertyValue('--core').trim();
var LINE = css.getPropertyValue('--line').trim();
var P1 = css.getPropertyValue('--p1').trim();
var P2 = css.getPropertyValue('--p2').trim();
var P3 = css.getPropertyValue('--p3').trim();
var PALETTE = [P1, P2, P3];
function makeRng(seed) {
var s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
var N = 10;
var LOOP = 2.4;
var rng = makeRng(20260925);
var parts = [];
for (var i = 0; i < N; i++) {
parts.push({
r0: 0.42 + rng() * 0.56, // 軌道 반지름 비율(필드 반지름 대비) — 吸引 시작점
phase: rng() * Math.PI * 2,
speed: (0.55 + rng() * 0.9) * (rng() < 0.5 ? -1 : 1)
});
}
var W = 0, H = 0, dpr = 1, cxp = 0, cyp = 0, fieldR = 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);
cxp = W * 0.5; cyp = H * 0.5;
fieldR = Math.min(W, H) * 0.44;
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP; // 0→1 진행률
var shrink = 1 - p; // 軌道 반지름 배율 — p=1 에서 정확히 0(吸引 완료)
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.6;
for (var i = 0; i < N; i++) {
var q = parts[i];
var rad = fieldR * q.r0 * shrink;
var ang = q.phase + sec * q.speed;
var x = cxp + Math.cos(ang) * rad;
var y = cyp + Math.sin(ang) * rad;
ctx.beginPath();
ctx.moveTo(cxp, cyp);
ctx.lineTo(x, y);
ctx.stroke();
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.beginPath();
ctx.arc(x, y, 6.2, 0, Math.PI * 2);
ctx.fill();
}
var coreR = 8 + (1 - shrink) * 14; // 흡인이 진행될수록 코어가 커진다(자기장이 강해지는 느낌)
var grad = ctx.createRadialGradient(cxp, cyp, 0, cxp, cyp, coreR * 2.2);
grad.addColorStop(0, CORE);
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cxp, cyp, coreR * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(cxp, cyp, coreR, 0, Math.PI * 2);
ctx.fill();
pctEl.textContent = Math.round(p * 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();
fit();
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);
}// MagneticOrbit — vanilla 판(run/395/demo/05-canvas-loader-magnetic-field-pull-orbit/index.html) 의 React 포트.
// 입자 10개가 코어를 돌며 궤도 반지름이 진행률에 비례해 0 에 가까워진다(훅 고정 — magnetic-field-pull-orbit).
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./MagneticOrbit.module.scss";
export type MagneticOrbitProps = {
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 = { r0: number; phase: number; speed: number };
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
const N = 10;
const LOOP = 2.4;
const FIELD_LABEL = "자기장 궤도 흡인 실험판";
function makeParticles(): Particle[] {
const rng = makeRng(20260925);
const list: Particle[] = [];
for (let i = 0; i < N; i++) {
list.push({
r0: 0.42 + rng() * 0.56,
phase: rng() * Math.PI * 2,
speed: (0.55 + rng() * 0.9) * (rng() < 0.5 ? -1 : 1),
});
}
return list;
}
export default function MagneticOrbit({
duration = "2.4s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#5b8cf8", // vanilla $color: $subject-blue-light
}: MagneticOrbitProps) {
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, cx: 0, cy: 0, fieldR: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; fieldR: number }>;
const partsRef = useRef<Particle[]>(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;
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.cx = r.width * 0.5; size.cy = r.height * 0.5;
size.fieldR = Math.min(r.width, r.height) * 0.44;
const root = rootRef.current;
if (root) {
const paper = getComputedStyle(root).getPropertyValue("--paper").trim();
ctx.fillStyle = paper;
ctx.fillRect(0, 0, r.width, r.height);
}
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const p = t / LOOP;
const shrink = 1 - p;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER_FADE = cssv("--paper-fade"), CORE = cssv("--core"), LINE = cssv("--line");
const PALETTE = [cssv("--p1"), cssv("--p2"), cssv("--p3")];
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.6;
const parts = partsRef.current;
for (let i = 0; i < N; i++) {
const q = parts[i];
const rad = size.fieldR * q.r0 * shrink;
const ang = q.phase + sec * q.speed;
const x = size.cx + Math.cos(ang) * rad;
const y = size.cy + Math.sin(ang) * rad;
ctx.beginPath();
ctx.moveTo(size.cx, size.cy);
ctx.lineTo(x, y);
ctx.stroke();
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.beginPath();
ctx.arc(x, y, 6.2, 0, Math.PI * 2);
ctx.fill();
}
const coreR = 8 + (1 - shrink) * 14;
const grad = ctx.createRadialGradient(size.cx, size.cy, 0, size.cx, size.cy, coreR * 2.2);
grad.addColorStop(0, CORE);
grad.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(size.cx, size.cy, coreR * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(size.cx, size.cy, coreR, 0, Math.PI * 2);
ctx.fill();
if (pctRef.current) pctRef.current.textContent = Math.round(p * 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();
fit();
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>
);
}05 · 軌道半径の値が縮める磁場ローダー — 計測 累積面積 11.671% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var t = sec % LOOP;
var p = t / LOOP; // 0→1 進捗
var shrink = 1 - p; // 軌道半径の倍率 — p=1でちょうど0(吸引完了)
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.6;
for (var i = 0; i < N; i++) {
var q = parts[i];
var rad = fieldR * q.r0 * shrink;
var ang = q.phase + sec * q.speed;
var x = cxp + Math.cos(ang) * rad;
var y = cyp + Math.sin(ang) * rad;
ctx.beginPath();
ctx.moveTo(cxp, cyp);
ctx.lineTo(x, y);
ctx.stroke();
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.beginPath();
ctx.arc(x, y, 6.2, 0, Math.PI * 2);
ctx.fill();
}
06浸食厚さの値が削るノイズリングローダー
2Dノイズフィールド上に描いたリングの内側半径を、進行度に応じて食い込む浸食厚さの値で決め、ノイズがでこぼこに削られながらリングが細くなっていきます。検索結果待機画面や長いフォーム送信時のローダーに向いています — でこぼこした浸食はなめらかな偽物より「本当に処理している」感じが出ます。ウィンドウをリサイズすると、新しいサイズでリングが再び浸食されます。

<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 *;
// 노이즈 링 로더 — 두 옥타브 값-노이즈로 링의 바깥 경계를 진행률만큼 갉아먹는다(침식 두께 = 진행률).
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.ld {
--paper: #{$subject-cream};
--ring: #{rgba($subject-blue, .82)};
--ring-edge: #{$subject-blue-deep};
--inner: #{$subject-cream};
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: $subject-blue-light; }
.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;
--ring: rgba(47, 109, 246, 0.82);
--ring-edge: #0a4fe8;
--inner: #fff7e6;
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: #5b8cf8;
}
.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;
}
}// 노이즈 링 로더 — 두 옥타브 값-노이즈(LCG 격자점 보간)를 각도에 따라 흐르게 하고, 그 값에
// 진행률을 곱한 만큼 링의 바깥 경계를 안쪽으로 갉아먹는다. 浸食 두께가 곧 진행률이다.
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 RING = css.getPropertyValue('--ring').trim();
var RING_EDGE = css.getPropertyValue('--ring-edge').trim();
var INNER = css.getPropertyValue('--inner').trim();
function makeRng(seed) {
var s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
var LATTICE_N = 24;
var rng = makeRng(20260926);
var lattice = [];
for (var i = 0; i < LATTICE_N; i++) lattice.push(rng());
function smooth(f) { return f * f * (3 - 2 * f); }
function valueNoise(x) {
var xi = ((x % LATTICE_N) + LATTICE_N) % LATTICE_N;
var i0 = Math.floor(xi);
var i1 = (i0 + 1) % LATTICE_N;
var f = smooth(xi - i0);
return lattice[i0] * (1 - f) + lattice[i1] * f;
}
function fieldNoise(theta, sec) {
var x1 = (theta / (Math.PI * 2)) * LATTICE_N + sec * 1.6;
var x2 = (theta / (Math.PI * 2)) * LATTICE_N * 2.3 + 40 - sec * 2.4;
return valueNoise(x1) * 0.68 + valueNoise(x2) * 0.32;
}
var LOOP = 2.6;
var SAMPLES = 96;
var W = 0, H = 0, dpr = 1, cxp = 0, cyp = 0, outerR = 0, innerR = 0, maxErode = 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);
cxp = W * 0.5; cyp = H * 0.5;
outerR = Math.min(W, H) * 0.42;
innerR = outerR * 0.52;
maxErode = outerR - innerR;
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP;
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.beginPath();
for (var i = 0; i <= SAMPLES; i++) {
var theta = (i / SAMPLES) * Math.PI * 2;
var n = fieldNoise(theta, sec);
var r = outerR - p * maxErode * n;
var x = cxp + Math.cos(theta) * r;
var y = cyp + Math.sin(theta) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.moveTo(cxp + innerR, cyp);
ctx.arc(cxp, cyp, innerR, 0, Math.PI * 2, true);
ctx.fillStyle = RING;
ctx.fill('evenodd');
ctx.beginPath();
for (var j = 0; j <= SAMPLES; j++) {
var th = (j / SAMPLES) * Math.PI * 2;
var nn = fieldNoise(th, sec);
var rr = outerR - p * maxErode * nn;
var xx = cxp + Math.cos(th) * rr;
var yy = cyp + Math.sin(th) * rr;
if (j === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.closePath();
ctx.strokeStyle = RING_EDGE;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = INNER;
ctx.beginPath();
ctx.arc(cxp, cyp, innerR * 0.86, 0, Math.PI * 2);
ctx.fill();
pctEl.textContent = Math.round(p * 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);
}// NoiseErosion — vanilla 판(run/395/demo/06-canvas-loader-noise-flow-ring-erosion/index.html) 의 React 포트.
// 두 옥타브 값-노이즈(LCG 격자점 보간)가 각도를 따라 흐르고, 그 값에 진행률을 곱해 링의 바깥 경계를 갉아먹는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./NoiseErosion.module.scss";
export type NoiseErosionProps = {
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): () => number {
let s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
const LATTICE_N = 24;
const LOOP = 2.6;
const SAMPLES = 96;
const FIELD_LABEL = "노이즈 링 침식 실험판";
function makeLattice(): number[] {
const rng = makeRng(20260926);
const list: number[] = [];
for (let i = 0; i < LATTICE_N; i++) list.push(rng());
return list;
}
function smooth(f: number): number {
return f * f * (3 - 2 * f);
}
export default function NoiseErosion({
duration = "2.6s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#0a4fe8", // vanilla $color: $subject-blue-deep
}: NoiseErosionProps) {
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, cx: 0, cy: 0, outerR: 0, innerR: 0, maxErode: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; outerR: number; innerR: number; maxErode: number }>;
const latticeRef = useRef<number[]>(makeLattice()) as Box<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 valueNoise(x: number): number {
const lattice = latticeRef.current;
const xi = ((x % LATTICE_N) + LATTICE_N) % LATTICE_N;
const i0 = Math.floor(xi);
const i1 = (i0 + 1) % LATTICE_N;
const f = smooth(xi - i0);
return lattice[i0] * (1 - f) + lattice[i1] * f;
}
function fieldNoise(theta: number, sec: number): number {
const x1 = (theta / (Math.PI * 2)) * LATTICE_N + sec * 1.6;
const x2 = (theta / (Math.PI * 2)) * LATTICE_N * 2.3 + 40 - sec * 2.4;
return valueNoise(x1) * 0.68 + valueNoise(x2) * 0.32;
}
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.cx = r.width * 0.5; size.cy = r.height * 0.5;
size.outerR = Math.min(r.width, r.height) * 0.42;
size.innerR = size.outerR * 0.52;
size.maxErode = size.outerR - size.innerR;
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const p = t / LOOP;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), RING = cssv("--ring"), RING_EDGE = cssv("--ring-edge"), INNER = cssv("--inner");
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.beginPath();
for (let i = 0; i <= SAMPLES; i++) {
const theta = (i / SAMPLES) * Math.PI * 2;
const n = fieldNoise(theta, sec);
const r = size.outerR - p * size.maxErode * n;
const x = size.cx + Math.cos(theta) * r;
const y = size.cy + Math.sin(theta) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.moveTo(size.cx + size.innerR, size.cy);
ctx.arc(size.cx, size.cy, size.innerR, 0, Math.PI * 2, true);
ctx.fillStyle = RING;
ctx.fill("evenodd");
ctx.beginPath();
for (let j = 0; j <= SAMPLES; j++) {
const th = (j / SAMPLES) * Math.PI * 2;
const nn = fieldNoise(th, sec);
const rr = size.outerR - p * size.maxErode * nn;
const xx = size.cx + Math.cos(th) * rr;
const yy = size.cy + Math.sin(th) * rr;
if (j === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.closePath();
ctx.strokeStyle = RING_EDGE;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = INNER;
ctx.beginPath();
ctx.arc(size.cx, size.cy, size.innerR * 0.86, 0, Math.PI * 2);
ctx.fill();
if (pctRef.current) pctRef.current.textContent = Math.round(p * 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;
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>
);
}06 · 浸食厚さの値が削るノイズリングローダー — 計測 累積面積 8.508% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function smooth(f) { return f * f * (3 - 2 * f); }
function valueNoise(x) {
var xi = ((x % LATTICE_N) + LATTICE_N) % LATTICE_N;
var i0 = Math.floor(xi);
var i1 = (i0 + 1) % LATTICE_N;
var f = smooth(xi - i0);
return lattice[i0] * (1 - f) + lattice[i1] * f;
}
function fieldNoise(theta, sec) {
var x1 = (theta / (Math.PI * 2)) * LATTICE_N + sec * 1.6;
var x2 = (theta / (Math.PI * 2)) * LATTICE_N * 2.3 + 40 - sec * 2.4;
return valueNoise(x1) * 0.68 + valueNoise(x2) * 0.32;
}
07波紋半径の値が広がるバネ格子ローダー
格子状に並べた点をバネで結び、進行度を中心から広がる波紋半径の値として扱うことで、半径が通り過ぎた点だけが跳ね上がり波打つ格子が残ります。ヒーローセクションのプリローダーやインタラクティブなランディング待機画面に向いています — ページの最初のフレームに、本物のコンテンツが描画される前の動きを与えられます。タイルをクリックすると、中心からもう一度波紋を送り出します。

<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 *;
// 스프링 격자 로더 — 파동 반경이 번지며 닿은 격자점마다 감쇠 스프링(강성 상수 STIFFNESS)으로 튕긴다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-orange-deep;
@include stage($stage-orange);
.ld {
--paper: #{$subject-cream};
--line: #{rgba($subject-ink, .22)};
--node: #{rgba($subject-ink, .38)};
--node-hot: #{$subject-orange-deep};
--ring: #{rgba($subject-orange-deep, .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-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;
--line: rgba(23, 20, 26, 0.22);
--node: rgba(23, 20, 26, 0.38);
--node-hot: #c93000;
--ring: rgba(201, 48, 0, 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: #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;
}
}// 스프링 격자 로더 — 波紋 반경이 중심에서 바깥으로 번지고, 파동이 각 격자점에 닿는 순간
// 그 점은 감쇠 스프링(강성 상수 spring-mesh-stiffness, 닫힌해 SHM)으로 튕겼다가 가라앉는다.
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 LINE = css.getPropertyValue('--line').trim();
var NODE = css.getPropertyValue('--node').trim();
var NODE_HOT = css.getPropertyValue('--node-hot').trim();
var RING = css.getPropertyValue('--ring').trim();
var GX = 9, GY = 6;
var STIFFNESS = 9; // 스프링 강성 상수(훅 대상) — spring-mesh-stiffness, 값이 클수록 빨리 흔들린다
var DAMP = 3.4; // 감쇠 계수 — 흔들림이 잦아드는 속도
var AMP = 9; // 최대 변위(px)
var LOOP = 2.2;
var W = 0, H = 0, dpr = 1, cxp = 0, cyp = 0, maxR = 0, pts = [];
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);
cxp = W * 0.5; cyp = H * 0.5;
maxR = Math.max(cxp, cyp) * 1.05 + Math.min(W, H) * 0.1;
var mx = W * 0.1, my = H * 0.14;
pts = [];
for (var gy = 0; gy < GY; gy++) {
for (var gx = 0; gx < GX; gx++) {
var px = mx + (W - mx * 2) * (gx / (GX - 1));
var py = my + (H - my * 2) * (gy / (GY - 1));
var dx = px - cxp, dy = py - cyp;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
pts.push({ px: px, py: py, ux: dx / dist, uy: dy / dist, dist: dist });
}
}
}
function ripple(dt) {
if (dt < 0) return 0;
return AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt);
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP;
var frontR = p * maxR;
var speed = maxR / LOOP;
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = RING;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(cxp, cyp, Math.max(2, frontR), 0, Math.PI * 2);
ctx.stroke();
var disp = new Array(pts.length);
for (var i = 0; i < pts.length; i++) {
var q = pts[i];
var dt = (t - q.dist / speed);
disp[i] = ripple(dt);
}
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.2;
for (var gy = 0; gy < GY; gy++) {
for (var gx = 0; gx < GX; gx++) {
var idx = gy * GX + gx;
var q2 = pts[idx];
var x1 = q2.px + q2.ux * disp[idx];
var y1 = q2.py + q2.uy * disp[idx];
if (gx < GX - 1) {
var idxR = idx + 1;
var qR = pts[idxR];
var x2 = qR.px + qR.ux * disp[idxR];
var y2 = qR.py + qR.uy * disp[idxR];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
if (gy < GY - 1) {
var idxD = idx + GX;
var qD = pts[idxD];
var x3 = qD.px + qD.ux * disp[idxD];
var y3 = qD.py + qD.uy * disp[idxD];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x3, y3); ctx.stroke();
}
}
}
for (var k = 0; k < pts.length; k++) {
var qq = pts[k];
var x = qq.px + qq.ux * disp[k];
var y = qq.py + qq.uy * disp[k];
var heat = Math.min(1, Math.abs(disp[k]) / AMP);
ctx.fillStyle = heat > 0.18 ? NODE_HOT : NODE;
ctx.beginPath();
ctx.arc(x, y, 2.6 + heat * 2.4, 0, Math.PI * 2);
ctx.fill();
}
pctEl.textContent = Math.round(p * 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);
}// SpringMesh — vanilla 판(run/395/demo/07-canvas-loader-spring-mesh-ripple-radius/index.html) 의 React 포트.
// 파동 반경이 중심에서 번지고, 파동이 닿은 격자점마다 감쇠 스프링(닫힌해 SHM)으로 튕겼다가 가라앉는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SpringMesh.module.scss";
export type SpringMeshProps = {
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 Pt = { px: number; py: number; ux: number; uy: number; dist: number };
const GX = 9, GY = 6;
const STIFFNESS = 9; // 스프링 강성 상수(훅 대상) — spring-mesh-stiffness, 값이 클수록 빨리 흔들린다
const DAMP = 3.4;
const AMP = 9;
const LOOP = 2.2;
const FIELD_LABEL = "스프링 격자 파동 실험판";
function ripple(dt: number): number {
if (dt < 0) return 0;
return AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt);
}
export default function SpringMesh({
duration = "2.2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#c93000", // vanilla $color: $subject-orange-deep
}: SpringMeshProps) {
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, cx: 0, cy: 0, maxR: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; maxR: number }>;
const ptsRef = useRef<Pt[]>([]) as Box<Pt[]>;
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.cx = r.width * 0.5; size.cy = r.height * 0.5;
size.maxR = Math.max(size.cx, size.cy) * 1.05 + Math.min(r.width, r.height) * 0.1;
const mx = r.width * 0.1, my = r.height * 0.14;
const list: Pt[] = [];
for (let gy = 0; gy < GY; gy++) {
for (let gx = 0; gx < GX; gx++) {
const px = mx + (r.width - mx * 2) * (gx / (GX - 1));
const py = my + (r.height - my * 2) * (gy / (GY - 1));
const dx = px - size.cx, dy = py - size.cy;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
list.push({ px, py, ux: dx / dist, uy: dy / dist, dist });
}
}
ptsRef.current = list;
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const p = t / LOOP;
const frontR = p * size.maxR;
const speed = size.maxR / LOOP;
const pts = ptsRef.current;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), LINE = cssv("--line"), NODE = cssv("--node"), NODE_HOT = cssv("--node-hot"), RING = cssv("--ring");
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = RING;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(size.cx, size.cy, Math.max(2, frontR), 0, Math.PI * 2);
ctx.stroke();
const disp = new Array(pts.length);
for (let i = 0; i < pts.length; i++) {
const q = pts[i];
const dt = t - q.dist / speed;
disp[i] = ripple(dt);
}
ctx.strokeStyle = LINE;
ctx.lineWidth = 1.2;
for (let gy = 0; gy < GY; gy++) {
for (let gx = 0; gx < GX; gx++) {
const idx = gy * GX + gx;
const q2 = pts[idx];
const x1 = q2.px + q2.ux * disp[idx];
const y1 = q2.py + q2.uy * disp[idx];
if (gx < GX - 1) {
const idxR = idx + 1;
const qR = pts[idxR];
const x2 = qR.px + qR.ux * disp[idxR];
const y2 = qR.py + qR.uy * disp[idxR];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
if (gy < GY - 1) {
const idxD = idx + GX;
const qD = pts[idxD];
const x3 = qD.px + qD.ux * disp[idxD];
const y3 = qD.py + qD.uy * disp[idxD];
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x3, y3); ctx.stroke();
}
}
}
for (let k = 0; k < pts.length; k++) {
const qq = pts[k];
const x = qq.px + qq.ux * disp[k];
const y = qq.py + qq.uy * disp[k];
const heat = Math.min(1, Math.abs(disp[k]) / AMP);
ctx.fillStyle = heat > 0.18 ? NODE_HOT : NODE;
ctx.beginPath();
ctx.arc(x, y, 2.6 + heat * 2.4, 0, Math.PI * 2);
ctx.fill();
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 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;
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>
);
}07 · 波紋半径の値が広がるバネ格子ローダー — 計測 累積面積 22.819% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function ripple(dt) {
if (dt < 0) return 0;
return AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt);
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP;
var frontR = p * maxR;
var speed = maxR / LOOP;
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = RING;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(cxp, cyp, Math.max(2, frontR), 0, Math.PI * 2);
ctx.stroke();
var disp = new Array(pts.length);
for (var i = 0; i < pts.length; i++) {
var q = pts[i];
var dt = (t - q.dist / speed);
disp[i] = ripple(dt);
08角度の値が掃くように進む衛星軌道ローダー
進行度を0〜360度の角度値に変換し、衛星の点がその角度まで軌道を描いて通過するようにして、通った跡に尾を残すことで角度がそのまま進行度として読めます。決済処理待機画面やAPI応答待ちバッジに向いています — 掃くように進む角度は、終わりの見えない回転よりも着実な進行に見えます。タイルの上でSpaceキーを押すと、スイープを途中で止められます。

<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 *;
// 위성 궤도 로더 — 각도 값이 0~360°를 쓸어가고, 궤도 3개의 위성이 반지름에 반비례하는 배속으로 꼬리를 남긴다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-paper);
.ld {
--paper: #{$subject-cream};
--orbit: #{rgba($subject-ink, .14)};
--s1: #{$subject-blue};
--s2: #{$subject-mint};
--s3: #{$subject-pink};
--core: #{$subject-ink};
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: $subject-blue-light; }
.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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ld {
--paper: #fff7e6;
--orbit: rgba(23, 20, 26, 0.14);
--s1: #2f6df6;
--s2: #4cd4a6;
--s3: #ff9ebb;
--core: #17141a;
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: #5b8cf8;
}
.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;
}
}// 위성 軌道 로더 — 각도 값 하나가 0~360°를 쓸어가고, 軌道 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 ORBIT = css.getPropertyValue('--orbit').trim();
var S1 = css.getPropertyValue('--s1').trim();
var S2 = css.getPropertyValue('--s2').trim();
var S3 = css.getPropertyValue('--s3').trim();
var CORE = css.getPropertyValue('--core').trim();
var RINGS = [
{ rf: 0.30, speed: 1.0, color: S1 },
{ rf: 0.58, speed: 0.62, color: S2 },
{ rf: 0.86, speed: 0.38, color: S3 }
];
var LOOP = 3.0;
var TRAIL_SEG = 26;
var TRAIL_SPAN = 1.1; // 꼬리가 덮는 라디안 폭
var W = 0, H = 0, dpr = 1, cxp = 0, cyp = 0, baseR = 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);
cxp = W * 0.5; cyp = H * 0.5;
baseR = Math.min(W, H) * 0.46;
}
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP;
var sweep = p * Math.PI * 2; // 0→2π — 전역 스윕 각도
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var WIDE_SEG = 30, WIDE_SPAN = 1.7;
for (var w = WIDE_SEG; w >= 0; w--) {
var wa0 = sweep - WIDE_SPAN * (w / WIDE_SEG);
var wa1 = sweep - WIDE_SPAN * ((w - 1) / WIDE_SEG);
var walpha = (1 - w / WIDE_SEG);
ctx.fillStyle = S1;
ctx.globalAlpha = walpha * walpha * 0.14;
ctx.beginPath();
ctx.moveTo(cxp, cyp);
ctx.arc(cxp, cyp, baseR, wa0, wa1);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
for (var i = 0; i < RINGS.length; i++) {
var ring = RINGS[i];
var rad = baseR * ring.rf;
ctx.strokeStyle = ORBIT;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cxp, cyp, rad, 0, Math.PI * 2);
ctx.stroke();
var ang = sweep * ring.speed;
for (var s = TRAIL_SEG; s >= 0; s--) {
var a0 = ang - TRAIL_SPAN * (s / TRAIL_SEG);
var a1 = ang - TRAIL_SPAN * ((s - 1) / TRAIL_SEG);
var alpha = 1 - s / TRAIL_SEG;
ctx.strokeStyle = ring.color;
ctx.globalAlpha = alpha * alpha * 0.85;
ctx.lineWidth = 2.6;
ctx.beginPath();
ctx.arc(cxp, cyp, rad, a0, a1);
ctx.stroke();
}
ctx.globalAlpha = 1;
var sx = cxp + Math.cos(ang) * rad;
var sy = cyp + Math.sin(ang) * rad;
ctx.fillStyle = ring.color;
ctx.beginPath();
ctx.arc(sx, sy, 4.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(cxp, cyp, 7, 0, Math.PI * 2);
ctx.fill();
pctEl.textContent = Math.round(p * 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);
}// OrbitSweep — vanilla 판(run/395/demo/08-canvas-loader-orbit-satellite-arc-sweep/index.html) 의 React 포트.
// 각도 값 하나가 0~360°를 쓸어가고, 궤도 3개의 위성이 반지름에 반비례하는 배속으로 꼬리를 남긴다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./OrbitSweep.module.scss";
export type OrbitSweepProps = {
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 Ring = { rf: number; speed: number };
const RINGS: Ring[] = [
{ rf: 0.30, speed: 1.0 },
{ rf: 0.58, speed: 0.62 },
{ rf: 0.86, speed: 0.38 },
];
const LOOP = 3.0;
const TRAIL_SEG = 26;
const TRAIL_SPAN = 1.1;
const FIELD_LABEL = "위성 궤도 각도 스윕 실험판";
export default function OrbitSweep({
duration = "3s",
easing = "cubic-bezier(.2, 1.4, .4, 1)", // vanilla $easing: $ease-pop
color = "#0a4fe8", // vanilla $color: $subject-blue-deep
}: OrbitSweepProps) {
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, cx: 0, cy: 0, baseR: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; baseR: 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 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.cx = r.width * 0.5; size.cy = r.height * 0.5;
size.baseR = Math.min(r.width, r.height) * 0.46;
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const size = sizeRef.current;
const W = size.w, H = size.h;
const t = sec % LOOP;
const p = t / LOOP;
const sweep = p * Math.PI * 2;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER = cssv("--paper"), ORBIT = cssv("--orbit"), CORE = cssv("--core");
const COLORS = [cssv("--s1"), cssv("--s2"), cssv("--s3")];
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
const WIDE_SEG = 30, WIDE_SPAN = 1.7;
for (let w = WIDE_SEG; w >= 0; w--) {
const wa0 = sweep - WIDE_SPAN * (w / WIDE_SEG);
const wa1 = sweep - WIDE_SPAN * ((w - 1) / WIDE_SEG);
const walpha = 1 - w / WIDE_SEG;
ctx.fillStyle = COLORS[0];
ctx.globalAlpha = walpha * walpha * 0.14;
ctx.beginPath();
ctx.moveTo(size.cx, size.cy);
ctx.arc(size.cx, size.cy, size.baseR, wa0, wa1);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
for (let i = 0; i < RINGS.length; i++) {
const ring = RINGS[i];
const rad = size.baseR * ring.rf;
ctx.strokeStyle = ORBIT;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(size.cx, size.cy, rad, 0, Math.PI * 2);
ctx.stroke();
const ang = sweep * ring.speed;
for (let s = TRAIL_SEG; s >= 0; s--) {
const a0 = ang - TRAIL_SPAN * (s / TRAIL_SEG);
const a1 = ang - TRAIL_SPAN * ((s - 1) / TRAIL_SEG);
const alpha = 1 - s / TRAIL_SEG;
ctx.strokeStyle = COLORS[i];
ctx.globalAlpha = alpha * alpha * 0.85;
ctx.lineWidth = 2.6;
ctx.beginPath();
ctx.arc(size.cx, size.cy, rad, a0, a1);
ctx.stroke();
}
ctx.globalAlpha = 1;
const sx = size.cx + Math.cos(ang) * rad;
const sy = size.cy + Math.sin(ang) * rad;
ctx.fillStyle = COLORS[i];
ctx.beginPath();
ctx.arc(sx, sy, 4.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(size.cx, size.cy, 7, 0, Math.PI * 2);
ctx.fill();
if (pctRef.current) pctRef.current.textContent = Math.round(p * 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;
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>
);
}08 · 角度の値が掃くように進む衛星軌道ローダー — 計測 累積面積 19.327% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function draw(sec) {
var t = sec % LOOP;
var p = t / LOOP;
var sweep = p * Math.PI * 2; // 0→2π — 全体スイープ角度
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
var WIDE_SEG = 30, WIDE_SPAN = 1.7;
for (var w = WIDE_SEG; w >= 0; w--) {
var wa0 = sweep - WIDE_SPAN * (w / WIDE_SEG);
var wa1 = sweep - WIDE_SPAN * ((w - 1) / WIDE_SEG);
var walpha = (1 - w / WIDE_SEG);
ctx.fillStyle = S1;
ctx.globalAlpha = walpha * walpha * 0.14;
ctx.beginPath();
ctx.moveTo(cxp, cyp);
ctx.arc(cxp, cyp, baseR, wa0, wa1);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
09生存ルールの値が満たすセルオートマトンローダー
格子の各セルに生存ルールを適用し、世代が進むたびに点灯セルの割合を計算して、その割合が進行度の目標値に届くまで世代を重ねてローダーの内側を満たします。大量データ処理画面やバックグラウンド同期ローダーに向いています — 1世代ごとに1バッチ終わったように見えるので、単調な1本のバーより実際の処理に近く読めます。タイルをクリックすると、格子を再びシードしてルールが埋めていく様子をやり直せます。

<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 *;
// 셀룰러 오토마타 로더 — 씨앗 몇 개에서 "이웃 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 · 生存ルールの値が満たすセルオートマトンローダー — 計測 累積面積 33.618% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
どこで壊れるか — DAMPINGとSTIFFNESSの落とし穴
落とし穴は、DAMPING・STIFFNESS・FREQを際限のない自由なつまみとして扱ってしまうことです。03の跳ね返りはDAMPING 0.92で動いていますが、この値を1に近づけるほどb.vyは床に触れるたびに失う速度が小さくなり、Math.abs(b.vy)がREST_Vを下回るまでの時間が延び、ブロックの状態がfallingからsettledに変わるまでの時間も延びていきます — 早く終わるはずのスタックが、いつまでも収まらなくなります。07のバネ格子は逆の落とし穴を持っています。ripple()はAMP * Math.exp(-DAMP * dt)で減衰しながらMath.cos(STIFFNESS * dt)で振動するので、DAMPを一緒に上げずにSTIFFNESSだけを下げると、振幅の項が波を平らにするはずのタイミングを過ぎてもコサインの項が鳴り続け、前の波紋が目に見えて消える前に次の波紋が始まってしまいます。01のFREQにも似た、もっと静かな落とし穴があります。Math.sin(x * FREQ + sec * SPEED1)は描画ループの固定4px刻みのx方向ステップに対して計算されているので、FREQを十分に上げるとその正弦波を細かく拾いきれなくなり、表面が水ではなくノイズのように見えてしまいます。どれも実行時エラーではありません — 計算は毎フレーム動き続けますが、作ったはずのローダーとしては読めなくなるのです。
最初に触るべき3つの定数と、reduced-motionが取り除くもの
| 変形 | 変えた値 | 感触の予想 |
|---|---|---|
| 波の振動数値を調整する | 01 FREQ 0.045 → 0.09 | 振動数値を上げると液体表面が細かく速く波打つ方向に近づくはずです。 |
| 跳ね返り減衰値を調整する | 03 DAMPING 0.92 → 0.6 | 減衰値を下げるとブロックが床に触れるたびに低く跳ねて早く収まる方向に近づくはずです。 |
| バネ剛性値を調整する | 07 STIFFNESS 9 → 16 | 剛性値を上げると格子点がより早く元の位置に戻り波紋が短く途切れる方向に近づくはずです。 |
9つとも、ページが読み込まれた瞬間に一度だけprefers-reduced-motionを読み取り、毎フレーム読み取ることはしません。そのメディアクエリがreduceを返した場合は、ループを一切開始せず、ループ途中の値ではなく固定した進行度で静止フレームを1枚描くだけです。クリックできるタイルにはすべてrole="application"・tabindex="0"・aria-labelが付いていて、クリック・Enterキー・Spaceキー(どちらもループの切り替え)・Rキー(再スタート)に同じハンドラーが応答し、さらにリサイズリスナーがタイルを新しいサイズで再描画します。マウスなしでも9つすべてを再生・一時停止・再スタートできます。パターンごとの9つのソースファイルは、このデモと同じフォルダの中で5ert9w9qの向こうに置かれています。上のDAMPINGやSTIFFNESSの落とし穴に近づける前に一度開いておくと、表の上では問題なく見えても、実際には収まらないスタックや静まらない格子が本物の読み込み画面を止めてしまう事故を避けられます。
この9つを実際にどこで使うか
01の水面が上昇するローダーは、ファイルアップロードの進捗モーダルやアプリのスプラッシュ画面でこそ使う価値があります。実際のアップロードがすでに持っている進行度の数値を1つ渡すだけで動き、2つの正弦波が平坦なバーに質感を足してくれるので、別のデータソースを新たに用意する必要がありません。
05の縮んでいく軌道半径と08の掃くように進む角度は、どちらもカード型のスピナーや決済処理の待機バッジのような、丸くて小さな場所によく合います。直線のバーではその形の場所に収まらないためです。どちらを選ぶかは、中心に引き寄せられる粒子と、固定半径を回る点のどちらが周りのUIの他の動きに馴染むかで決まります。
09のセルオートマトンの充填は、大量データ処理画面やバックグラウンド同期ローダーで使う価値があります。1世代が1バッチの完了のように見えるので、UIスレッドが適当に作ったパーセンテージより、実際のバッチ処理に近い読み方ができます。
css ローディング アニメーション js の実例はどこで見られる?
css ローディング アニメーション の作り方 を検索するとジェネレーターがすぐに動くキーフレームを返してくれますが、CSSキーフレームを前提にしたジェネレーターには、収束させる粒子配列も、生存ルールを回す格子も、波打たせるバネ格子もありません。02の到着比率や09の世代ごとの充填は、プロパティが一つの値から別の値へイージングするからではなく、プレーンなJavaScriptの数値が毎フレームcanvasを動かしているからこそ成立しています。ローディング アニメーション フリー でそのままプロジェクトに使えるものを探しているなら、以下の9つがまさにそれです — パターンの下のソースを開いてファイルをコピーし、進行度の値だけ自分のアップロードやリクエストが実際に返す数値に差し替えてください。
9つのローダーを組み込む前によくある質問
この9つは、ローディング アニメーション おしゃれ な見た目のまま自分のプロジェクトに使えますか?
はい。01を例にすると、level(p)にデモのループするpの代わりに実際のアップロードの進行度比率を渡すだけで、2つの正弦波が値が1に近づくまで表面を揺らし続けます。色や余白を触らなくても、見た目のおしゃれさは保たれます。
03のスタックと09の充填はなぜ見え方が違うのですか?
03は落下するブロックという固定された少数の要素を追跡し、最後の1つが収まった瞬間に止まります。一方09は毎世代ごとに格子全体を再計算し、点灯セルの割合が目標値を超えるまで続けます。片方は数えられるスタック、もう片方は場全体から読み取るパーセンテージです。
この9つを動かすのにライブラリやCDNは必要ですか?
いいえ。9つとも同じファイルの中にある自前のcanvas 2D描画とシード付きの値だけで動いており、ライブラリもWebGLコンテキストも外部スクリプトタグも一つもありません。
Sources: MDN — Canvas API · MDN — CanvasRenderingContext2D · MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — Using the progressbar role
関連記事: キャンバス メタボール アニメーション9種 — マーチングスクエアで融合する液体の塊 · css 背景 パターン ジェネレーター 自作9種 · 【コピペOK】AIチャット ローディング アニメーション css 9選 — 1つ目はこの9つと同じくライブラリなしの2Dキャンバスでメタボールのしきい値という別の物理場を計算しており、2つ目は02や04が粒子や揺れの値をシードするのと同じ考え方で自前の生成パターンをシードしています。3つ目は同じ9パターン構成のローディングアニメーションで、こちらは物理演算ではなくチャット特有の動きを軸にしています。









