HTML5 Canvas Animation: 9 Warp & Droplet Effects
HTML5 canvas animation bends pixels — a droplet swelling the text under it, a black hole pulling the screen in — no retouching, no 3D engine.
01 fx-droplet-lens
02 fx-orb-depth-field
03 fx-noise-shader-2d
04 fx-text-heat-warp
05 fx-blackhole-tunnel
06 fx-galaxy-swirl
07 fx-fluid-surface
08 fx-light-scatter
09 fx-depth-fog
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Water droplet lens
- 02 Rising 3D orb field
- 03 Noise shader in 2D
- 04 Wobbling 3D text
- 05 Black hole tunnel
- 06 Galaxy swirl
- 07 Fluid ripple surface
- 08 Scattered light rays
- 09 Depth fog scene
The order is not a ranking by flash; it is the order in which the distortion is built. First pixels are pulled out and laid back down as refraction (01), then the illusion of depth appears (02, 03, 04), then whole-screen feedback flows (05, 06), and finally light and fog dress the entire scene (07, 08, 09). The canvas particle engine that scatters grains and the physics interaction piece covering springs and gravity are this article's neighbors.
01Water droplet lens
A hero piece where the background is painted once and only the pixels under the droplet are pulled out and re-laid by a spherical refraction formula, like a drop rolling across a product photo.

<main class="stage">
<div class="dl" id="dl">
<p class="dl__hint">
<svg class="dl__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>Drag the droplet and the pixels under it swell like a lens</span>
</p>
<div class="dl__field" id="field" role="application" aria-label="Refraction test board" tabindex="0">
<canvas class="dl__cv" id="cv"></canvas>
<b class="dl__badge" aria-hidden="true"><span class="dl__badgeNum">1.34</span><span class="dl__badgeUnit">IOR</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물방울 렌즈 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는 것이 중요하다 — CSS 애니메이션이 0개라야 렌더러가 시계를 고정하지 않고
// 실시간으로 24장을 찍어 캔버스가 실제로 도는 모습이 담긴다(도구/demo.py 렌더(), 012 선례 "애니메이션": 0).
// 캔버스가 쓰는 색은 여기서 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dl {
--paper: #{$subject-cream};
--ink: #{$subject-ink};
--line: #{$color};
--rim-0: #{rgba($subject-ink, 0)};
--rim-1: #{rgba($subject-ink, .18)};
--gloss-0: #{rgba($stage-paper, .88)};
--gloss-1: #{rgba($stage-paper, 0)};
--edge: #{rgba($subject-ink, .38)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.dl__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;
}
.dl__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.dl__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: crosshair; touch-action: none;
}
.dl__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dl__cv { display: block; width: 100%; height: 100%; }
// 방울이 쓰는 상수를 화면에도 적어 둔다 — 코드의 IOR 과 같은 값이다
.dl__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;
}
.dl__badgeNum { font-size: 13px; color: $stage-yellow; }
.dl__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.dl { gap: $sp-1; }
.dl__field { height: 124px; }
.dl__badge { font-size: 10px; padding: 0 $sp-1; }
.dl__badgeNum { font-size: 11px; }
}
// reduce — 루프가 아예 안 돌아 끄고 말고 할 애니메이션이 없다.
// 방울이 멈춰 선 한 장이 남으므로 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.dl.is-still .dl__field { cursor: default !important; }
.dl.is-still .dl__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);
}
.dl {
--paper: #fff7e6;
--ink: #17141a;
--line: #2f6df6;
--rim-0: rgba(23, 20, 26, 0);
--rim-1: rgba(23, 20, 26, 0.18);
--gloss-0: rgba(255, 255, 255, 0.88);
--gloss-1: rgba(255, 255, 255, 0);
--edge: rgba(23, 20, 26, 0.38);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.dl__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;
}
.dl__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.dl__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: crosshair;
touch-action: none;
}
.dl__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dl__cv {
display: block;
width: 100%;
height: 100%;
}
.dl__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;
}
.dl__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.dl__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.dl {
gap: 4px;
}
.dl__field {
height: 124px;
}
.dl__badge {
font-size: 10px;
padding: 0 4px;
}
.dl__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-still .dl__field {
cursor: default !important;
}
.dl.is-still .dl__badge {
opacity: 0.92 !important;
}
}// 물방울 렌즈 — 배경을 한 번 그려 둔 다음, 방울이 덮은 원 안쪽 픽셀만 getImageData 로 꺼내
// 구면 through refraction 자리를 옮겨 putImageData 로 되돌려 놓는다.
// 요점은 "확대"가 아니라 **표본 좌표를 옮기는 것**이다 — 그릴 것은 그대로고 어디서 읽느냐만 바뀐다.
var root = document.getElementById('dl');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
// willReadFrequently — 프레임마다 getImageData 를 부르는 캔버스는 이 힌트를 줘야 GPU 왕복을 안 한다
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var PAPER = css.getPropertyValue('--paper').trim();
var INK = css.getPropertyValue('--ink').trim();
var LINE = css.getPropertyValue('--line').trim();
var RIM0 = css.getPropertyValue('--rim-0').trim();
var RIM1 = css.getPropertyValue('--rim-1').trim();
var GLOSS0 = css.getPropertyValue('--gloss-0').trim();
var GLOSS1 = css.getPropertyValue('--gloss-1').trim();
var EDGE = css.getPropertyValue('--edge').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var LINES = ['Pull the pixels', 'through refraction', 'and lay them again'];
var IOR = 1.34; // 물의 IOR — 공기(1.00)에서 물로 들어갈 때의 비
var GRID = 20; // 배경 격자 한 칸(CSS px) — 렌즈가 휘는 정도를 눈으로 재는 자
var LOOP = 2; // 방울이 8자 한 바퀴를 도는 데 걸리는 초. 캡처 창(2초)과 같아 gif 가 이어진다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, radius = 54;
var cx = 0, cy = 0; // 방울 중심(CSS px)
var tx = 0, ty = 0; // 방울이 따라가는 목표점
var manual = false; // 사람이 잡고 있는가 — 손을 떼면 다시 8자 경로로 돌아간다
var raf = 0;
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로.
// 굴절 계산만 배킹 스토어(장치 픽셀) 기준으로 한다. getImageData·putImageData 는 변환을 무시하기 때문이다.
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);
radius = Math.max(30, Math.min(62, Math.round(Math.min(W, H) * 0.27)));
if (!cx) { cx = W * 0.5; cy = H * 0.5; tx = cx; ty = cy; }
}
// 배경 — 렌즈가 읽어갈 그림. 글자와 격자처럼 가늘고 곧은 것이 굴절을 가장 잘 드러낸다
function scene() {
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = LINE;
ctx.globalAlpha = 0.5;
ctx.lineWidth = 1;
ctx.beginPath();
for (var x = GRID; x < W; x += GRID) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (var y = GRID; y < H; y += GRID) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
ctx.globalAlpha = 1;
// 글자는 판을 가득 채운다 — 방울이 어디로 가든 굴절시킬 것이 있어야 렌즈로 보인다(실측:
// 세 줄만 두었더니 방울이 빈 자리를 확대해 뿌연 공으로만 보였다)
// 100px 로 한 번 재 두고 가장 긴 줄이 판 폭의 88% 가 되도록 크기를 역산한다
ctx.font = '700 100px ' + FONT;
var widest = 1;
for (var k = 0; k < LINES.length; k++) widest = Math.max(widest, ctx.measureText(LINES[k]).width);
var size = Math.max(12, Math.round(100 * (W * 0.88) / widest));
var rowH = Math.round(size * 1.18);
var rows = Math.max(3, Math.round(H / rowH));
var top = (H - (rows - 1) * rowH) * 0.5;
ctx.fillStyle = INK;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 ' + size + 'px ' + FONT;
for (var i = 0; i < rows; i++) {
ctx.fillText(LINES[i % LINES.length], W * 0.5, top + i * rowH);
}
}
// 구면 굴절 — 수직으로 들어온 빛이 반구 표면에서 스넬 법칙만큼 꺾인다.
// 중심에서의 정규화 거리 rn 이 곧 입사각의 sin 이고, 그 각도 차이가 깊이만큼 곱해져 옆으로 밀린 양이 된다.
function refract() {
var pr = Math.round(radius * dpr);
var px = Math.round(cx * dpr), py = Math.round(cy * dpr);
var sx = Math.max(0, px - pr), sy = Math.max(0, py - pr);
var ex = Math.min(cv.width, px + pr), ey = Math.min(cv.height, py + pr);
var sw = ex - sx, sh = ey - sy;
if (sw < 2 || sh < 2) return;
var src = ctx.getImageData(sx, sy, sw, sh);
var out = ctx.createImageData(sw, sh);
var s = src.data, o = out.data;
var ox = px - sx, oy = py - sy; // 잘라낸 조각 안에서의 방울 중심
var depth = pr * 0.9; // 방울 아래 물층 두께(구 반지름의 0.9배) — 클수록 크게 부푼다.
// 1.0 을 넘기면 가장자리에서 표본 좌표가 음수로 넘어가 상이 뒤집힌다
for (var y = 0; y < sh; y++) {
for (var x = 0; x < sw; x++) {
var i = (y * sw + x) * 4;
var dx = x - ox, dy = y - oy;
var dist = Math.sqrt(dx * dx + dy * dy);
var rn = dist / pr;
var j = i;
if (rn < 1 && dist > 0) {
var inc = Math.asin(rn); // 입사각
var ref = Math.asin(rn / IOR); // 굴절각 (스넬 법칙)
var shift = depth * Math.tan(inc - ref); // 안쪽으로 당겨 읽을 거리
var k = (dist - shift) / dist;
var mx = Math.round(ox + dx * k), my = Math.round(oy + dy * k);
if (mx < 0) mx = 0; else if (mx >= sw) mx = sw - 1;
if (my < 0) my = 0; else if (my >= sh) my = sh - 1;
j = (my * sw + mx) * 4;
}
o[i] = s[j]; o[i + 1] = s[j + 1]; o[i + 2] = s[j + 2]; o[i + 3] = 255;
}
}
ctx.putImageData(out, sx, sy);
}
// 방울처럼 보이게 하는 것은 굴절이 아니라 이 세 겹이다 — 가장자리 그늘·왼쪽 위 광택·얇은 테두리
function gloss() {
// 가장자리 그늘은 좁고 옅게 — 넓게 깔면 방울이 유리가 아니라 회색 공으로 읽힌다(실측)
var rim = ctx.createRadialGradient(cx, cy, radius * 0.72, cx, cy, radius);
rim.addColorStop(0, RIM0);
rim.addColorStop(1, RIM1);
ctx.fillStyle = rim;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
// 광택은 작게 — 크게 깔면 굴절로 부푼 글자가 하얗게 묻힌다(실측)
var hx = cx - radius * 0.36, hy = cy - radius * 0.4;
var hi = ctx.createRadialGradient(hx, hy, 1, hx, hy, radius * 0.26);
hi.addColorStop(0, GLOSS0);
hi.addColorStop(1, GLOSS1);
ctx.fillStyle = hi;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = EDGE;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, radius - 0.75, 0, Math.PI * 2);
ctx.stroke();
}
function draw() {
scene();
refract();
gloss();
}
// 8자 경로 — 가로는 한 바퀴, 세로는 두 바퀴 도는 리사주 곡선이 그대로 8자가 된다
function autoTarget(sec) {
var th = (sec % LOOP) / LOOP * Math.PI * 2;
tx = W * 0.5 + Math.sin(th) * W * 0.26;
ty = H * 0.5 + Math.sin(th * 2) * H * 0.2;
}
function step(sec) {
if (!manual) autoTarget(sec);
cx += (tx - cx) * 0.16;
cy += (ty - cy) * 0.16;
}
function frame(now) {
step(now / 1000);
draw();
raf = requestAnimationFrame(frame);
}
function aim(e) {
var r = field.getBoundingClientRect();
tx = Math.max(0, Math.min(W, e.clientX - r.left));
ty = Math.max(0, Math.min(H, e.clientY - r.top));
manual = true;
}
field.addEventListener('pointermove', aim);
field.addEventListener('pointerdown', function (e) { aim(e); field.setPointerCapture(e.pointerId); });
field.addEventListener('pointerup', function (e) { field.releasePointerCapture(e.pointerId); });
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16] }[e.key];
if (!d) { if (e.key === 'Escape') manual = false; return; }
e.preventDefault();
manual = true;
tx = Math.max(0, Math.min(W, tx + d[0]));
ty = Math.max(0, Math.min(H, ty + d[1]));
if (reduce) { cx = tx; cy = ty; draw(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(); });
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 방울이 글자 위에 올라선 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
cx = W * 0.44; cy = H * 0.5; tx = cx; ty = cy;
draw();
} else {
raf = requestAnimationFrame(frame);
}// DropletLens — vanilla 판(run/077/demo/01-fx-droplet-lens/index.html) 의 React 포트.
// 배경을 한 번 그려 둔 다음 방울이 덮은 원 안쪽 픽셀만 getImageData 로 꺼내
// 구면 굴절 식(스넬 법칙)으로 자리를 옮겨 putImageData 로 되돌려 놓는다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DropletLens.module.scss";
export type DropletLensProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const IOR = 1.34; // 물의 굴절률 — 공기(1.00)에서 물로 들어갈 때의 비
const GRID = 20; // 배경 격자 한 칸(CSS px)
const LOOP = 2; // 방울이 8자 한 바퀴를 도는 데 걸리는 초
const LINES = ["픽셀을 꺼내", "굴절 식으로", "다시 배치합니다"];
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "굴절 실험판";
export default function DropletLens({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: DropletLensProps) {
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 sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const dropRef = useRef({ cx: 0, cy: 0, tx: 0, ty: 0, radius: 54, manual: false }) as Box<{ cx: number; cy: number; tx: number; ty: number; radius: number; manual: boolean }>;
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;
}
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로.
// 굴절 계산만 배킹 스토어 기준으로 한다. getImageData·putImageData 는 변환을 무시하기 때문이다.
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 d = dropRef.current;
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);
d.radius = Math.max(30, Math.min(62, Math.round(Math.min(r.width, r.height) * 0.27)));
if (!d.cx) { d.cx = r.width * 0.5; d.cy = r.height * 0.5; d.tx = d.cx; d.ty = d.cy; }
}
// 배경 — 렌즈가 읽어갈 그림. 글자와 격자처럼 가늘고 곧은 것이 굴절을 가장 잘 드러낸다
function scene(): 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();
ctx.fillStyle = cssv("--paper");
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = cssv("--line");
ctx.globalAlpha = 0.5;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = GRID; x < W; x += GRID) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (let y = GRID; y < H; y += GRID) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
ctx.globalAlpha = 1;
// 100px 로 한 번 재 두고 가장 긴 줄이 판 폭의 88% 가 되도록 크기를 역산한다
ctx.font = "700 100px " + FONT;
let widest = 1;
for (const s of LINES) widest = Math.max(widest, ctx.measureText(s).width);
const size = Math.max(12, Math.round(100 * (W * 0.88) / widest));
const rowH = Math.round(size * 1.18);
const rows = Math.max(3, Math.round(H / rowH));
const top = (H - (rows - 1) * rowH) * 0.5;
ctx.fillStyle = cssv("--ink");
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 " + size + "px " + FONT;
for (let i = 0; i < rows; i++) {
ctx.fillText(LINES[i % LINES.length], W * 0.5, top + i * rowH);
}
}
// 구면 굴절 — 수직으로 들어온 빛이 반구 표면에서 스넬 법칙만큼 꺾인다.
// 중심에서의 정규화 거리 rn 이 곧 입사각의 sin 이고, 각도 차이가 깊이를 곱해 옆으로 밀린 양이 된다.
function refract(): void {
const ctx = ctxRef.current;
const cv = cvRef.current;
if (!ctx || !cv) return;
const { dpr } = sizeRef.current;
const d = dropRef.current;
const pr = Math.round(d.radius * dpr);
const px = Math.round(d.cx * dpr), py = Math.round(d.cy * dpr);
const sx = Math.max(0, px - pr), sy = Math.max(0, py - pr);
const ex = Math.min(cv.width, px + pr), ey = Math.min(cv.height, py + pr);
const sw = ex - sx, sh = ey - sy;
if (sw < 2 || sh < 2) return;
const src = ctx.getImageData(sx, sy, sw, sh);
const out = ctx.createImageData(sw, sh);
const s = src.data, o = out.data;
const ox = px - sx, oy = py - sy;
const depth = pr * 0.9; // 방울 아래 물층 두께(구 반지름의 0.9배)
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
const i = (y * sw + x) * 4;
const dx = x - ox, dy = y - oy;
const dist = Math.sqrt(dx * dx + dy * dy);
const rn = dist / pr;
let j = i;
if (rn < 1 && dist > 0) {
const inc = Math.asin(rn);
const ref = Math.asin(rn / IOR);
const shift = depth * Math.tan(inc - ref);
const k = (dist - shift) / dist;
let mx = Math.round(ox + dx * k), my = Math.round(oy + dy * k);
if (mx < 0) mx = 0; else if (mx >= sw) mx = sw - 1;
if (my < 0) my = 0; else if (my >= sh) my = sh - 1;
j = (my * sw + mx) * 4;
}
o[i] = s[j]; o[i + 1] = s[j + 1]; o[i + 2] = s[j + 2]; o[i + 3] = 255;
}
}
ctx.putImageData(out, sx, sy);
}
// 방울처럼 보이게 하는 것은 굴절이 아니라 이 세 겹이다 — 가장자리 그늘·왼쪽 위 광택·얇은 테두리
function gloss(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const d = dropRef.current;
// 가장자리 그늘은 좁고 옅게 — 넓게 깔면 방울이 유리가 아니라 회색 공으로 읽힌다
const rim = ctx.createRadialGradient(d.cx, d.cy, d.radius * 0.72, d.cx, d.cy, d.radius);
rim.addColorStop(0, cssv("--rim-0"));
rim.addColorStop(1, cssv("--rim-1"));
ctx.fillStyle = rim;
ctx.beginPath();
ctx.arc(d.cx, d.cy, d.radius, 0, Math.PI * 2);
ctx.fill();
// 광택은 작게 — 크게 깔면 굴절로 부푼 글자가 하얗게 묻힌다
const hx = d.cx - d.radius * 0.36, hy = d.cy - d.radius * 0.4;
const hi = ctx.createRadialGradient(hx, hy, 1, hx, hy, d.radius * 0.26);
hi.addColorStop(0, cssv("--gloss-0"));
hi.addColorStop(1, cssv("--gloss-1"));
ctx.fillStyle = hi;
ctx.beginPath();
ctx.arc(d.cx, d.cy, d.radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = cssv("--edge");
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(d.cx, d.cy, d.radius - 0.75, 0, Math.PI * 2);
ctx.stroke();
}
function draw(): void {
scene();
refract();
gloss();
}
// 8자 경로 — 가로는 한 바퀴, 세로는 두 바퀴 도는 리사주 곡선이 그대로 8자가 된다
function autoTarget(sec: number): void {
const W = sizeRef.current.w, H = sizeRef.current.h;
const th = (sec % LOOP) / LOOP * Math.PI * 2;
const d = dropRef.current;
d.tx = W * 0.5 + Math.sin(th) * W * 0.26;
d.ty = H * 0.5 + Math.sin(th * 2) * H * 0.2;
}
function step(sec: number): void {
const d = dropRef.current;
if (!d.manual) autoTarget(sec);
d.cx += (d.tx - d.cx) * 0.16;
d.cy += (d.ty - d.cy) * 0.16;
}
function frame(now: number): void {
step(now / 1000);
draw();
rafRef.current = requestAnimationFrame(frame);
}
function aim(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const W = sizeRef.current.w, H = sizeRef.current.h;
const d = dropRef.current;
d.tx = Math.max(0, Math.min(W, e.clientX - r.left));
d.ty = Math.max(0, Math.min(H, e.clientY - r.top));
d.manual = true;
}
function onMove(e: PtrEvt): void {
aim(e);
}
function onDown(e: PtrEvt): void {
aim(e);
const field = fieldRef.current;
if (field && typeof field.setPointerCapture === "function") {
try { field.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 치명적이지 않다 */ }
}
}
function onUp(e: PtrEvt): void {
const field = fieldRef.current;
if (field && typeof field.releasePointerCapture === "function") {
try { field.releasePointerCapture(e.pointerId); } catch { /* 이미 풀려 있을 수 있다 */ }
}
}
function onLeave(): void {
dropRef.current.manual = false;
}
function onKey(e: KeyEvt): void {
const W = sizeRef.current.w, H = sizeRef.current.h;
const d = dropRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16],
};
const dd = map[e.key];
if (!dd) { if (e.key === "Escape") d.manual = false; return; }
e.preventDefault();
d.manual = true;
d.tx = Math.max(0, Math.min(W, d.tx + dd[0]));
d.ty = Math.max(0, Math.min(H, d.ty + dd[1]));
if (reduced()) { d.cx = d.tx; d.cy = d.ty; draw(); }
}
function onResize(): void {
fit();
if (reduced()) draw();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
// willReadFrequently — 프레임마다 getImageData 를 부르는 캔버스는 이 힌트를 줘야 GPU 왕복을 안 한다
ctxRef.current = cv.getContext("2d", { willReadFrequently: true });
if (!ctxRef.current) return;
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 방울이 글자 위에 올라선 한 장만 그린다
const root = rootRef.current;
if (root) root.classList.add("is-still");
const d = dropRef.current;
d.cx = sizeRef.current.w * 0.44; d.cy = sizeRef.current.h * 0.5; d.tx = d.cx; d.ty = d.cy;
draw();
} 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="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>방울을 잡아 끌면 그 아래가 렌즈처럼 부풀어 오릅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerDown={onDown}
onPointerUp={onUp}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>1.34</span><span className={styles.badgeUnit}>굴절률</span></b>
</div>
</div>
</main>
);
}01 · Water droplet lens — Measured area 32.968% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Magnification is just moving the sample coordinates
for (var y = 0; y < sh; y++) {
for (var x = 0; x < sw; x++) {
var i = (y * sw + x) * 4;
var dx = x - ox, dy = y - oy;
var dist = Math.sqrt(dx * dx + dy * dy);
var rn = dist / pr;
var j = i;
if (rn < 1 && dist > 0) {
var inc = Math.asin(rn); // angle of incidence
var ref = Math.asin(rn / IOR); // angle of refraction (Snell)
var shift = depth * Math.tan(inc - ref); // how far inward we read from
var k = (dist - shift) / dist;
var mx = Math.round(ox + dx * k), my = Math.round(oy + dy * k);
if (mx < 0) mx = 0; else if (mx >= sw) mx = sw - 1;
if (my < 0) my = 0; else if (my >= sh) my = sh - 1;
j = (my * sw + mx) * 4;
}
What gets drawn stays untouched; only the sample point moves toward the droplet center, so the grid and letters swell under the lens. Snell's law is solved with the refractive index of water, 1.34, and dragging the droplet makes it glide to your hand (measured: 14,273 alpha pixels changed during the drag).
02Rising 3D orb field
Forty orbs each carry a z value, and a single division by the focal length creates perspective — a background drifting up behind a pricing section.

<main class="stage">
<div class="od" id="od">
<p class="od__hint">
<svg class="od__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg>
<span>A shorter focal length deepens perspective and pulls front orbs closer</span>
</p>
<div class="od__field" id="field" role="application" aria-label="Perspective test board" tabindex="0">
<canvas class="od__cv" id="cv"></canvas>
</div>
<div class="od__bar">
<label class="od__dial">Focal length
<input type="range" id="focal" min="120" max="640" step="20" value="280" aria-label="Focal length">
<b class="od__val" id="focalVal">280px</b>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 떠오르는 3D 볼 필드 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·다이얼만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 시계를 고정하지 않고 실시간 24장을 찍는다.
// 구슬 세 계열의 하이라이트·중간·그늘 색은 토큰에서 섞어 커스텀 프로퍼티로 내려보낸다(값은 토큰 한 곳에만).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.od {
--bg-0: #{mix($subject-blue, $stage-ink, 16%)};
--bg-1: #{$stage-ink};
// 계열 a — 파랑 구슬 (가장 많다)
--orb-a-hi: #{mix($subject-cream, $color, 58%)};
--orb-a-mid: #{$color};
--orb-a-lo: #{mix($stage-ink, $color, 58%)};
// 계열 b — 크림 구슬
--orb-b-hi: #{$stage-paper};
--orb-b-mid: #{$subject-cream};
--orb-b-lo: #{mix($stage-ink, $subject-cream, 52%)};
// 계열 c — 노랑 구슬 (몇 알만)
--orb-c-hi: #{mix($stage-paper, $stage-yellow, 46%)};
--orb-c-mid: #{$stage-yellow};
--orb-c-lo: #{mix($stage-ink, $stage-yellow, 52%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.od__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;
}
.od__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.od__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.od__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.od__cv { display: block; width: 100%; height: 100%; }
.od__bar { display: flex; align-items: center; gap: $sp-3; }
.od__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.od__dial input { width: 116px; margin: 0; accent-color: $subject-blue-light; }
.od__val { color: $stage-yellow; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.od { gap: $sp-1; }
.od__field { height: 100px; }
.od__dial { gap: $sp-1; }
.od__dial input { width: 68px; }
.od__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 멈춰 선 한 장이 그대로 남으므로 움직일 것 같은 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.od.is-still .od__field { cursor: default !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);
}
.od {
--bg-0: rgb(10.5254901961%, 13.4274509804%, 24%);
--bg-1: #17141a;
--orb-a-hi: rgb(65.7411764706%, 74.1333333333%, 92.831372549%);
--orb-a-mid: #2f6df6;
--orb-a-lo: rgb(12.9725490196%, 22.5019607843%, 46.431372549%);
--orb-b-hi: #ffffff;
--orb-b-mid: #fff7e6;
--orb-b-lo: rgb(52.6901960784%, 50.5725490196%, 48.5960784314%);
--orb-c-hi: rgb(100%, 90.4705882353%, 59.3411764706%);
--orb-c-mid: #ffd23f;
--orb-c-lo: rgb(52.6901960784%, 43.6078431373%, 17.1607843137%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.od__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;
}
.od__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.od__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.od__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.od__cv {
display: block;
width: 100%;
height: 100%;
}
.od__bar {
display: flex;
align-items: center;
gap: 12px;
}
.od__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.od__dial input {
width: 116px;
margin: 0;
accent-color: #5b8cf8;
}
.od__val {
color: #ffd23f;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.od {
gap: 4px;
}
.od__field {
height: 100px;
}
.od__dial {
gap: 4px;
}
.od__dial input {
width: 68px;
}
.od__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.od.is-still .od__field {
cursor: default !important;
}
}// 떠오르는 3D 볼 필드 — 구슬마다 x·y·z 를 두고 s = focal / (focal + z) 한 번으로 원근을 만든다.
// 3D 라이브러리도 행렬도 없다. 입체로 읽히는 것은 ① 먼 것부터 그리는 화가 알고리즘 ② z 를 따라
// 같이 변하는 반경·흐림·투명도, 이 둘뿐이다.
var root = document.getElementById('od');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var focalIn = document.getElementById('focal');
var focalOut = document.getElementById('focalVal');
var css = getComputedStyle(root);
function tone(name) {
return [
css.getPropertyValue('--orb-' + name + '-hi').trim(),
css.getPropertyValue('--orb-' + name + '-mid').trim(),
css.getPropertyValue('--orb-' + name + '-lo').trim()
];
}
var TONES = [tone('a'), tone('a'), tone('a'), tone('b'), tone('c')];
var BG0 = css.getPropertyValue('--bg-0').trim();
var BG1 = css.getPropertyValue('--bg-1').trim();
var COUNT = 40;
var Z_NEAR = 40, Z_FAR = 980; // 구슬이 사는 깊이 구간
var RISE = 52; // 월드 좌표에서의 상승 속도 — 모든 구슬이 같다(등속)
var DRIFT = 26; // 같은 속도로 천천히 다가온다
var TAU = Math.PI * 2;
var MAX_BLUR = 7;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var spanX = 0, spanY = 0;
var focal = 280;
var camX = 0, camY = 0, aimX = 0, aimY = 0; // 포인터가 만드는 시차(카메라 평행이동)
var orbs = [], order = [];
var last = 0, raf = 0;
for (var i = 0; i < COUNT; i++) {
var o = {
x: Math.random() * 2 - 1, // 월드 폭 대비 비율 — fit() 에서 실제 좌표로 편다
y: Math.random() * 2 - 1,
z: Z_NEAR + Math.random() * (Z_FAR - Z_NEAR),
r: 16 + Math.random() * 30,
tone: i % TONES.length
};
orbs.push(o);
order.push(o);
}
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);
spanX = W * 0.92; // 화면 밖까지 조금 넉넉히 — 가장자리에서 구슬이 툭 사라지지 않게
spanY = H * 1.15;
}
// 월드 좌표는 -1..1 비율로 들고 있다가 그릴 때만 화면 크기를 곱한다 — 창이 바뀌어도 배치가 안 흐트러진다
function step(dt) {
var ry = RISE * dt / spanY;
var rz = DRIFT * dt;
for (var i = 0; i < COUNT; i++) {
var o = orbs[i];
o.y -= ry;
if (o.y < -1.1) o.y += 2.2; // 위를 벗어나면 아래에서 다시 들어온다
o.z -= rz;
if (o.z < Z_NEAR) o.z += (Z_FAR - Z_NEAR);
}
camX += (aimX - camX) * 0.12;
camY += (aimY - camY) * 0.12;
}
function draw() {
var bg = ctx.createRadialGradient(W * 0.5, H * 0.42, 8, W * 0.5, H * 0.5, Math.max(W, H) * 0.72);
bg.addColorStop(0, BG0);
bg.addColorStop(1, BG1);
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// 화가 알고리즘 — z 내림차순으로 먼 것부터. 깊이 버퍼가 없는 2D 에서 겹침 순서를 만드는 유일한 방법이다
order.sort(function (a, b) { return b.z - a.z; });
var blurNow = -1;
for (var i = 0; i < COUNT; i++) {
var o = order[i];
var s = focal / (focal + o.z); // 원근 투영 — 이 한 줄이 전부다
var sx = W * 0.5 + (o.x * spanX * 0.5 - camX) * s;
var sy = H * 0.5 + (o.y * spanY * 0.5 - camY) * s;
var r = o.r * s;
if (r < 0.8) continue;
// 흐림은 정수 단계로 끊는다 — z 로 정렬돼 있어 ctx.filter 를 다섯 번쯤만 갈아 끼우면 된다.
// 범위를 0~7px 로 넓혀 먼 것·가까운 것의 차이가 한눈에 벌어지게 한다(기존 4px 로는 스틸컷에서
// 거의 안 보였다 — 실측)
var blur = Math.round((o.z - Z_NEAR) / (Z_FAR - Z_NEAR) * MAX_BLUR * 1.2);
if (blur > MAX_BLUR) blur = MAX_BLUR;
if (blur !== blurNow) {
ctx.filter = blur > 0 ? 'blur(' + blur + 'px)' : 'none';
blurNow = blur;
}
ctx.globalAlpha = 0.28 + 0.72 * Math.min(1, s * 1.6);
var t = TONES[o.tone];
var g = ctx.createRadialGradient(sx - r * 0.36, sy - r * 0.42, r * 0.06, sx, sy, r);
g.addColorStop(0, t[0]);
g.addColorStop(0.52, t[1]);
g.addColorStop(1, t[2]);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, TAU);
ctx.fill();
}
ctx.filter = 'none';
ctx.globalAlpha = 1;
}
function frame(now) {
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
last = now;
step(dt);
draw();
raf = requestAnimationFrame(frame);
}
function setFocal(v) {
focal = Number(v);
focalOut.textContent = focal + 'px';
if (reduce) draw();
}
focalIn.addEventListener('input', function () { setFocal(focalIn.value); });
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
aimX = ((e.clientX - r.left) / r.width - 0.5) * 150;
aimY = ((e.clientY - r.top) / r.height - 0.5) * 90;
if (reduce) { camX = aimX; camY = aimY; draw(); }
});
field.addEventListener('pointerleave', function () { aimX = 0; aimY = 0; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-30, 0], ArrowRight: [30, 0], ArrowUp: [0, -22], ArrowDown: [0, 22] }[e.key];
if (!d) return;
e.preventDefault();
aimX = Math.max(-150, Math.min(150, aimX + d[0]));
aimY = Math.max(-90, Math.min(90, aimY + d[1]));
if (reduce) { camX = aimX; camY = aimY; draw(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(); });
fit();
setFocal(focalIn.value);
if (reduce) {
// reduce — 루프를 시작하지 않고 구슬이 깊이별로 흩어진 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw();
} else {
raf = requestAnimationFrame(frame);
}// OrbDepthField — vanilla 판(run/077/demo/02-fx-orb-depth-field/index.html) 의 React 포트.
// 구슬마다 x·y·z 를 두고 s = focal / (focal + z) 한 번으로 원근을 만든다.
// 먼 것부터 그리는 화가 알고리즘(정렬)과 z 를 따라 변하는 반경·흐림·투명도가 입체를 만든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OrbDepthField.module.scss";
export type OrbDepthFieldProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
type InputEvt = { target: { value: string } };
type Orb = { x: number; y: number; z: number; r: number; tone: number };
const COUNT = 40;
const Z_NEAR = 40, Z_FAR = 980; // 구슬이 사는 깊이 구간
const RISE = 52; // 월드 좌표에서의 상승 속도 — 모든 구슬이 같다(등속)
const DRIFT = 26; // 같은 속도로 천천히 다가온다
const TAU = Math.PI * 2;
const MAX_BLUR = 7;
const FIELD_LABEL = "원근 실험판";
function makeOrbs(): Orb[] {
const orbs: Orb[] = [];
for (let i = 0; i < COUNT; i++) {
orbs.push({
x: Math.random() * 2 - 1, // 월드 폭 대비 비율 — fit() 에서 실제 좌표로 편다
y: Math.random() * 2 - 1,
z: Z_NEAR + Math.random() * (Z_FAR - Z_NEAR),
r: 16 + Math.random() * 30,
tone: i % 5,
});
}
return orbs;
}
export default function OrbDepthField({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: OrbDepthFieldProps) {
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 focalOutRef = useRef<HTMLSpanElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, spanX: 0, spanY: 0 }) as Box<{ w: number; h: number; spanX: number; spanY: number }>;
const orbsRef = useRef<Orb[]>([]) as Box<Orb[]>;
const camRef = useRef({ x: 0, y: 0, ax: 0, ay: 0 }) as Box<{ x: number; y: number; ax: number; ay: number }>;
const focalRef = useRef(280) as Box<number>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [focal, setFocal] = useState(280);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tones(): string[][] {
const root = rootRef.current;
if (!root) return [];
const one = (n: string) => [
getComputedStyle(root).getPropertyValue("--orb-" + n + "-hi").trim(),
getComputedStyle(root).getPropertyValue("--orb-" + n + "-mid").trim(),
getComputedStyle(root).getPropertyValue("--orb-" + n + "-lo").trim(),
];
const a = one("a");
return [a, a, a, one("b"), one("c")];
}
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
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;
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);
sizeRef.current.spanX = r.width * 0.92; // 화면 밖까지 조금 넉넉히 — 가장자리에서 구슬이 툭 사라지지 않게
sizeRef.current.spanY = r.height * 1.15;
}
// 월드 좌표는 -1..1 비율로 들고 있다가 그릴 때만 화면 크기를 곱한다
function step(dt: number): void {
const { spanY } = sizeRef.current;
const ry = RISE * dt / spanY;
const rz = DRIFT * dt;
for (const o of orbsRef.current) {
o.y -= ry;
if (o.y < -1.1) o.y += 2.2; // 위를 벗어나면 아래에서 다시 들어온다
o.z -= rz;
if (o.z < Z_NEAR) o.z += (Z_FAR - Z_NEAR);
}
const cam = camRef.current;
cam.x += (cam.ax - cam.x) * 0.12;
cam.y += (cam.ay - cam.y) * 0.12;
}
function draw(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const bg0 = getComputedStyle(root).getPropertyValue("--bg-0").trim();
const bg1 = getComputedStyle(root).getPropertyValue("--bg-1").trim();
const bg = ctx.createRadialGradient(W * 0.5, H * 0.42, 8, W * 0.5, H * 0.5, Math.max(W, H) * 0.72);
bg.addColorStop(0, bg0);
bg.addColorStop(1, bg1);
ctx.filter = "none";
ctx.globalAlpha = 1;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
const T = tones();
const order = orbsRef.current.slice();
// 화가 알고리즘 — z 내림차순으로 먼 것부터. 깊이 버퍼가 없는 2D 에서 겹침 순서를 만드는 유일한 방법이다
order.sort((a, b) => b.z - a.z);
const cam = camRef.current;
let blurNow = -1;
for (const o of order) {
const s = focalRef.current / (focalRef.current + o.z); // 원근 투영 — 이 한 줄이 전부다
const sx = W * 0.5 + (o.x * sizeRef.current.spanX * 0.5 - cam.x) * s;
const sy = H * 0.5 + (o.y * sizeRef.current.spanY * 0.5 - cam.y) * s;
const r = o.r * s;
if (r < 0.8) continue;
// 흐림은 정수 단계로 끊는다 — z 로 정렬돼 있어 ctx.filter 를 몇 번만 갈아 끼우면 된다
let blur = Math.round((o.z - Z_NEAR) / (Z_FAR - Z_NEAR) * MAX_BLUR * 1.2);
if (blur > MAX_BLUR) blur = MAX_BLUR;
if (blur !== blurNow) {
ctx.filter = blur > 0 ? "blur(" + blur + "px)" : "none";
blurNow = blur;
}
ctx.globalAlpha = 0.28 + 0.72 * Math.min(1, s * 1.6);
const t = T[o.tone] || T[0];
const g = ctx.createRadialGradient(sx - r * 0.36, sy - r * 0.42, r * 0.06, sx, sy, r);
g.addColorStop(0, t[0]);
g.addColorStop(0.52, t[1]);
g.addColorStop(1, t[2]);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, TAU);
ctx.fill();
}
ctx.filter = "none";
ctx.globalAlpha = 1;
}
function frame(now: number): void {
const dt = lastRef.current ? Math.min((now - lastRef.current) / 1000, 0.05) : 0.016;
lastRef.current = now;
step(dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
function setFocalValue(v: number): void {
focalRef.current = v;
setFocal(v);
if (focalOutRef.current) focalOutRef.current.textContent = v + "px";
if (reduced()) draw();
}
function onInput(e: InputEvt): void {
setFocalValue(Number(e.target.value));
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const cam = camRef.current;
cam.ax = ((e.clientX - r.left) / r.width - 0.5) * 150;
cam.ay = ((e.clientY - r.top) / r.height - 0.5) * 90;
if (reduced()) { cam.x = cam.ax; cam.y = cam.ay; draw(); }
}
function onLeave(): void {
camRef.current.ax = 0;
camRef.current.ay = 0;
}
function onKey(e: KeyEvt): void {
const cam = camRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-30, 0], ArrowRight: [30, 0], ArrowUp: [0, -22], ArrowDown: [0, 22],
};
const d = map[e.key];
if (!d) return;
e.preventDefault();
cam.ax = Math.max(-150, Math.min(150, cam.ax + d[0]));
cam.ay = Math.max(-90, Math.min(90, cam.ay + d[1]));
if (reduced()) { cam.x = cam.ax; cam.y = cam.ay; draw(); }
}
function onResize(): void {
fit();
if (reduced()) draw();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
orbsRef.current = makeOrbs();
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 구슬이 깊이별로 흩어진 한 장만 그린다
setStill(true);
draw();
} else {
lastRef.current = 0;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} 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="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg>
<span>초점거리를 줄이면 원근이 세져 앞쪽 구슬이 크게 옵니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>초점거리
<input type="range" min={120} max={640} step={20} value={focal} aria-label="초점거리" onChange={onInput} />
<b className={styles.val} ref={focalOutRef}>{focal}px</b>
</label>
</div>
</div>
</main>
);
}02 · Rising 3D orb field — Measured area 31.135% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Perspective is a single division
// Painter's algorithm — far to near by descending z. The only way to settle
// overlap order in 2D, where there is no depth buffer.
order.sort(function (a, b) { return b.z - a.z; });
var blurNow = -1;
for (var i = 0; i < COUNT; i++) {
var o = order[i];
var s = focal / (focal + o.z); // perspective projection — this one line is all of it
var sx = W * 0.5 + (o.x * spanX * 0.5 - camX) * s;
var sy = H * 0.5 + (o.y * spanY * 0.5 - camY) * s;
var r = o.r * s;
if (r < 0.8) continue;
// Blur snaps to whole pixels — since the orbs are z-sorted, ctx.filter
// only needs swapping about five times per frame.
var blur = Math.round((o.z - Z_NEAR) / (Z_FAR - Z_NEAR) * MAX_BLUR * 1.2);
Multiplying coordinates and radius by the scale s draws identically sized orbs small when far and large when near. Distant orbs get a ctx.filter blur and low alpha, and moving the pointer tilts the camera slightly so the space itself appears to lean.
03Noise shader in 2D
Four layers of value noise are written pixel by pixel into a small 160x100 buffer that is then scaled up — a drifting cloud background to fill the gap between sections.

<main class="stage">
<div class="ns" id="ns">
<p class="ns__hint">
<svg class="ns__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></svg>
<span>Drag to push the clouds; fewer layers coarsen the grain</span>
</p>
<div class="ns__field" id="field" role="application" aria-label="Noise test board" tabindex="0">
<canvas class="ns__cv" id="cv"></canvas>
<b class="ns__badge" aria-hidden="true">160 × 100</b>
</div>
<div class="ns__bar">
<label class="ns__dial">Octaves
<input type="range" id="oct" min="2" max="5" step="1" value="4" aria-label="Octaves">
<b class="ns__val" id="octVal">4</b>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 노이즈 쉐이더 재현 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·다이얼만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 시계를 고정하지 않고 실시간 24장을 찍는다.
// 구름 색은 토큰 네 단계를 커스텀 프로퍼티로 내려보내고, JS 가 그걸 256칸 표로 펴서 픽셀에 꽂는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ns {
--n0: #{$stage-ink};
--n1: #{mix($color, $stage-ink, 44%)};
--n2: #{$color};
--n3: #{mix($subject-cream, $color, 68%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ns__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;
}
.ns__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.ns__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: grab; touch-action: none;
}
.ns__field:active { cursor: grabbing; }
.ns__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.ns__cv { display: block; width: 100%; height: 100%; }
// 버퍼 해상도를 화면에 적어 둔다 — 소스의 NW·NH 와 같은 값이고, 이 데모의 요점이다
.ns__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ns__bar { display: flex; align-items: center; gap: $sp-3; }
.ns__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.ns__dial input { width: 104px; margin: 0; accent-color: $subject-blue-light; }
.ns__val { color: $stage-yellow; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.ns { gap: $sp-1; }
.ns__field { height: 100px; }
.ns__badge { font-size: 10px; padding: 0 $sp-1; }
.ns__dial { gap: $sp-1; }
.ns__dial input { width: 60px; }
.ns__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 구름이 멈춰 선 한 장이 그대로 남으므로 끌 수 있다는 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.ns.is-still .ns__field { cursor: default !important; }
.ns.is-still .ns__field:active { cursor: default !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);
}
.ns {
--n0: #17141a;
--n1: rgb(13.1607843137%, 23.2%, 48.1568627451%);
--n2: #2f6df6;
--n3: rgb(73.8980392157%, 79.5450980392%, 92.2039215686%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ns__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;
}
.ns__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.ns__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: grab;
touch-action: none;
}
.ns__field:active {
cursor: grabbing;
}
.ns__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.ns__cv {
display: block;
width: 100%;
height: 100%;
}
.ns__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ns__bar {
display: flex;
align-items: center;
gap: 12px;
}
.ns__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.ns__dial input {
width: 104px;
margin: 0;
accent-color: #5b8cf8;
}
.ns__val {
color: #ffd23f;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.ns {
gap: 4px;
}
.ns__field {
height: 100px;
}
.ns__badge {
font-size: 10px;
padding: 0 4px;
}
.ns__dial {
gap: 4px;
}
.ns__dial input {
width: 60px;
}
.ns__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ns.is-still .ns__field {
cursor: default !important;
}
.ns.is-still .ns__field:active {
cursor: default !important;
}
}// 노이즈 쉐이더 재현 — 셰이더가 아니라 160x100 짜리 ImageData 에 값 노이즈를 픽셀 단위로 써 넣고,
// 그 작은 버퍼를 imageSmoothingEnabled 로 늘려 480x300 을 채운다.
// 셰이더처럼 보이는 까닭은 계산이 대단해서가 아니라 **해상도를 일부러 낮췄기** 때문이다 —
// 16,000 픽셀만 계산하고 나머지는 브라우저의 확대 보간이 메운다.
var root = document.getElementById('ns');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var octIn = document.getElementById('oct');
var octOut = document.getElementById('octVal');
var NW = 160, NH = 100; // 노이즈 버퍼 해상도 — 화면이 커져도 이 값은 그대로다
var small = document.createElement('canvas');
small.width = NW; small.height = NH;
var sctx = small.getContext('2d');
var buf = sctx.createImageData(NW, NH); // 한 번만 만들어 두고 프레임마다 같은 배열을 덮어쓴다
var px = buf.data;
// ── 값 노이즈 — 격자 꼭짓점마다 난수를 하나 두고 그 사이를 부드럽게 잇는다 ───────────
// 그라디언트 노이즈(펄린)보다 단순하고, 네 겹만 쌓아도 구름으로 읽힌다.
var PERM = new Uint8Array(512);
var VAL = new Float32Array(256);
(function seed() {
var p = [], i, j, t;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { j = (Math.random() * (i + 1)) | 0; t = p[i]; p[i] = p[j]; p[j] = t; }
for (i = 0; i < 512; i++) PERM[i] = p[i & 255];
for (i = 0; i < 256; i++) VAL[i] = Math.random();
})();
function fade(t) { return t * t * (3 - 2 * t); } // 스무스스텝 — 격자 경계에서 각이 안 지게
function vnoise(x, y, z) {
var xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
var xf = x - xi, yf = y - yi, zf = z - zi;
xi &= 255; yi &= 255; zi &= 255;
var u = fade(xf), v = fade(yf), w = fade(zf);
var a = PERM[xi] + yi, b = PERM[xi + 1] + yi;
var aa = PERM[a] + zi, ab = PERM[a + 1] + zi;
var ba = PERM[b] + zi, bb = PERM[b + 1] + zi;
var c000 = VAL[PERM[aa]], c100 = VAL[PERM[ba]];
var c010 = VAL[PERM[ab]], c110 = VAL[PERM[bb]];
var c001 = VAL[PERM[aa + 1]], c101 = VAL[PERM[ba + 1]];
var c011 = VAL[PERM[ab + 1]], c111 = VAL[PERM[bb + 1]];
var x00 = c000 + (c100 - c000) * u, x10 = c010 + (c110 - c010) * u;
var x01 = c001 + (c101 - c001) * u, x11 = c011 + (c111 - c011) * u;
var y0 = x00 + (x10 - x00) * v, y1 = x01 + (x11 - x01) * v;
return y0 + (y1 - y0) * w;
}
// fbm — 같은 노이즈를 주파수 2배·진폭 절반으로 겹쳐 쌓는다. 겹 수가 곧 결의 고움이다
function fbm(x, y, z, oct) {
var sum = 0, amp = 0.5, norm = 0, f = 1;
for (var i = 0; i < oct; i++) {
sum += vnoise(x * f, y * f, z * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 색 — 토큰 네 단계를 256칸 표로 펴 두고 노이즈 값으로 찾아 쓴다 ─────────────────
var css = getComputedStyle(root);
function rgbOf(name) {
var v = css.getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
var STOPS = [rgbOf('--n0'), rgbOf('--n1'), rgbOf('--n2'), rgbOf('--n3')];
var LUT = new Uint8Array(256 * 3);
for (var q = 0; q < 256; q++) {
var t = q / 255 * (STOPS.length - 1);
var k = Math.min(STOPS.length - 2, Math.floor(t));
var f2 = t - k;
for (var c = 0; c < 3; c++) LUT[q * 3 + c] = STOPS[k][c] + (STOPS[k + 1][c] - STOPS[k][c]) * f2;
}
var SCALE = 0.034; // 격자 간격 — 작을수록 구름 덩어리가 커진다. 0.05 에서는 결이
// 스틸컷에서 거칠게 보였다(실측) — 낮춰 덩어리를 키운다
var FLOW = 0.24; // 시간축이 흐르는 속도. 시간은 노이즈의 셋째 차원이다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var octaves = 4;
var panX = 0, panY = 0, aimX = 0, aimY = 0;
var raf = 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);
}
function paint(sec) {
panX += (aimX - panX) * 0.14;
panY += (aimY - panY) * 0.14;
var z = sec * FLOW;
var i = 0;
for (var y = 0; y < NH; y++) {
var ny = (y + panY) * SCALE;
for (var x = 0; x < NW; x++) {
var n = fbm((x + panX) * SCALE, ny, z, octaves);
n = n * 1.5 - 0.25; // 값 노이즈는 가운데로 몰린다 — 대비를 한 번 펴 준다
if (n < 0) n = 0; else if (n > 1) n = 1;
var q2 = (n * 255) | 0;
px[i] = LUT[q2 * 3];
px[i + 1] = LUT[q2 * 3 + 1];
px[i + 2] = LUT[q2 * 3 + 2];
px[i + 3] = 255;
i += 4;
}
}
sctx.putImageData(buf, 0, 0);
// 확대 보간을 켜 둔 채로 늘린다 — 이 한 줄이 픽셀 덩어리를 구름으로 바꾼다
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, NW, NH, 0, 0, W, H);
}
function frame(now) {
paint(now / 1000);
raf = requestAnimationFrame(frame);
}
function setOct(v) {
octaves = Number(v);
octOut.textContent = String(octaves);
if (reduce) paint(0);
}
octIn.addEventListener('input', function () { setOct(octIn.value); });
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
aimX = ((e.clientX - r.left) / r.width - 0.5) * 190;
aimY = ((e.clientY - r.top) / r.height - 0.5) * 120;
if (reduce) paint(0);
});
field.addEventListener('pointerleave', function () { aimX = 0; aimY = 0; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24] }[e.key];
if (!d) return;
e.preventDefault();
aimX = Math.max(-190, Math.min(190, aimX + d[0]));
aimY = Math.max(-120, Math.min(120, aimY + d[1]));
if (reduce) paint(0);
});
window.addEventListener('resize', function () { fit(); if (reduce) paint(0); });
fit();
setOct(octIn.value);
if (reduce) {
// reduce — 루프를 시작하지 않고 구름이 멈춰 선 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
panX = 0; panY = 0;
paint(0);
} else {
raf = requestAnimationFrame(frame);
}// NoiseShader2D — vanilla 판(run/077/demo/03-fx-noise-shader-2d/index.html) 의 React 포트.
// 160x100 짜리 ImageData 에 값 노이즈 4옥타브(fbm) 를 픽셀 단위로 써 넣고
// imageSmoothingEnabled 를 켠 채 drawImage 로 늘려 화면을 채운다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NoiseShader2D.module.scss";
export type NoiseShader2DProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
type InputEvt = { target: { value: string } };
const NW = 160, NH = 100; // 노이즈 버퍼 해상도 — 화면이 커져도 이 값은 그대로다
const SCALE = 0.034; // 격자 간격 — 작을수록 구름 덩어리가 커진다
const FLOW = 0.24; // 시간축이 흐르는 속도. 시간은 노이즈의 셋째 차원이다
const FIELD_LABEL = "노이즈 실험판";
function fade(t: number): number {
return t * t * (3 - 2 * t); // 스무스스텝 — 격자 경계에서 각이 안 지게
}
// ── 값 노이즈 — 격자 꼭짓점마다 난수를 하나 두고 그 사이를 부드럽게 잇는다 ───────────
// 그라디언트 노이즈(펄린)보다 단순하고, 네 겹만 쌓아도 구름으로 읽힌다.
class ValueNoise {
perm: Uint8Array;
val: Float32Array;
constructor() {
const p: number[] = [];
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = (Math.random() * (i + 1)) | 0;
const t = p[i]; p[i] = p[j]; p[j] = t;
}
this.perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
this.val = new Float32Array(256);
for (let i = 0; i < 256; i++) this.val[i] = Math.random();
}
at(x: number, y: number, z: number): number {
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
const xf = x - xi, yf = y - yi, zf = z - zi;
const X = xi & 255, Y = yi & 255, Z = zi & 255;
const u = fade(xf), v = fade(yf), w = fade(zf);
const a = this.perm[X] + Y, b = this.perm[X + 1] + Y;
const aa = this.perm[a] + Z, ab = this.perm[a + 1] + Z;
const ba = this.perm[b] + Z, bb = this.perm[b + 1] + Z;
const c000 = this.val[this.perm[aa]], c100 = this.val[this.perm[ba]];
const c010 = this.val[this.perm[ab]], c110 = this.val[this.perm[bb]];
const c001 = this.val[this.perm[aa + 1]], c101 = this.val[this.perm[ba + 1]];
const c011 = this.val[this.perm[ab + 1]], c111 = this.val[this.perm[bb + 1]];
const x00 = c000 + (c100 - c000) * u, x10 = c010 + (c110 - c010) * u;
const x01 = c001 + (c101 - c001) * u, x11 = c011 + (c111 - c011) * u;
const y0 = x00 + (x10 - x00) * v, y1 = x01 + (x11 - x01) * v;
return y0 + (y1 - y0) * w;
}
}
// fbm — 같은 노이즈를 주파수 2배·진폭 절반으로 겹쳐 쌓는다. 겹 수이 곧 결의 고움이다
function fbm(n: ValueNoise, x: number, y: number, z: number, oct: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i++) {
sum += n.at(x * f, y * f, z * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 토큰 네 단계를 256칸 표로 편다 — 프레임마다 색을 다시 섞지 않게 하기 위해서다
function buildLut(stops: number[][]): Uint8Array {
const lut = new Uint8Array(256 * 3);
for (let q = 0; q < 256; q++) {
const t = q / 255 * (stops.length - 1);
const k = Math.min(stops.length - 2, Math.floor(t));
const f = t - k;
for (let c = 0; c < 3; c++) lut[q * 3 + c] = stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f;
}
return lut;
}
export default function NoiseShader2D({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: NoiseShader2DProps) {
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 smallRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const bufRef = useRef<ImageData | null>(null);
const noiseRef = useRef<ValueNoise | null>(null);
const lutRef = useRef<Uint8Array | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const panRef = useRef({ x: 0, y: 0, ax: 0, ay: 0 }) as Box<{ x: number; y: number; ax: number; ay: number }>;
const octRef = useRef(4) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [oct, setOct] = useState(4);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function hex3to6(v: string): string {
if (v.length === 3) return v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return v;
}
function stops(): number[][] {
const root = rootRef.current;
if (!root) return [[0, 0, 0], [255, 255, 255]];
const one = (n: string) => {
const v = hex3to6(getComputedStyle(root).getPropertyValue(n).trim().replace("#", ""));
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
return [one("--n0"), one("--n1"), one("--n2"), one("--n3")];
}
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
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;
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 paint(sec: number): void {
const ctx = ctxRef.current, sctx = sctxRef.current, n = noiseRef.current;
const buf = bufRef.current, lut = lutRef.current;
if (!ctx || !sctx || !n || !buf || !lut) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const pan = panRef.current;
pan.x += (pan.ax - pan.x) * 0.14;
pan.y += (pan.ay - pan.y) * 0.14;
const z = sec * FLOW;
const px = buf.data;
let i = 0;
for (let y = 0; y < NH; y++) {
const ny = (y + pan.y) * SCALE;
for (let x = 0; x < NW; x++) {
let v = fbm(n, (x + pan.x) * SCALE, ny, z, octRef.current);
v = v * 1.5 - 0.25; // 값 노이즈는 가운데로 몰린다 — 대비를 한 번 펴 준다
if (v < 0) v = 0; else if (v > 1) v = 1;
const q = (v * 255) | 0;
px[i] = lut[q * 3];
px[i + 1] = lut[q * 3 + 1];
px[i + 2] = lut[q * 3 + 2];
px[i + 3] = 255;
i += 4;
}
}
sctx.putImageData(buf, 0, 0);
// 확대 보간을 켜 둔 채로 늘린다 — 이 한 줄이 픽셀 덩어리를 구름으로 바꾼다
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(sctx.canvas, 0, 0, NW, NH, 0, 0, W, H);
}
function frame(now: number): void {
paint(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function setOctValue(v: number): void {
octRef.current = v;
setOct(v);
if (reduced()) paint(0);
}
function onInput(e: InputEvt): void {
setOctValue(Number(e.target.value));
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const pan = panRef.current;
pan.ax = ((e.clientX - r.left) / r.width - 0.5) * 190;
pan.ay = ((e.clientY - r.top) / r.height - 0.5) * 120;
if (reduced()) paint(0);
}
function onLeave(): void {
panRef.current.ax = 0;
panRef.current.ay = 0;
}
function onKey(e: KeyEvt): void {
const pan = panRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24],
};
const d = map[e.key];
if (!d) return;
e.preventDefault();
pan.ax = Math.max(-190, Math.min(190, pan.ax + d[0]));
pan.ay = Math.max(-120, Math.min(120, pan.ay + d[1]));
if (reduced()) paint(0);
}
function onResize(): void {
fit();
if (reduced()) paint(0);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
const small = document.createElement("canvas");
small.width = NW;
small.height = NH;
smallRef.current = small;
sctxRef.current = small.getContext("2d");
if (!sctxRef.current) return;
bufRef.current = sctxRef.current.createImageData(NW, NH); // 한 번만 만들어 두고 프레임마다 같은 배열을 덮어쓴다
noiseRef.current = new ValueNoise();
lutRef.current = buildLut(stops());
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 구름이 멈춰 선 한 장만 그린다
setStill(true);
panRef.current.x = 0;
panRef.current.y = 0;
paint(0);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} 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="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></svg>
<span>끌면 구름이 밀리고, 겹을 줄이면 결이 거칠어집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">160 × 100</b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>노이즈 겹
<input type="range" min={2} max={5} step={1} value={oct} aria-label="노이즈 겹" onChange={onInput} />
<b className={styles.val}>{oct}</b>
</label>
</div>
</div>
</main>
);
}03 · Noise shader in 2D — Measured area 44.889% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Noise is stacked at double frequency
// fbm — the same noise stacked at double frequency and half amplitude.
// The layer count is the fineness of the grain.
function fbm(x, y, z, oct) {
var sum = 0, amp = 0.5, norm = 0, f = 1;
for (var i = 0; i < oct; i++) {
sum += vnoise(x * f, y * f, z * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
This mimics a fragment shader loop but drops the resolution to 160x100, cutting the per-frame pixel math to about a hundredth of the screen. Time feeds in as the third argument so the clouds drift, and dropping the layers from 4 to 2 makes the grain chunky (measured: slider at 2 layers, 69,182 alpha pixels changed).
04Wobbling 3D text
The phrase is drawn once onto an offscreen canvas, then copied back one scanline at a time, each line pushed sideways by a sine wave — a rippling headline for an event page.

<main class="stage">
<div class="tw" id="tw">
<p class="tw__hint">
<svg class="tw__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 128c-52 110.85-78 55.43-104 0Zm-208 0h104c-26-55.43-52-110.85-104 0" opacity=".2"/><path d="M239.24 131.4c-22 46.8-41.4 68.6-61.2 68.6c-25.1 0-40.73-33.32-57.28-68.6C107.7 103.56 92.9 72 78 72c-16.4 0-36.31 37.21-46.72 59.4a8 8 0 0 1-14.48-6.8C38.71 77.8 58.16 56 78 56c25.1 0 40.73 33.32 57.28 68.6C148.3 152.44 163.1 184 178 184c16.4 0 36.31-37.21 46.72-59.4a8 8 0 0 1 14.48 6.8Z"/></g></svg>
<span>Change the phrase and each scanline slides by its own sine phase</span>
</p>
<div class="tw__field">
<canvas class="tw__cv" id="cv"></canvas>
<b class="tw__badge" aria-hidden="true"><span class="tw__badgeNum">2px</span><span class="tw__badgeUnit">scanline</span></b>
</div>
<div class="tw__bar">
<input class="tw__in" id="phraseIn" type="text" maxlength="12" value="Ripple like a flag" placeholder="Type a phrase" aria-label="Phrase to show">
<button class="tw__btn" id="nextBtn" type="button">Next phrase</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 흔들리는 3D 텍스트 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·입력칸만 맡는다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 시계를 고정하지 않고
// 실시간으로 24장을 찍는다(01 항목과 같은 방식, 도구/demo.py 렌더()).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.tw {
--face: #{$subject-ink}; // 글자 앞면 — 노랑 무대 위 잉크
--depth: #{$color}; // 아래 복사층(가짜 3D 돌출) — 파랑 한 겹
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tw__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;
}
.tw__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.tw__field {
position: relative; height: 184px;
border-radius: $r-card; overflow: hidden;
background: $stage-yellow;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
}
.tw__cv { display: block; width: 100%; height: 100%; }
// 코드의 ROW 상수를 화면에도 적어 둔다 — 2px 스캔라인
.tw__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;
}
.tw__badgeNum { font-size: 13px; color: $stage-yellow; }
.tw__badgeUnit { font-weight: 700; }
.tw__bar { display: flex; align-items: center; gap: $sp-2; }
.tw__in {
flex: 1 1 auto; min-width: 0; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: $stage-paper; color: $subject-ink;
font-weight: 700; font-size: 12px; line-height: 1.4;
font-family: inherit;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .22);
}
.tw__in:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tw__btn {
flex: 0 0 auto; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-control;
background: $subject-ink; color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.4;
font-family: inherit; cursor: pointer;
}
.tw__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tw__btn:active { transform: translateY(1px); }
@media (max-width: 340px) {
.tw { gap: $sp-1; }
.tw__field { height: 108px; }
.tw__badge { font-size: 10px; padding: 0 $sp-1; }
.tw__badgeNum { font-size: 11px; }
.tw__bar { gap: $sp-1; }
.tw__in { font-size: 11px; padding: $sp-1; }
.tw__btn { padding: $sp-1; font-size: 10px; }
.tw__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 출렁임이 잡힌 정적 한 장이 남으므로
// 문구를 바꾸면 즉시 다시 그린다. 없애야 할 애니메이션이 없어 커서 신호만 정리한다.
@media (prefers-reduced-motion: reduce) {
.tw.is-still .tw__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);
}
.tw {
--face: #17141a;
--depth: #2f6df6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tw__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;
}
.tw__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.tw__field {
position: relative;
height: 184px;
border-radius: 16px;
overflow: hidden;
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
}
.tw__cv {
display: block;
width: 100%;
height: 100%;
}
.tw__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;
}
.tw__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.tw__badgeUnit {
font-weight: 700;
}
.tw__bar {
display: flex;
align-items: center;
gap: 8px;
}
.tw__in {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.22);
}
.tw__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tw__btn {
flex: 0 0 auto;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
}
.tw__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tw__btn:active {
transform: translateY(1px);
}
@media (max-width: 340px) {
.tw {
gap: 4px;
}
.tw__field {
height: 108px;
}
.tw__badge {
font-size: 10px;
padding: 0 4px;
}
.tw__badgeNum {
font-size: 11px;
}
.tw__bar {
gap: 4px;
}
.tw__in {
font-size: 11px;
padding: 4px;
}
.tw__btn {
padding: 4px;
font-size: 10px;
}
.tw__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tw.is-still .tw__badge {
opacity: 0.92 !important;
}
}// 흔들리는 3D 텍스트 — 글자를 보이지 않는 캔버스에 딱 한 번 fillText 로 그려 두고,
// 본 캔버스에는 가로 scanline(2px) 단위로 drawImage 잘라 붙인다. 이때 줄마다 사인파 위상만큼
// 옆으로 밀고 가로 폭도 살짝 들숨 쉬게 하면 깃발처럼 출렁이는 글자가 된다.
// 아래쪽에 먼저 깔아 두는 어두운 복사층이 두께(가짜 3D 돌출)를 만든다.
var root = document.getElementById('tw');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var phraseIn = document.getElementById('phraseIn');
var nextBtn = document.getElementById('nextBtn');
var css = getComputedStyle(root);
var FACE = css.getPropertyValue('--face').trim(); // 글자 앞면
var DEPTH = css.getPropertyValue('--depth').trim(); // 아래 복사층(돌출)
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var PRESETS = ['Ripple like a flag', 'With one canvas', 'Extruded 3D letters'];
var presetAt = 0;
var ROW = 2; // scanline 한 줄의 높이(CSS px). 화면 배지와 같은 값이다
var DROP = 7; // 복사층을 아래로 미루는 양 — 두께로 읽힌다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var phrase = phraseIn.value;
// 글자 원본 — 앞면과 복사층 두 장. 그리는 시점마다 다시 만든다(문구·창 폭이 바뀔 때뿐이다)
var face = document.createElement('canvas');
var depth = document.createElement('canvas');
var fctx = face.getContext('2d');
var dctx = depth.getContext('2d');
function fit() {
var r = cv.parentElement.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);
}
// 오프스크린 한 번 그리기 — measureText 로 100px 에서 폭을 재고 판 폭의 84% 에 맞춰 크기를 역산해
// 중앙 정렬한다. 긴 문구를 넣어도 넘치지 않는 이유가 이 두 줄이다.
function build() {
face.width = cv.width; face.height = cv.height;
depth.width = cv.width; depth.height = cv.height;
var w = face.width, h = face.height;
fctx.font = '700 100px ' + FONT;
var wide = Math.max(1, fctx.measureText(phrase).width);
var size = Math.min(100 * (w * 0.84) / wide, h * 0.5);
fctx.textAlign = 'center';
fctx.textBaseline = 'middle';
fctx.fillStyle = FACE;
fctx.font = '700 ' + size + 'px ' + FONT;
fctx.fillText(phrase, w * 0.5, h * 0.47);
dctx.textAlign = 'center';
dctx.textBaseline = 'middle';
dctx.fillStyle = DEPTH;
dctx.font = '700 ' + size + 'px ' + FONT;
dctx.fillText(phrase, w * 0.5, h * 0.47);
}
// 파동 — 사인 합성. 줄의 옆 이동 sx 는 y 와 시간 t 의 사인 둘(서로 반대 방향으로 달리는 행파),
// 가로 스케일 w 는 y 와 t 의 사인 하나. 스케일은 중심 기준이므로 밀림이 x 의 1차 함수가 되어
// 세 항을 합하면 변위가 x·y·t 에 모두 의존하는 마퀴식 출렁임이 된다.
function pass(img, drop, t) {
for (var y = 0; y < H; y += ROW) {
var sx = 7 * Math.sin(0.055 * y + 2.4 * t) + 3 * Math.sin(0.021 * y - 1.5 * t + 1.4);
var w = 1 + 0.05 * Math.sin(0.026 * y - 1.9 * t + 0.7);
ctx.drawImage(img, 0, y * dpr, img.width, ROW * dpr, (W - W * w) * 0.5 + sx, y + drop, W * w, ROW);
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
pass(depth, DROP, t - 0.12); // 복사층 먼저 — 위상을 조금 늦게 줘 앞면과 어긋나면 돌출이 더 산다
pass(face, 0, t);
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function setPhrase(p) {
phrase = p || PRESETS[0];
build();
if (reduce) draw(1.7);
}
phraseIn.addEventListener('input', function () { setPhrase(phraseIn.value); });
nextBtn.addEventListener('click', function () {
presetAt = (presetAt + 1) % PRESETS.length;
phraseIn.value = PRESETS[presetAt];
setPhrase(phraseIn.value);
});
window.addEventListener('resize', function () { fit(); build(); if (reduce) draw(1.7); });
fit();
build();
if (reduce) {
// reduce — 루프를 시작하지 않는다. 출렁임이 잡힌 한 순간의 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(1.7);
} else {
raf = requestAnimationFrame(frame);
}// TextHeatWarp — vanilla 판(run/077/demo/04-fx-text-heat-warp/index.html) 의 React 포트.
// 글자를 오프스크린 캔버스에 한 번만 fillText 로 그려 두고, 가로 스캔라인(2px) 단위로 drawImage
// 잘라 붙이되 줄마다 사인파 위상만큼 옆으로 민다. 아래 복사층이 두께(가짜 3D 돌출)를 만든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./TextHeatWarp.module.scss";
export type TextHeatWarpProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type InEvt = { target: { value: string } };
const PRESETS = ["깃발처럼 출렁", "canvas 한 장으로", "3D 돌출 글자"];
const ROW = 2; // 스캔라인 한 줄의 높이(CSS px). 화면 배지와 같은 값이다
const DROP = 7; // 복사층을 아래로 미루는 양 — 두께로 읽힌다
const STILL_T = 1.7; // reduce 정지판을 찍는 파동의 시각(초)
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const PHRASE_LABEL = "표시할 문구";
const PLACEHOLDER = "문구를 입력하세요";
const NEXT_LABEL = "다음 문구";
export default function TextHeatWarp({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: TextHeatWarpProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const faceRef = useRef<HTMLCanvasElement | null>(null);
const depthRef = useRef<HTMLCanvasElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<{ w: number; h: number; dpr: number }>;
const phraseRef = useRef("깃발처럼 출렁") as Box<string>;
const presetRef = 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 cv = cvRef.current;
if (!cv || !cv.parentElement) return;
const r = cv.parentElement.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);
ctxRef.current!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 오프스크린 한 번 그리기 — measureText 로 100px 에서 폭을 재고 판 폭의 84% 에 맞춰
// 크기를 역산해 중앙 정렬한다. 긴 문구를 넣어도 넘치지 않는 이유가 이 두 줄이다.
function build(): void {
const cv = cvRef.current;
const face = faceRef.current;
const depth = depthRef.current;
const root = rootRef.current;
if (!cv || !face || !depth || !root) return;
face.width = cv.width; face.height = cv.height;
depth.width = cv.width; depth.height = cv.height;
const fctx = face.getContext("2d");
const dctx = depth.getContext("2d");
if (!fctx || !dctx) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const w = face.width, h = face.height;
const phrase = phraseRef.current;
fctx.font = "700 100px " + FONT;
const wide = Math.max(1, fctx.measureText(phrase).width);
const size = Math.min(100 * (w * 0.84) / wide, h * 0.5);
fctx.textAlign = "center";
fctx.textBaseline = "middle";
fctx.fillStyle = cssv("--face");
fctx.font = "700 " + size + "px " + FONT;
fctx.fillText(phrase, w * 0.5, h * 0.47);
dctx.textAlign = "center";
dctx.textBaseline = "middle";
dctx.fillStyle = cssv("--depth");
dctx.font = "700 " + size + "px " + FONT;
dctx.fillText(phrase, w * 0.5, h * 0.47);
}
// 파동 — 사인 합성. 줄의 옆 이동 sx 는 y 와 시간 t 의 사인 둘, 가로 스케일 w 는 y 와 t 의
// 사인 하나. 스케일은 중심 기준이므로 밀림이 x 의 1차 함수가 되어 세 항을 합하면 변위가
// x·y·t 에 모두 의존하는 마퀴식 출렁임이 된다.
function pass(img: HTMLCanvasElement, drop: number, t: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const { w: W, h: H, dpr } = sizeRef.current;
for (let y = 0; y < H; y += ROW) {
const sx = 7 * Math.sin(0.055 * y + 2.4 * t) + 3 * Math.sin(0.021 * y - 1.5 * t + 1.4);
const w = 1 + 0.05 * Math.sin(0.026 * y - 1.9 * t + 0.7);
ctx.drawImage(img, 0, y * dpr, img.width, ROW * dpr, (W - W * w) * 0.5 + sx, y + drop, W * w, ROW);
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const face = faceRef.current;
const depth = depthRef.current;
if (!ctx || !face || !depth) return;
const { w: W, h: H } = sizeRef.current;
ctx.clearRect(0, 0, W, H);
// 복사층 먼저 — 위상을 조금 늦게 줘 앞면과 어긋나면 돌출이 더 산다
pass(depth, DROP, t - 0.12);
pass(face, 0, t);
}
function frame(now: number): void {
draw(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function setPhrase(p: string): void {
phraseRef.current = p || PRESETS[0];
build();
if (reduced()) draw(STILL_T);
}
function onInput(e: InEvt): void {
setPhrase(e.target.value);
}
function onNext(): void {
const input = inputRef.current;
presetRef.current = (presetRef.current + 1) % PRESETS.length;
const next = PRESETS[presetRef.current];
if (input) input.value = next;
setPhrase(next);
}
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
faceRef.current = document.createElement("canvas");
depthRef.current = document.createElement("canvas");
if (!ctxRef.current) return;
fit();
build();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않는다. 출렁임이 잡힌 한 순간의 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
draw(STILL_T);
} 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 128c-52 110.85-78 55.43-104 0Zm-208 0h104c-26-55.43-52-110.85-104 0" opacity=".2"/><path d="M239.24 131.4c-22 46.8-41.4 68.6-61.2 68.6c-25.1 0-40.73-33.32-57.28-68.6C107.7 103.56 92.9 72 78 72c-16.4 0-36.31 37.21-46.72 59.4a8 8 0 0 1-14.48-6.8C38.71 77.8 58.16 56 78 56c25.1 0 40.73 33.32 57.28 68.6C148.3 152.44 163.1 184 178 184c16.4 0 36.31-37.21 46.72-59.4a8 8 0 0 1 14.48 6.8Z"/></g></svg>
<span>문구를 바꾸면 가로 줄마다 사인파 위상만큼 밀려 출렁입니다</span>
</p>
<div className={styles.field}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>2px</span><span className={styles.badgeUnit}>스캔라인</span></b>
</div>
<div className={styles.bar}>
<input
className={styles.in}
ref={inputRef}
type="text"
maxLength={12}
defaultValue="깃발처럼 출렁"
placeholder={PLACEHOLDER}
aria-label={PHRASE_LABEL}
onChange={onInput}
/>
<button className={styles.btn} type="button" onClick={onNext}>{NEXT_LABEL}</button>
</div>
</div>
</main>
);
}04 · Wobbling 3D text — Measured area 13.245% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Each line is pushed by a sine phase
function pass(img, drop, t) {
for (var y = 0; y < H; y += ROW) {
var sx = 7 * Math.sin(0.055 * y + 2.4 * t) + 3 * Math.sin(0.021 * y - 1.5 * t + 1.4);
var w = 1 + 0.05 * Math.sin(0.026 * y - 1.9 * t + 0.7);
ctx.drawImage(img, 0, y * dpr, img.width, ROW * dpr, (W - W * w) * 0.5 + sx, y + drop, W * w, ROW);
}
}
Two sines run as counter-marching waves, so a phase difference builds between lines and the letters ripple like a flag. A dark copy laid underneath gives the letters thickness, and typing your own phrase lets measureText re-center it (measured: 14,779 alpha pixels changed on phrase swap).
05Black hole tunnel
One feedback zoom — redrawing the previous frame slightly enlarged about the center — produces a tunnel that pulls inward forever, right before a page transition.

<main class="stage">
<div class="bh" id="bh">
<p class="bh__hint">
<svg class="bh__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg>
<span>Lower the zoom and the tunnel pulls deeper and slower</span>
</p>
<div class="bh__field">
<canvas class="bh__cv" id="cv"></canvas>
</div>
<div class="bh__bar">
<label class="bh__dial">Zoom
<input type="range" id="zoom" min="102" max="110" step="1" value="106" aria-label="Zoom">
<b class="bh__val" id="zoomVal">×1.06</b>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 블랙홀 터널 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·다이얼만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 실시간으로 24장을 찍는다(01 항목과 같은 방식).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.bh {
--bg-0: #{mix($subject-blue, $stage-ink, 14%)};
--bg-1: #{$stage-ink};
// 터널 벽 얼룩 — 크림·하늘빛·노랑·주황이 섞여 돌아간다
--speck-cream: #{$subject-cream};
--speck-blue: #{$subject-blue-light};
--speck-yellow: #{$stage-yellow};
--speck-orange: #{$stage-orange};
// 강착 빛(지평선 바깥의 따뜻한 안개)과 광자 구
--glow-0: #{rgba($stage-orange, .5)};
--glow-1: #{rgba($stage-orange, 0)};
--ring: #{$stage-yellow};
--hole: #000; // 사건의 지평선 — 잉크보다 더 깊은 검정
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.bh__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;
}
.bh__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.bh__field {
position: relative; height: 184px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
}
.bh__cv { display: block; width: 100%; height: 100%; }
.bh__bar { display: flex; align-items: center; gap: $sp-3; }
.bh__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.bh__dial input { width: 116px; margin: 0; accent-color: $subject-blue-light; }
.bh__val { color: $stage-yellow; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.bh { gap: $sp-1; }
.bh__field { height: 104px; }
.bh__dial { gap: $sp-1; }
.bh__dial input { width: 68px; }
.bh__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 완성된 터널의 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.bh.is-still .bh__val { 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);
}
.bh {
--bg-0: rgb(10.337254902%, 12.7294117647%, 22.2745098039%);
--bg-1: #17141a;
--speck-cream: #fff7e6;
--speck-blue: #5b8cf8;
--speck-yellow: #ffd23f;
--speck-orange: #ff4d1f;
--glow-0: rgba(255, 77, 31, 0.5);
--glow-1: rgba(255, 77, 31, 0);
--ring: #ffd23f;
--hole: #000;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.bh__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;
}
.bh__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.bh__field {
position: relative;
height: 184px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
}
.bh__cv {
display: block;
width: 100%;
height: 100%;
}
.bh__bar {
display: flex;
align-items: center;
gap: 12px;
}
.bh__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.bh__dial input {
width: 116px;
margin: 0;
accent-color: #5b8cf8;
}
.bh__val {
color: #ffd23f;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.bh {
gap: 4px;
}
.bh__field {
height: 104px;
}
.bh__dial {
gap: 4px;
}
.bh__dial input {
width: 68px;
}
.bh__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bh.is-still .bh__val {
opacity: 0.92 !important;
}
}// 블랙홀 터널 — 되먹임 확대 하나로 만든다. 매 프레임 캔버스 자기 자신(직전 프레임)을
// 보조 캔버스에 복사한 뒤 중심 기준 scale 배로 키워 자기 위에 다시 drawImage 한다.
// 자기 위에 겹쳐 그리면 읽는 도중 내용이 바뀌므로 반드시 보조 캔버스를 거친다.
// 키워진 지난 그림 위에 매 프레임 새 얼룩(밝은 점·호)을 lighter 합성으로 조금씩 뿌리면
// 얼룩이 바깥으로 흘러 나가며 끝없이 빨려 드는 터널 벽이 된다.
var root = document.getElementById('bh');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var zoomIn = document.getElementById('zoom');
var zoomOut = document.getElementById('zoomVal');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var BG0 = v('--bg-0'), BG1 = v('--bg-1');
var SPECKS = [v('--speck-cream'), v('--speck-blue'), v('--speck-yellow'), v('--speck-orange')];
var GLOW0 = v('--glow-0'), GLOW1 = v('--glow-1');
var RING = v('--ring');
var HOLE = v('--hole');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var zoom = 1.06; // 되먹임 배율 — 편 변형 1번(1.06 에서 1.02 로)과 같은 축
var SEEDS = 4; // 프레임마다 뿌리는 얼룩 수
var raf = 0;
var aux = document.createElement('canvas');
var actx = aux.getContext('2d');
function fit() {
var r = cv.parentElement.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
// 이 데모는 되먹임 drawImage 이 좌표계가 흐트러지지 않게 장치 픽셀로만 그린다(변환 없음)
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
aux.width = cv.width; aux.height = cv.height;
}
// 바닥 — 짙은 방사 배경과 옅은 동심 원 몇 개. 첫 프레임이 빈 화면이 되지 않게 한다
function base() {
var w = cv.width, h = cv.height;
var g = ctx.createRadialGradient(w * 0.5, h * 0.5, 4, w * 0.5, h * 0.5, Math.max(w, h) * 0.72);
g.addColorStop(0, BG0);
g.addColorStop(1, BG1);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = SPECKS[1];
for (var i = 1; i <= 4; i++) {
ctx.globalAlpha = 0.05 + i * 0.015;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, horizonR() * (1 + i * 0.85), 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function horizonR() { return Math.max(12, Math.round(Math.min(cv.width, cv.height) * 0.15)); }
// 새 얼룩 — 터널 벽의 밥알. 지평선 바로 바깥에서 태어나 확대되며 바깥으로 흘러 나간다
function speck() {
var w = cv.width, h = cv.height, r = horizonR();
var ang = Math.random() * Math.PI * 2;
var dist = r * (1.12 + Math.random() * 1.7);
var x = w * 0.5 + Math.cos(ang) * dist;
var y = h * 0.5 + Math.sin(ang) * dist;
ctx.fillStyle = SPECKS[(Math.random() * SPECKS.length) | 0];
if (Math.random() < 0.3) {
// 호 얼룩 — 훑고 지나가는 빛의 자국
ctx.lineWidth = 1 + Math.random() * 1.6;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, dist, ang - 0.18, ang + 0.18);
ctx.stroke();
} else {
ctx.globalAlpha = 0.5 + Math.random() * 0.5;
ctx.beginPath();
ctx.arc(x, y, 0.8 + Math.random() * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// 사건의 지평선 + 광자 구 — 매 프레임 마지막에 얹는다. 검은 원은 방사 그라디언트로
// 가장자리만 물러지게 지우고, 그 둘레를 도는 얇은 링이 흐르는 방향을 알려 준다.
function horizon(now) {
var w = cv.width, h = cv.height, r = horizonR();
var cx = w * 0.5, cy = h * 0.5;
// 강착 빛 — 지평선 바로 바깥의 따뜻한 안개
ctx.globalCompositeOperation = 'lighter';
var glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.1);
glow.addColorStop(0, GLOW0);
glow.addColorStop(1, GLOW1);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 2.1, 0, Math.PI * 2);
ctx.fill();
// 검은 원
ctx.globalCompositeOperation = 'source-over';
var disc = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r);
disc.addColorStop(0, HOLE);
disc.addColorStop(0.82, HOLE);
disc.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = disc;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// 광자 구 — 링 전체를 옅게, 그 위에 대시 한토막을 돌려 보인다
ctx.strokeStyle = RING;
ctx.lineWidth = Math.max(1, r * 0.05);
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
var circ = 2 * Math.PI * r * 1.06;
ctx.setLineDash([circ * 0.22, circ * 0.78]);
ctx.lineDashOffset = -(now / 1000 % 1) * circ;
ctx.lineWidth = Math.max(1.4, r * 0.09);
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
function frame(now) {
var w = cv.width, h = cv.height;
// ① 직전 프레임을 보조 캔버스에 복사
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
// ② 중심 기준 확대해 다시 깐다 — 이 한 번의 drawImage 가 터널 전부다
var dw = w * zoom, dh = h * zoom;
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
// ③ 새 얼룩을 더한다 — 겹치는 곳이 밝아지는 lighter 합성
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < SEEDS; i++) speck();
// ④ 지평선과 광자 구
horizon(now);
raf = requestAnimationFrame(frame);
}
function setZoom(val) {
zoom = Number(val) / 100;
zoomOut.textContent = '×' + zoom.toFixed(2);
}
zoomIn.addEventListener('input', function () { setZoom(zoomIn.value); });
window.addEventListener('resize', function () { fit(); if (reduce) still(); else base(); });
// 정지판 — 루프 없이 얼룩·확대를 몇십 번만 몰아 그려 이미 다 만들어진 터널 한 장을 얻는다
function still() {
base();
for (var i = 0; i < 60; i++) {
var w = cv.width, h = cv.height;
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
var dw = w * zoom, dh = h * zoom;
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
ctx.globalCompositeOperation = 'lighter';
for (var k = 0; k < SEEDS; k++) speck();
}
horizon(0);
}
fit();
setZoom(zoomIn.value);
if (reduce) {
// reduce — 루프를 시작하지 않고 완성된 터널의 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
still();
} else {
base();
raf = requestAnimationFrame(frame);
}// BlackholeTunnel — vanilla 판(run/077/demo/05-fx-blackhole-tunnel/index.html) 의 React 포트.
// 매 프레임 캔버스 자기 자신(직전 프레임)을 보조 캔버스에 복사한 뒤 중심 기준 확대해
// 다시 drawImage 하는 되먹임 하나로 끝없이 빨려 드는 터널을 만든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BlackholeTunnel.module.scss";
export type BlackholeTunnelProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type RngEvt = { target: { value: string } };
const ZOOM_LABEL = "확대율";
const SEEDS = 4; // 프레임마다 뿌리는 얼룩 수
const BASE_ZOOM = "106";
export default function BlackholeTunnel({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: BlackholeTunnelProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const zoomOutRef = useRef<HTMLElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const auxRef = useRef<HTMLCanvasElement | null>(null);
const actxRef = useRef<CanvasRenderingContext2D | null>(null);
const zoomRef = useRef(1.06) as Box<number>; // 되먹임 배율 — 편 변형 1번과 같은 축
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 horizonR(): number {
const cv = cvRef.current;
if (!cv) return 12;
return Math.max(12, Math.round(Math.min(cv.width, cv.height) * 0.15));
}
// 이 데모는 되먹임 drawImage 이 좌표계가 흐트러지지 않게 장치 픽셀로만 그린다(변환 없음)
function fit(): void {
const cv = cvRef.current;
const aux = auxRef.current;
if (!cv || !aux || !cv.parentElement) return;
const r = cv.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
aux.width = cv.width;
aux.height = cv.height;
}
// 바닥 — 짙은 방사 배경과 옅은 동심 원 몇 개. 첫 프레임이 빈 화면이 되지 않게 한다
function base(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const cv = cvRef.current;
if (!ctx || !root || !cv) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const w = cv.width, h = cv.height;
const g = ctx.createRadialGradient(w * 0.5, h * 0.5, 4, w * 0.5, h * 0.5, Math.max(w, h) * 0.72);
g.addColorStop(0, cssv("--bg-0"));
g.addColorStop(1, cssv("--bg-1"));
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = cssv("--speck-blue");
for (let i = 1; i <= 4; i++) {
ctx.globalAlpha = 0.05 + i * 0.015;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, horizonR() * (1 + i * 0.85), 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// 새 얼룩 — 터널 벽의 밥알. 지평선 바로 바깥에서 태어나 확대되며 바깥으로 흘러 나간다
function speck(): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const cv = cvRef.current;
if (!ctx || !root || !cv) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const specks = ["--speck-cream", "--speck-blue", "--speck-yellow", "--speck-orange"].map(cssv);
const w = cv.width, h = cv.height, r = horizonR();
const ang = Math.random() * Math.PI * 2;
const dist = r * (1.12 + Math.random() * 1.7);
const x = w * 0.5 + Math.cos(ang) * dist;
const y = h * 0.5 + Math.sin(ang) * dist;
ctx.strokeStyle = specks[(Math.random() * specks.length) | 0];
ctx.fillStyle = ctx.strokeStyle;
if (Math.random() < 0.3) {
// 호 얼룩 — 훑고 지나가는 빛의 자국
ctx.lineWidth = 1 + Math.random() * 1.6;
ctx.beginPath();
ctx.arc(w * 0.5, h * 0.5, dist, ang - 0.18, ang + 0.18);
ctx.stroke();
} else {
ctx.globalAlpha = 0.5 + Math.random() * 0.5;
ctx.beginPath();
ctx.arc(x, y, 0.8 + Math.random() * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// 사건의 지평선 + 광자 구 — 매 프레임 마지막에 얹는다. 검은 원은 방사 그라디언트로
// 가장자리만 물러지게 지우고, 그 둘레를 도는 얇은 링이 흐르는 방향을 알려 준다.
function horizon(now: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const cv = cvRef.current;
if (!ctx || !root || !cv) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const w = cv.width, h = cv.height, r = horizonR();
const cx = w * 0.5, cy = h * 0.5;
const hole = cssv("--hole");
// 강착 빛 — 지평선 바로 바깥의 따뜻한 안개
ctx.globalCompositeOperation = "lighter";
const glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.1);
glow.addColorStop(0, cssv("--glow-0"));
glow.addColorStop(1, cssv("--glow-1"));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 2.1, 0, Math.PI * 2);
ctx.fill();
// 검은 원
ctx.globalCompositeOperation = "source-over";
const disc = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r);
disc.addColorStop(0, hole);
disc.addColorStop(0.82, hole);
disc.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = disc;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// 광자 구 — 링 전체를 옅게, 그 위에 대시 한토막을 돌려 보인다
ctx.strokeStyle = cssv("--ring");
ctx.lineWidth = Math.max(1, r * 0.05);
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
const circ = 2 * Math.PI * r * 1.06;
ctx.setLineDash([circ * 0.22, circ * 0.78]);
ctx.lineDashOffset = -(now / 1000 % 1) * circ;
ctx.lineWidth = Math.max(1.4, r * 0.09);
ctx.beginPath();
ctx.arc(cx, cy, r * 1.06, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// 되먹임 한 걸음 — 직전 프레임을 확대해 다시 깐다. 이 drawImage 가 터널 전부다
function feedback(): void {
const ctx = ctxRef.current;
const actx = actxRef.current;
const cv = cvRef.current;
const aux = auxRef.current;
if (!ctx || !actx || !cv || !aux) return;
const w = cv.width, h = cv.height;
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
const dw = w * zoomRef.current, dh = h * zoomRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
}
function frame(now: number): void {
feedback();
const ctx = ctxRef.current;
if (ctx) {
// 새 얼룩을 더한다 — 겹치는 곳이 밝아지는 lighter 합성
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < SEEDS; i++) speck();
}
horizon(now);
rafRef.current = requestAnimationFrame(frame);
}
function setZoom(val: string): void {
zoomRef.current = Number(val) / 100;
if (zoomOutRef.current) zoomOutRef.current.textContent = "×" + zoomRef.current.toFixed(2);
if (reduced()) still();
}
function onRange(e: RngEvt): void {
setZoom(e.target.value);
}
// 정지판 — 루프 없이 얼룩·확대를 몇십 번만 몰아 그려 이미 다 만들어진 터널 한 장을 얻는다
function still(): void {
const ctx = ctxRef.current;
if (!ctx) return;
base();
for (let i = 0; i < 60; i++) {
feedback();
ctx.globalCompositeOperation = "lighter";
for (let k = 0; k < SEEDS; k++) speck();
}
horizon(0);
}
function onResize(): void {
fit();
if (reduced()) still();
else base();
}
useEffect(() => {
const cv = cvRef.current;
const aux = document.createElement("canvas");
auxRef.current = aux;
actxRef.current = aux.getContext("2d");
if (!cv || !actxRef.current) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
if (reduced()) {
// reduce — 루프를 시작하지 않고 완성된 터널의 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
still();
} else {
base();
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
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="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg>
<span>확대율을 낮추면 터널이 더 깊고 느리게 빨려 듭니다</span>
</p>
<div className={styles.field}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>{ZOOM_LABEL}
<input type="range" min="102" max="110" step="1" defaultValue={BASE_ZOOM} aria-label={ZOOM_LABEL} onChange={onRange}></input>
<b className={styles.val} ref={zoomOutRef}>×1.06</b>
</label>
</div>
</div>
</main>
);
}05 · Black hole tunnel — Measured area 52.193% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One feedback zoom is the whole tunnel
function frame(now) {
var w = cv.width, h = cv.height;
// 1. copy the previous frame onto an aux canvas
actx.clearRect(0, 0, w, h);
actx.drawImage(cv, 0, 0);
// 2. enlarge about the center and lay it back — this single drawImage is the tunnel
var dw = w * zoom, dh = h * zoom;
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(aux, 0, 0, w, h, (w - dw) * 0.5, (h - dh) * 0.5, dw, dh);
// 3. add fresh specks — the lighter composite brightens where they overlap
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < SEEDS; i++) speck();
// 4. horizon and photon sphere
horizon(now);
raf = requestAnimationFrame(frame);
}
Enlarging the previous frame by 1.06 every pass pushes the specks outward, and the tunnel becomes infinitely deep. Lowering the zoom to 1.02 visibly slows the pull (measured: pixels changed in the same 0.4s fell from 63,768 to 57,853).
06Galaxy swirl
Six hundred stars get a radius and a starting angle once; every position after that comes from a formula — a calm background for a dark-mode dashboard.

<main class="stage">
<div class="gs" id="gs">
<p class="gs__hint">
<svg class="gs__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M245.11 60.68c-7.65-13.19-27.84-16.16-58.5-8.66A95.93 95.93 0 0 0 32 128a98 98 0 0 0 .78 12.31C5.09 169 5.49 186 10.9 195.32C16 204.16 26.64 208 40.64 208a124 124 0 0 0 28.79-4A95.93 95.93 0 0 0 224 128a97 97 0 0 0-.77-12.25c12.5-13 20.82-25.35 23.65-35.92c1.95-7.32 1.36-13.76-1.77-19.15M128 48a80.11 80.11 0 0 1 78 62.2c-17.06 16.06-40.15 32.53-62.07 45.13c-27.55 15.81-51.45 25.67-70.51 31.07A79.94 79.94 0 0 1 128 48M24.74 187.29c-1.46-2.51-.65-7.24 2.22-13a79 79 0 0 1 10.29-15.05a96 96 0 0 0 18 31.32c-17.25 2.9-28.01 1.05-30.51-3.27M128 208a79.45 79.45 0 0 1-38.56-9.94a370 370 0 0 0 62.43-28.86c21.58-12.39 40.68-25.82 56.07-39.08A80.07 80.07 0 0 1 128 208M231.42 75.69c-1.7 6.31-6.19 13.53-12.63 21.13a95.7 95.7 0 0 0-18-31.35c14.21-2.35 27.37-2.17 30.5 3.24c.9 1.57.95 3.92.13 6.98"/></g></svg>
<span>Hover and that spot becomes the density hub for a moment</span>
</p>
<div class="gs__field" id="field" role="application" aria-label="Galaxy test board" tabindex="0">
<canvas class="gs__cv" id="cv"></canvas>
<b class="gs__badge" aria-hidden="true"><span class="gs__badgeNum">600</span><span class="gs__badgeUnit">stars</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 은하 회오리 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 실시간으로 24장을 찍는다(01 항목과 같은 방식).
// 별 빛깔은 반지름에 따라 파랑(바깥) 에서 주황(허브) 으로 — 색 닻 다섯을 커스텀 프로퍼티로 내려보낸다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.gs {
// 허브 빛 — 중심의 따뜻한 코어. 낮은 알파로 매 프레임 얹어 equilibrium 이 맞는다
--hub-0: #{rgba($stage-yellow, .9)};
--hub-1: #{rgba($stage-orange, 0)};
// 반지름 순서대로: 허브(주황) 따뜻한 노랑 크림 시원한 파랑 가장자리 파랑
--tone-hub: #{$stage-orange};
--tone-warm: #{$stage-yellow};
--tone-mid: #{$subject-cream};
--tone-cool: #{$subject-blue-light};
--tone-edge: #{mix($stage-ink, $subject-blue, 30%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gs__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;
}
.gs__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.gs__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: crosshair; touch-action: none;
}
.gs__field:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.gs__cv { display: block; width: 100%; height: 100%; }
// 코드의 COUNT 상수를 화면에도 적어 둔다 — 별 육백 개
.gs__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;
}
.gs__badgeNum { font-size: 13px; color: $stage-yellow; }
.gs__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.gs { gap: $sp-1; }
.gs__field { height: 124px; }
.gs__badge { font-size: 10px; padding: 0 $sp-1; }
.gs__badgeNum { font-size: 11px; }
.gs__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 나선이 감긴 정적 한 장이 남으므로 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.gs.is-still .gs__field { cursor: default !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);
}
.gs {
--hub-0: rgba(255, 210, 63, 0.9);
--hub-1: rgba(255, 77, 31, 0);
--tone-hub: #ff4d1f;
--tone-warm: #ffd23f;
--tone-mid: #fff7e6;
--tone-cool: #5b8cf8;
--tone-edge: rgb(15.6078431373%, 32.2745098039%, 70.5882352941%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gs__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;
}
.gs__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.gs__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: crosshair;
touch-action: none;
}
.gs__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.gs__cv {
display: block;
width: 100%;
height: 100%;
}
.gs__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;
}
.gs__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.gs__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.gs {
gap: 4px;
}
.gs__field {
height: 124px;
}
.gs__badge {
font-size: 10px;
padding: 0 4px;
}
.gs__badgeNum {
font-size: 11px;
}
.gs__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gs.is-still .gs__field {
cursor: default !important;
}
}// 은하 회오리 — stars 육백 개의 반지름 r 과 시작각 a0 은 초기화 때 한 번만 정한다.
// 매 프레임 위치는 식으로 계산한다: a = a0 + t * K / (r^0.8 + 20).
// 안쪽일수록 분모가 작아 각속도가 커진다(시더) — 속도나 수명을 저장하는 배열이 없어도
// 같은 시각 t 에서는 언제나 같은 그림이 나오고, 차등 회전이 쌓여 나선팔이 저절로 생긴다.
// 꼬리는 프레임을 완전히 지우지 않아서 생긴다 — destination-out 합성으로 낮은 알파를 발라
// 옛별이 서서히 지워지게 하면 별이 지나온 궤적이 남는다.
var root = document.getElementById('gs');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var HUB0 = v('--hub-0'), HUB1 = v('--hub-1'); // 허브 빛(중심의 따뜻한 코어)
var EDGE = [v('--tone-hub'), v('--tone-warm'), v('--tone-mid'), v('--tone-cool'), v('--tone-edge')];
var COUNT = 600;
var K = 13; // 각속도 계수 — r 이 0 에 가까우면 약 0.65rad/s, 바깥은 약 0.17rad/s
var FLAT = 0.58; // 세로 눌림 — 납작한 회전 은하로 읽히는 값
var TILT = -0.35; // 전체 기울기(라디안)
var cosT = Math.cos(TILT), sinT = Math.sin(TILT);
var FADE = 0.16; // 꼬리 길이 — 지우는 알파가 낮을수록 궤적이 길게 남는다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var cx = 0, cy = 0, rmax = 0;
var px = 0, py = 0, inf = 0, aimInf = 0; // 포인터 항 — (px, py) 를 향해 전체가 살짝 기운다
// stars — 반지름·시작각·크기·빛깔만 받는다. 속도·수명 배열은 없다(식이 전부 계산한다)
var stars = [];
for (var i = 0; i < COUNT; i++) {
var rr = Math.pow(Math.random(), 1.25); // 안쪽 밀도를 살짝 높인다(지수 1 이상이 중심으로 몬다)
stars.push({
r: rr,
a0: Math.random() * Math.PI * 2,
s: 0.7 + Math.random() * 1.5 + (Math.random() < 0.03 ? 0.9 : 0),
tone: EDGE[Math.min(EDGE.length - 1, Math.round(rr * (EDGE.length - 1)))],
tw: i % 9 === 0 ? Math.random() * Math.PI * 2 : -1 // 아홉 개 중 하나는 반짝인다
});
}
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);
cx = W * 0.5; cy = H * 0.52;
rmax = Math.min(W * 0.47, H * 0.82);
if (reduce) draw(42);
}
function draw(t) {
// 꼬리 — 완전히 지우지 않고 낮은 알파로 바른다
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, ' + FADE + ')';
ctx.fillRect(0, 0, W, H);
// 별과 허브 빛은 겹치면 밝아지는 lighter 로 얹는다
ctx.globalCompositeOperation = 'lighter';
var hub = ctx.createRadialGradient(cx, cy, 0, cx, cy, rmax * 0.34);
hub.addColorStop(0, HUB0);
hub.addColorStop(1, HUB1);
ctx.globalAlpha = 0.08;
ctx.fillStyle = hub;
ctx.beginPath();
ctx.arc(cx, cy, rmax * 0.34, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
for (var i = 0; i < COUNT; i++) {
var st = stars[i];
var R = st.r * rmax + 8; // 아주 중심으로 모두 몰리지 않게 바닥 반지름 8
var a = st.a0 + t * K / (Math.pow(R, 0.8) + 20);
var ex = Math.cos(a) * R;
var ey = Math.sin(a) * R * FLAT;
// 계산식의 포인터 항 — (px, py) 쪽으로 좌표 전체가 inf 만큼 기운다(밀도 중심이 잠깐 그 자리로)
var x = cx + ex * cosT - ey * sinT + (px - cx) * inf;
var y = cy + ex * sinT + ey * cosT + (py - cy) * inf;
ctx.globalAlpha = st.tw >= 0 ? 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * 2.2 + st.tw)) : 0.9;
ctx.fillStyle = st.tone;
ctx.beginPath();
ctx.arc(x, y, st.s, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function frame(now) {
inf += (aimInf - inf) * 0.08;
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px = Math.max(0, Math.min(W, e.clientX - r.left));
py = Math.max(0, Math.min(H, e.clientY - r.top));
aimInf = 0.32;
if (reduce) { inf = aimInf; draw(42); }
});
field.addEventListener('pointerleave', function () { aimInf = 0; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16] }[e.key];
if (!d) { if (e.key === 'Escape') aimInf = 0; return; }
e.preventDefault();
aimInf = 0.32;
px = Math.max(0, Math.min(W, px + d[0]));
py = Math.max(0, Math.min(H, py + d[1]));
if (reduce) { inf = aimInf; draw(42); }
});
window.addEventListener('resize', function () { fit(); });
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 나선이 감긴 한 장(t = 42 초)만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(42);
} else {
px = W * 0.62; py = H * 0.4;
raf = requestAnimationFrame(frame);
}// GalaxySwirl — vanilla 판(run/077/demo/06-fx-galaxy-swirl/index.html) 의 React 포트.
// 별 육백 개의 반지름과 시작각만 초기화 때 정하고 매 프레임 위치는 식으로 계산한다:
// a = a0 + t * K / (r^0.8 + 20). 꼬리는 destination-out 낮은 알파 지우기로 남긴다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GalaxySwirl.module.scss";
export type GalaxySwirlProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
type Star = { r: number; a0: number; s: number; tone: string; tw: number };
const COUNT = 600;
const K = 13; // 각속도 계수 — 안쪽은 약 0.65rad/s, 바깥은 약 0.17rad/s
const FLAT = 0.58; // 세로 눌림 — 납작한 회전 은하로 읽히는 값
const TILT = -0.35; // 전체 기울기(라디안)
const COS_T = Math.cos(TILT), SIN_T = Math.sin(TILT);
const FADE = 0.16; // 꼬리 길이 — 지우는 알파가 낮을수록 궤적이 길게 남는다
const STILL_T = 42; // reduce 정지판의 시각(초) — 나선이 감긴 뒤의 모습
const FIELD_LABEL = "은하 실험판";
export default function GalaxySwirl({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: GalaxySwirlProps) {
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 sizeRef = useRef({ w: 0, h: 0, dpr: 1, cx: 0, cy: 0, rmax: 0 }) as Box<{ w: number; h: number; dpr: number; cx: number; cy: number; rmax: number }>;
const starsRef = useRef([]) as Box<Star[]>;
const aimRef = useRef({ px: 0, py: 0, inf: 0, aimInf: 0 }) as Box<{ px: number; py: number; inf: number; aimInf: 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 s = sizeRef.current;
s.w = r.width; s.h = r.height; s.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctxRef.current!.setTransform(dpr, 0, 0, dpr, 0, 0);
s.cx = r.width * 0.5;
s.cy = r.height * 0.52;
s.rmax = Math.min(r.width * 0.47, r.height * 0.82);
if (reduced()) draw(STILL_T);
}
function draw(t: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
const stars = starsRef.current;
if (!ctx || !root) return;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const s = sizeRef.current;
const a = aimRef.current;
// 꼬리 — 완전히 지우지 않고 낮은 알파로 바른다
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, " + FADE + ")";
ctx.fillRect(0, 0, s.w, s.h);
// 별과 허브 빛은 겹치면 밝아지는 lighter 로 얹는다
ctx.globalCompositeOperation = "lighter";
const hub = ctx.createRadialGradient(s.cx, s.cy, 0, s.cx, s.cy, s.rmax * 0.34);
hub.addColorStop(0, cssv("--hub-0"));
hub.addColorStop(1, cssv("--hub-1"));
ctx.globalAlpha = 0.08;
ctx.fillStyle = hub;
ctx.beginPath();
ctx.arc(s.cx, s.cy, s.rmax * 0.34, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
for (let i = 0; i < stars.length; i++) {
const st = stars[i];
const R = st.r * s.rmax + 8; // 중심으로 모두 몰리지 않게 바닥 반지름 8
const ang = st.a0 + t * K / (Math.pow(R, 0.8) + 20);
const ex = Math.cos(ang) * R;
const ey = Math.sin(ang) * R * FLAT;
// 계산식의 포인터 항 — (px, py) 쪽으로 좌표 전체가 inf 만큼 기운다(밀도 중심이 잠깐 그 자리로)
const x = s.cx + ex * COS_T - ey * SIN_T + (a.px - s.cx) * a.inf;
const y = s.cy + ex * SIN_T + ey * COS_T + (a.py - s.cy) * a.inf;
ctx.globalAlpha = st.tw >= 0 ? 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * 2.2 + st.tw)) : 0.9;
ctx.fillStyle = st.tone;
ctx.beginPath();
ctx.arc(x, y, st.s, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
const a = aimRef.current;
a.inf += (a.aimInf - a.inf) * 0.08;
draw(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
const s = sizeRef.current;
const a = aimRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
a.px = Math.max(0, Math.min(s.w, e.clientX - r.left));
a.py = Math.max(0, Math.min(s.h, e.clientY - r.top));
a.aimInf = 0.32;
if (reduced()) { a.inf = a.aimInf; draw(STILL_T); }
}
function onLeave(): void {
aimRef.current.aimInf = 0;
}
function onKey(e: KeyEvt): void {
const s = sizeRef.current;
const a = aimRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16],
};
const d = map[e.key];
if (!d) { if (e.key === "Escape") a.aimInf = 0; return; }
e.preventDefault();
a.aimInf = 0.32;
a.px = Math.max(0, Math.min(s.w, a.px + d[0]));
a.py = Math.max(0, Math.min(s.h, a.py + d[1]));
if (reduced()) { a.inf = a.aimInf; draw(STILL_T); }
}
function onResize(): void {
fit();
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
// 별 — 반지름·시작각·크기·빛깔만 받는다. 속도·수명 배열은 없다(식이 전부 계산한다)
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const tones = ["--tone-hub", "--tone-warm", "--tone-mid", "--tone-cool", "--tone-edge"].map(cssv);
const stars: Star[] = [];
for (let i = 0; i < COUNT; i++) {
const rr = Math.pow(Math.random(), 1.25); // 지수 1 이상이 중심으로 모는 쪽이다
stars.push({
r: rr,
a0: Math.random() * Math.PI * 2,
s: 0.7 + Math.random() * 1.5 + (Math.random() < 0.03 ? 0.9 : 0),
tone: tones[Math.min(tones.length - 1, Math.round(rr * (tones.length - 1)))],
tw: i % 9 === 0 ? Math.random() * Math.PI * 2 : -1, // 아홉 개 중 하나는 반짝인다
});
}
starsRef.current = stars;
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 나선이 감긴 한 장만 그린다
root.classList.add("is-still");
draw(STILL_T);
} else {
const a = aimRef.current;
a.px = sizeRef.current.w * 0.62;
a.py = sizeRef.current.h * 0.4;
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="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M245.11 60.68c-7.65-13.19-27.84-16.16-58.5-8.66A95.93 95.93 0 0 0 32 128a98 98 0 0 0 .78 12.31C5.09 169 5.49 186 10.9 195.32C16 204.16 26.64 208 40.64 208a124 124 0 0 0 28.79-4A95.93 95.93 0 0 0 224 128a97 97 0 0 0-.77-12.25c12.5-13 20.82-25.35 23.65-35.92c1.95-7.32 1.36-13.76-1.77-19.15M128 48a80.11 80.11 0 0 1 78 62.2c-17.06 16.06-40.15 32.53-62.07 45.13c-27.55 15.81-51.45 25.67-70.51 31.07A79.94 79.94 0 0 1 128 48M24.74 187.29c-1.46-2.51-.65-7.24 2.22-13a79 79 0 0 1 10.29-15.05a96 96 0 0 0 18 31.32c-17.25 2.9-28.01 1.05-30.51-3.27M128 208a79.45 79.45 0 0 1-38.56-9.94a370 370 0 0 0 62.43-28.86c21.58-12.39 40.68-25.82 56.07-39.08A80.07 80.07 0 0 1 128 208M231.42 75.69c-1.7 6.31-6.19 13.53-12.63 21.13a95.7 95.7 0 0 0-18-31.35c14.21-2.35 27.37-2.17 30.5 3.24c.9 1.57.95 3.92.13 6.98"/></g></svg>
<span>포인터를 올리면 그 자리가 잠깐 밀도 중심이 됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>600</span><span className={styles.badgeUnit}>별</span></b>
</div>
</div>
</main>
);
}06 · Galaxy swirl — Measured area 30.838% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Positions are computed, not stored
function draw(t) {
// tails — never fully erased, just coated with low alpha
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, ' + FADE + ')';
ctx.fillRect(0, 0, W, H);
// stars and the hub glow go on with lighter, which brightens on overlap
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < COUNT; i++) {
var st = stars[i];
var R = st.r * rmax + 8; // floor radius 8 keeps them from all piling into the center
var a = st.a0 + t * K / (Math.pow(R, 0.8) + 20);
var ex = Math.cos(a) * R;
var ey = Math.sin(a) * R * FLAT;
// pointer term — the whole field leans toward (px, py) by inf (density center borrows that spot)
var x = cx + ex * cosT - ey * sinT + (px - cx) * inf;
var y = cy + ex * sinT + ey * cosT + (py - cy) * inf;
Angular speed falls off toward the outside, so the inside spins fast and the rim slowly, and differential rotation stacks into spiral arms on its own. No array stores velocity or lifetime, so the same clock time always draws the same picture, while the low-alpha destination-out eraser leaves the trails behind.
07Fluid ripple surface
Two height grids take turns solving a wave equation, and the height difference between neighboring cells fetches pixels from the picture underneath — a banner water surface that answers your touch.

<main class="stage">
<div class="fs" id="fs">
<p class="fs__hint">
<svg class="fs__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>Press or glide and the ripple spreads from that spot and bounces back off the walls</span>
</p>
<div class="fs__field" id="field" role="application" aria-label="Ripple test board" tabindex="0">
<canvas class="fs__cv" id="cv"></canvas>
<b class="fs__badge" aria-hidden="true"><span class="fs__badgeNum" id="gridVal">96×47</span><span class="fs__badgeUnit">sim grid</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유체 물결 표면 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다(01·05 항목과 같은 방식).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.fs {
--paper: #{$subject-cream};
--ink: #{$subject-ink};
--line: #{$color};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fs__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;
}
.fs__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.fs__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: crosshair; touch-action: none;
}
.fs__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fs__cv { display: block; width: 100%; height: 100%; }
// 시뮬 격자 크기를 화면에도 적어 둔다 — JS 의 COLS·ROWS 와 같은 값으로 fit() 이 갱신한다
.fs__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;
}
.fs__badgeNum { font-size: 13px; color: $stage-yellow; }
.fs__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.fs { gap: $sp-1; }
.fs__field { height: 124px; }
.fs__badge { font-size: 10px; padding: 0 $sp-1; }
.fs__badgeNum { font-size: 11px; }
}
// reduce — 루프가 아예 안 돈다. 물결이 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.fs.is-still .fs__field { cursor: default !important; }
.fs.is-still .fs__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);
}
.fs {
--paper: #fff7e6;
--ink: #17141a;
--line: #2f6df6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fs__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;
}
.fs__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.fs__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: crosshair;
touch-action: none;
}
.fs__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fs__cv {
display: block;
width: 100%;
height: 100%;
}
.fs__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;
}
.fs__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.fs__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.fs {
gap: 4px;
}
.fs__field {
height: 124px;
}
.fs__badge {
font-size: 10px;
padding: 0 4px;
}
.fs__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.fs.is-still .fs__field {
cursor: default !important;
}
.fs.is-still .fs__badge {
opacity: 0.92 !important;
}
}// 유체 물결 표면 — Two height grids(curr·prev)을 번갈아 쓰며 파동 방정식을 푼다.
// next = (이웃 네 칸의 합) × 0.5 − prev, 다시 감쇠를 곱한다. 포인터를 누르거나 지나가면
// 그 자리 높이를 올려 동그란 물결이 퍼지고, 가장자리 칸을 0 으로 고정해 둔 벽에서 반사된다.
// 화면은 밑그림(격자+글자)의 ImageData 를 떠 두고, 이웃 칸의 높이差를 굴절 오프셋으로 바꿔
// 원본 픽셀을 당겨 온다 — 그림을 다시 그리는 게 아니라 어디서 읽을지만 바꾼다(01 렌즈와 같은 뿌리).
var root = document.getElementById('fs');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var gridVal = document.getElementById('gridVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var PAPER = v('--paper'), INK = v('--ink'), LINE = v('--line');
var LINES = ['Two height grids', 'solving the wave equation', 'the ripple spreads'];
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var COLS = 96; // sim grid 가로 칸 수 — 세로 칸 수는 판 폭에 맞춰 정사각 칸이 되게 잡는다.
// 칸이 직사각이면 물결이 타원으로 퍼져 물로 안 읽힌다
var DAMP = 0.986; // 감쇠 — 1 에 가까울수록 물결이 오래 살아 벽 반사가 몇 번씩 보인다
var REFR = 4; // 굴절 계수 — 높이 기울기 1 이 렌더 픽셀을 몇 칸 당기는가
var SPEC = 26; // 경면광 계수 — 기울기를 밝기 요철로도 보게 한다. 색이 고른 데서는 이 요철이 굴절의 증거다
var SUBSTEPS = 2; // 프레임마다 푸는 걸음 수 — 2 걸음이면 물결이 약 0.8초 만에 판을 건넌다
var DROP_EVERY = 0.9; // 자동 물방울 간격(초)
var TOUR = 8.1; // 낙하 위치가 리사주로 한 바퀴 도는 초(물방울 9개)
var STEP = 3; // 시뮬 한 칸이 렌더 버퍼에서 차지하는 픽셀 — 렌더 해상도를 낮춰 픽셀 루프를 가볍게 한다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var curr = null, prev = null, swap = null; // Float32Array Two height grids(+교환용)
var bgd = null; // 밑그림을 떠 둔 픽셀
var buf = null, bctx = null, out = null; // 굴절 결과를 담는 렌더 버퍼(시뮬 칸의 3배 해상도)
var RW = 0, RH = 0;
var auto = true, tx = 0, ty = 0, lastDrop = -1, nowSec = 0, raf = 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);
ROWS = Math.max(24, Math.round(COLS * H / W));
var n = COLS * ROWS;
curr = new Float32Array(n);
prev = new Float32Array(n);
tx = W * 0.5; ty = H * 0.5;
RW = COLS * STEP; RH = ROWS * STEP;
// 밑그림 — 크림 바탕 + 격자 + 잉크 글자. 가늘고 곧은 것이 굴절을 가장 잘 드러낸다
var bgc = document.createElement('canvas');
bgc.width = RW; bgc.height = RH;
var g = bgc.getContext('2d');
g.fillStyle = PAPER;
g.fillRect(0, 0, RW, RH);
g.strokeStyle = LINE;
g.globalAlpha = 0.5;
g.lineWidth = 1;
g.beginPath();
for (var x = STEP * 2; x < RW; x += STEP * 2) { g.moveTo(x + 0.5, 0); g.lineTo(x + 0.5, RH); }
for (var y = STEP * 2; y < RH; y += STEP * 2) { g.moveTo(0, y + 0.5); g.lineTo(RW, y + 0.5); }
g.stroke();
g.globalAlpha = 1;
// 글자 크기 역산 — 100px 로 재 본 뒤 가장 긴 줄이 판 폭의 88% 를 차지하게 한다(01 과 같은 방식)
g.font = '700 100px ' + FONT;
var widest = 1;
for (var k = 0; k < LINES.length; k++) widest = Math.max(widest, g.measureText(LINES[k]).width);
var size = Math.max(11, Math.round(100 * (RW * 0.88) / widest));
var rowH = Math.round(size * 1.18);
var rows = Math.max(3, Math.round(RH / rowH));
var top = (RH - (rows - 1) * rowH) * 0.5;
g.fillStyle = INK;
g.textAlign = 'center';
g.textBaseline = 'middle';
g.font = '700 ' + size + 'px ' + FONT;
for (var i = 0; i < rows; i++) g.fillText(LINES[i % LINES.length], RW * 0.5, top + i * rowH);
bgd = g.getImageData(0, 0, RW, RH).data;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
gridVal.textContent = COLS + '×' + ROWS;
}
// 파동식 — 이웃 네 칸 평균의 두 배에서 직전 값을 빼면 다음 높이다. 두 장을 교환하며 푼다
function sim(steps) {
for (var s = 0; s < steps; s++) {
for (var y = 1; y < ROWS - 1; y++) {
var row = y * COLS;
for (var x = 1; x < COLS - 1; x++) {
var i = row + x;
prev[i] = ((curr[i - 1] + curr[i + 1] + curr[i - COLS] + curr[i + COLS]) * 0.5 - prev[i]) * DAMP;
}
}
swap = prev; prev = curr; curr = swap;
}
}
// 물방울 낙하 — 중심이 강하고 가장자리가 0 인 코사인 낙차로 높이를 올린다
function disturb(px, py, rad, str) {
var gx = px / W * COLS, gy = py / H * ROWS;
var x0 = Math.max(1, Math.floor(gx - rad)), x1 = Math.min(COLS - 2, Math.ceil(gx + rad));
var y0 = Math.max(1, Math.floor(gy - rad)), y1 = Math.min(ROWS - 2, Math.ceil(gy + rad));
for (var y = y0; y <= y1; y++) {
for (var x = x0; x <= x1; x++) {
var dx = x - gx, dy = y - gy;
var d = Math.sqrt(dx * dx + dy * dy);
if (d > rad) continue;
curr[y * COLS + x] += str * Math.cos(d / rad * Math.PI / 2);
}
}
}
// 굴절 렌더 — 각 렌더 픽셀에서 시뮬 칸 좌표의 높이를 네 방향으로 샘플해 기울기를 만들고,
// 그 기울기만큼 밑그림의 읽을 자리를 당겨 온다. 밝기 요철(경면광)을 함께 얹는다
function render() {
var o = out.data, b = bgd;
var kx = COLS / RW, ky = ROWS / RH;
for (var y = 0; y < RH; y++) {
var cy = Math.round(y * ky);
var rc = cy * COLS;
var ru = Math.max(0, cy - 1) * COLS;
var rd = Math.min(ROWS - 1, cy + 1) * COLS;
for (var x = 0; x < RW; x++) {
var cx = Math.round(x * kx);
var cl = Math.max(0, cx - 1), cr = Math.min(COLS - 1, cx + 1);
var hL = curr[rc + cl], hR = curr[rc + cr];
var hU = curr[ru + cx], hD = curr[rd + cx];
var sxp = x + (hL - hR) * REFR;
var syp = y + (hU - hD) * REFR;
if (sxp < 0) sxp = 0; else if (sxp > RW - 1.001) sxp = RW - 1.001;
if (syp < 0) syp = 0; else if (syp > RH - 1.001) syp = RH - 1.001;
var j = ((syp | 0) * RW + (sxp | 0)) * 4;
var i = (y * RW + x) * 4;
var spec = (hL - hR + hU - hD) * SPEC;
o[i] = b[j] + spec;
o[i + 1] = b[j + 1] + spec;
o[i + 2] = b[j + 2] + spec;
o[i + 3] = 255;
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var k = Math.floor(nowSec / DROP_EVERY);
if (k !== lastDrop) {
lastDrop = k;
var th = (k * DROP_EVERY / TOUR % 1) * Math.PI * 2; // 낙하 위치가 리사주로 옮겨 다닌다
tx = W * (0.5 + Math.sin(th) * 0.3);
ty = H * (0.5 + Math.sin(th * 2 + 1.2) * 0.24);
disturb(tx, ty, 3, 4.5);
}
}
sim(SUBSTEPS);
render();
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 파동을 몰아 전진해, 퍼져 가는 물결이 살아 있는 한 장을 담는다
function still() {
disturb(W * 0.52, H * 0.4, 3.4, 6);
sim(30);
disturb(W * 0.28, H * 0.66, 2.8, 4.5);
sim(20);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
auto = false;
disturb(p[0], p[1], 2.2, 2.2); // 지나가는 자국 — 누름보다 얕게
});
field.addEventListener('pointerdown', function (e) {
var p = localXY(e);
auto = false;
tx = p[0]; ty = p[1];
disturb(p[0], p[1], 3.2, 5.5); // 누름 — 동그란 파문의 씨
field.setPointerCapture(e.pointerId);
});
field.addEventListener('pointerup', function (e) { field.releasePointerCapture(e.pointerId); });
field.addEventListener('pointerleave', function () {
auto = true;
lastDrop = Math.floor(nowSec / DROP_EVERY); // 떠나면 다음 간격 뒤 자율 낙하로 돌아간다
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-14, 0], ArrowRight: [14, 0], ArrowUp: [0, -14], ArrowDown: [0, 14] }[e.key];
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
auto = false;
disturb(tx, ty, 3.4, 6);
if (reduce) { sim(26); render(); }
return;
}
if (!d) { if (e.key === 'Escape') auto = true; return; }
e.preventDefault();
auto = false;
tx = Math.max(0, Math.min(W, tx + d[0]));
ty = Math.max(0, Math.min(H, ty + d[1]));
if (reduce) { disturb(tx, ty, 1.8, 1.8); sim(20); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 물결이 살아 있는 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// FluidSurface — vanilla 판(run/077/demo/07-fx-fluid-surface/index.html) 의 React 포트.
// 높이 격자 두 장(curr·prev)을 번갈아 쓰며 파동 방정식을 풀고, 이웃 칸의 높이差를
// 굴절 오프셋으로 바꿔 밑그림의 픽셀을 끌어온다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FluidSurface.module.scss";
export type FluidSurfaceProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const LINES = ["높이 격자 두 장", "파동식을 풀면", "물결이 퍼집니다"];
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "물결 실험판";
const COLS = 96; // 시뮬 격자 가로 칸 수 — 세로 칸 수는 판 폭에 맞춰 정사각 칸이 되게 잡는다
const DAMP = 0.986; // 감쇠 — 1 에 가까울수록 물결이 오래 살아 벽 반사가 몇 번씩 보인다
const REFR = 4; // 굴절 계수 — 높이 기울기 1 이 렌더 픽셀을 몇 칸 당기는가
const SPEC = 26; // 경면광 계수 — 기울기를 밝기 요철로도 보게 한다
const SUBSTEPS = 2; // 프레임마다 푸는 걸음 수
const DROP_EVERY = 0.9; // 자동 물방울 간격(초)
const TOUR = 8.1; // 낙하 위치가 리사주로 한 바퀴 도는 초
const STEP = 3; // 시뮬 한 칸이 렌더 버퍼에서 차지하는 픽셀
type Sim = { curr: Float32Array; prev: Float32Array; rows: number };
type Bufs = { bgd: Uint8ClampedArray | null; buf: HTMLCanvasElement | null; bctx: CanvasRenderingContext2D | null; out: ImageData | null; rw: number; rh: number };
type Size = { w: number; h: number; dpr: number };
type Aim = { tx: number; ty: number; auto: boolean; lastDrop: number; nowSec: number };
export default function FluidSurface({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $subject-blue
}: FluidSurfaceProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const gridValRef = useRef<HTMLElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const simRef = useRef({ curr: new Float32Array(0), prev: new Float32Array(0), rows: 0 }) as Box<Sim>;
const bufRef = useRef({ bgd: null, buf: null, bctx: null, out: null, rw: 0, rh: 0 }) as Box<Bufs>;
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<Size>;
const aimRef = useRef({ tx: 0, ty: 0, auto: true, lastDrop: -1, nowSec: 0 }) as Box<Aim>;
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 s = sizeRef.current;
s.w = r.width; s.h = r.height; s.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const rows = Math.max(24, Math.round(COLS * r.height / r.width));
const n = COLS * rows;
simRef.current.curr = new Float32Array(n);
simRef.current.prev = new Float32Array(n);
simRef.current.rows = rows;
const a = aimRef.current;
a.tx = r.width * 0.5; a.ty = r.height * 0.5;
const rw = COLS * STEP, rh = rows * STEP;
// 밑그림 — 크림 바탕 + 격자 + 잉크 글자. 가늘고 곧은 것이 굴절을 가장 잘 드러낸다
const bgc = document.createElement("canvas");
bgc.width = rw; bgc.height = rh;
const g = bgc.getContext("2d");
if (!g) return;
const cssv = (nm: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(nm).trim();
g.fillStyle = cssv("--paper");
g.fillRect(0, 0, rw, rh);
g.strokeStyle = cssv("--line");
g.globalAlpha = 0.5;
g.lineWidth = 1;
g.beginPath();
for (let x = STEP * 2; x < rw; x += STEP * 2) { g.moveTo(x + 0.5, 0); g.lineTo(x + 0.5, rh); }
for (let y = STEP * 2; y < rh; y += STEP * 2) { g.moveTo(0, y + 0.5); g.lineTo(rw, y + 0.5); }
g.stroke();
g.globalAlpha = 1;
// 글자 크기 역산 — 100px 로 재 본 뒤 가장 긴 줄이 판 폭의 88% 를 차지하게 한다
g.font = "700 100px " + FONT;
let widest = 1;
for (const t of LINES) widest = Math.max(widest, g.measureText(t).width);
const size = Math.max(11, Math.round(100 * (rw * 0.88) / widest));
const rowH = Math.round(size * 1.18);
const rowsN = Math.max(3, Math.round(rh / rowH));
const top = (rh - (rowsN - 1) * rowH) * 0.5;
g.fillStyle = cssv("--ink");
g.textAlign = "center";
g.textBaseline = "middle";
g.font = "700 " + size + "px " + FONT;
for (let i = 0; i < rowsN; i++) g.fillText(LINES[i % LINES.length], rw * 0.5, top + i * rowH);
const buf = document.createElement("canvas");
buf.width = rw; buf.height = rh;
const bctx = buf.getContext("2d");
if (!bctx) return;
bufRef.current = { bgd: g.getImageData(0, 0, rw, rh).data, buf, bctx, out: bctx.createImageData(rw, rh), rw, rh };
if (gridValRef.current) gridValRef.current.textContent = COLS + "×" + rows;
}
// 파동식 — 이웃 네 칸 평균의 두 배에서 직전 값을 빼면 다음 높이다. 두 장을 교환하며 푼다
function sim(steps: number): void {
const sm = simRef.current;
if (!sm.curr.length) return;
for (let s = 0; s < steps; s++) {
for (let y = 1; y < sm.rows - 1; y++) {
const row = y * COLS;
for (let x = 1; x < COLS - 1; x++) {
const i = row + x;
sm.prev[i] = ((sm.curr[i - 1] + sm.curr[i + 1] + sm.curr[i - COLS] + sm.curr[i + COLS]) * 0.5 - sm.prev[i]) * DAMP;
}
}
const swap = sm.prev; sm.prev = sm.curr; sm.curr = swap;
}
}
// 물방울 낙하 — 중심이 강하고 가장자리가 0 인 코사인 낙차로 높이를 올린다
function disturb(px: number, py: number, rad: number, str: number): void {
const sm = simRef.current;
const s = sizeRef.current;
if (!sm.curr.length || !s.w) return;
const gx = px / s.w * COLS, gy = py / s.h * sm.rows;
const x0 = Math.max(1, Math.floor(gx - rad)), x1 = Math.min(COLS - 2, Math.ceil(gx + rad));
const y0 = Math.max(1, Math.floor(gy - rad)), y1 = Math.min(sm.rows - 2, Math.ceil(gy + rad));
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const dx = x - gx, dy = y - gy;
const d = Math.sqrt(dx * dx + dy * dy);
if (d > rad) continue;
sm.curr[y * COLS + x] += str * Math.cos(d / rad * Math.PI / 2);
}
}
}
// 굴절 렌더 — 높이 기울기만큼 밑그림의 읽을 자리를 당겨 온다. 밝기 요철(경면광)을 함께 얹는다
function render(): void {
const ctx = ctxRef.current;
const sm = simRef.current;
const bf = bufRef.current;
const s = sizeRef.current;
if (!ctx || !bf.bgd || !bf.buf || !bf.bctx || !bf.out || !bf.rw) return;
const o = bf.out.data, b = bf.bgd;
const rw = bf.rw, rh = bf.rh, rows = sm.rows;
const kx = COLS / rw, ky = rows / rh;
for (let y = 0; y < rh; y++) {
const cy = Math.round(y * ky);
const rc = cy * COLS;
const ru = Math.max(0, cy - 1) * COLS;
const rd = Math.min(rows - 1, cy + 1) * COLS;
for (let x = 0; x < rw; x++) {
const cx = Math.round(x * kx);
const cl = Math.max(0, cx - 1), cr = Math.min(COLS - 1, cx + 1);
const hL = sm.curr[rc + cl], hR = sm.curr[rc + cr];
const hU = sm.curr[ru + cx], hD = sm.curr[rd + cx];
let sxp = x + (hL - hR) * REFR;
let syp = y + (hU - hD) * REFR;
if (sxp < 0) sxp = 0; else if (sxp > rw - 1.001) sxp = rw - 1.001;
if (syp < 0) syp = 0; else if (syp > rh - 1.001) syp = rh - 1.001;
const j = ((syp | 0) * rw + (sxp | 0)) * 4;
const i = (y * rw + x) * 4;
const spec = (hL - hR + hU - hD) * SPEC;
o[i] = b[j] + spec;
o[i + 1] = b[j + 1] + spec;
o[i + 2] = b[j + 2] + spec;
o[i + 3] = 255;
}
}
bf.bctx.putImageData(bf.out, 0, 0);
ctx.drawImage(bf.buf, 0, 0, s.w, s.h);
}
function frame(now: number): void {
const s = sizeRef.current;
const a = aimRef.current;
a.nowSec = now / 1000;
if (a.auto) {
const k = Math.floor(a.nowSec / DROP_EVERY);
if (k !== a.lastDrop) {
a.lastDrop = k;
const th = (k * DROP_EVERY / TOUR % 1) * Math.PI * 2;
a.tx = s.w * (0.5 + Math.sin(th) * 0.3);
a.ty = s.h * (0.5 + Math.sin(th * 2 + 1.2) * 0.24);
disturb(a.tx, a.ty, 3, 4.5);
}
}
sim(SUBSTEPS);
render();
rafRef.current = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 파동을 몰아 전진해, 퍼져 가는 물결이 살아 있는 한 장을 담는다
function still(): void {
const s = sizeRef.current;
disturb(s.w * 0.52, s.h * 0.4, 3.4, 6);
sim(30);
disturb(s.w * 0.28, s.h * 0.66, 2.8, 4.5);
sim(20);
render();
}
function localXY(e: PtrEvt): number[] {
const field = fieldRef.current;
if (!field) return [0, 0];
const r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function onMove(e: PtrEvt): void {
const p = localXY(e);
aimRef.current.auto = false;
disturb(p[0], p[1], 2.2, 2.2);
}
function onDown(e: PtrEvt): void {
const p = localXY(e);
const a = aimRef.current;
a.auto = false;
a.tx = p[0]; a.ty = p[1];
disturb(p[0], p[1], 3.2, 5.5);
const field = fieldRef.current;
if (field && typeof field.setPointerCapture === "function") {
try { field.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 치명적이지 않다 */ }
}
}
function onUp(e: PtrEvt): void {
const field = fieldRef.current;
if (field && typeof field.releasePointerCapture === "function") {
try { field.releasePointerCapture(e.pointerId); } catch { /* 이미 풀려 있을 수 있다 */ }
}
}
function onLeave(): void {
const a = aimRef.current;
a.auto = true;
a.lastDrop = Math.floor(a.nowSec / DROP_EVERY);
}
function onKey(e: KeyEvt): void {
const s = sizeRef.current;
const a = aimRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-14, 0], ArrowRight: [14, 0], ArrowUp: [0, -14], ArrowDown: [0, 14],
};
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
a.auto = false;
disturb(a.tx, a.ty, 3.4, 6);
if (reduced()) { sim(26); render(); }
return;
}
const dd = map[e.key];
if (!dd) { if (e.key === "Escape") a.auto = true; return; }
e.preventDefault();
a.auto = false;
a.tx = Math.max(0, Math.min(s.w, a.tx + dd[0]));
a.ty = Math.max(0, Math.min(s.h, a.ty + dd[1]));
if (reduced()) { disturb(a.tx, a.ty, 1.8, 1.8); sim(20); render(); }
}
function onResize(): void {
fit();
if (reduced()) still();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
if (reduced()) {
// reduce — 루프를 시작하지 않고 물결이 살아 있는 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
still();
} else {
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
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="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg>
<span>누르거나 지나가면 그 자리에서 물결이 퍼져 벽에서 되돌아옵니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerDown={onDown}
onPointerUp={onUp}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={gridValRef}>96×47</span><span className={styles.badgeUnit}>시뮬 격자</span></b>
</div>
</div>
</main>
);
}07 · Fluid ripple surface — Measured area 57.639% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Twice the neighbor average, minus the previous step
// Wave equation — twice the average of the four neighbors minus the previous
// value gives the next height. Solved by swapping two buffers.
function sim(steps) {
for (var s = 0; s < steps; s++) {
for (var y = 1; y < ROWS - 1; y++) {
var row = y * COLS;
for (var x = 1; x < COLS - 1; x++) {
var i = row + x;
prev[i] = ((curr[i - 1] + curr[i + 1] + curr[i - COLS] + curr[i + COLS]) * 0.5 - prev[i]) * DAMP;
}
}
swap = prev; prev = curr; curr = swap;
}
}
for (var x = 0; x < RW; x++) {
var cx = Math.round(x * kx);
var cl = Math.max(0, cx - 1), cr = Math.min(COLS - 1, cx + 1);
var hL = curr[rc + cl], hR = curr[rc + cr];
var hU = curr[ru + cx], hD = curr[rd + cx];
var sxp = x + (hL - hR) * REFR;
The wave itself is physics, but what makes it visible is refraction — the left-right and up-down height differences become offsets that pull in source pixels. Press or sweep the board and a cosine dip dents the water, spreads into rings and bounces back off the walls (measured: 79,762 alpha pixels changed during a drag).
08Scattered light rays
The silhouette layer is stacked twelve times, each copy scaled up a little around the light source, and the lighter composite turns the gaps into a fan of rays over a forest or window photo.

<main class="stage">
<div class="ls" id="ls">
<p class="ls__hint">
<svg class="ls__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M184 128a56 56 0 1 1-56-56a56 56 0 0 1 56 56" opacity=".2"/><path d="M120 40V16a8 8 0 0 1 16 0v24a8 8 0 0 1-16 0m72 88a64 64 0 1 1-64-64a64.07 64.07 0 0 1 64 64m-16 0a48 48 0 1 0-48 48a48.05 48.05 0 0 0 48-48M58.34 69.66a8 8 0 0 0 11.32-11.32l-16-16a8 8 0 0 0-11.32 11.32Zm0 116.68l-16 16a8 8 0 0 0 11.32 11.32l16-16a8 8 0 0 0-11.32-11.32M192 72a8 8 0 0 0 5.66-2.34l16-16a8 8 0 0 0-11.32-11.32l-16 16A8 8 0 0 0 192 72m5.66 114.34a8 8 0 0 0-11.32 11.32l16 16a8 8 0 0 0 11.32-11.32ZM48 128a8 8 0 0 0-8-8H16a8 8 0 0 0 0 16h24a8 8 0 0 0 8-8m80 80a8 8 0 0 0-8 8v24a8 8 0 0 0 16 0v-24a8 8 0 0 0-8-8m112-88h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16"/></g></svg>
<span>Move the cursor and the light source follows slowly, fanning light into rays</span>
</p>
<div class="ls__field" id="field" role="application" aria-label="Light scatter test board" tabindex="0">
<canvas class="ls__cv" id="cv"></canvas>
<b class="ls__badge" aria-hidden="true"><span class="ls__badgeNum">12</span><span class="ls__badgeUnit">layers stacked</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 빛 산란 광선 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다(01·05 항목과 같은 방식).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.ls {
// 노을 하늘 — 위는 태운 잉크빛, 아래는 호박색. 밑그림의 밝기가 곧 광선의 양식이다
--sky-0: #{mix($stage-ink, $stage-orange, 68%)};
--sky-1: #{mix($stage-orange, $stage-ink, 22%)};
--glow-0: #{rgba($subject-cream, .98)};
--glow-1: #{rgba($subject-cream, 0)};
// 실루엣 — 먼 능선은 공기색이 묻어 옅고, 앞 능선과 나무는 거의 잉크다(어두울수록 덜 더해진다)
--ridge-far: #{mix($stage-ink, $stage-orange, 72%)};
--ridge-near: #{mix($stage-ink, $stage-orange, 93%)};
--tree: #{mix($stage-ink, $stage-orange, 97%)};
// 가늘고 긴 구름 — 하늘의 가림막. 구름 사이 틈으로 새는 빛이 하늘에도 광선을 만든다
--cloud: #{mix($stage-ink, $stage-orange, 66%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__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;
}
.ls__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.ls__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-orange;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .18);
cursor: crosshair; touch-action: none;
}
.ls__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
// 겹 수를 화면에도 적어 둔다 — JS 의 STACK 과 같은 값이다
.ls__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;
}
.ls__badgeNum { font-size: 13px; color: $stage-yellow; }
.ls__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 124px; }
.ls__badge { font-size: 10px; padding: 0 $sp-1; }
.ls__badgeNum { font-size: 11px; }
}
// reduce — 루프가 아예 안 돈다. 광선이 다 자란 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
.ls.is-still .ls__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);
}
.ls {
--sky-0: rgb(38.1333333333%, 14.9960784314%, 10.8235294118%);
--sky-1: rgb(29.0352941176%, 12.7607843137%, 10.6274509804%);
--glow-0: rgba(255, 247, 230, 0.98);
--glow-1: rgba(255, 247, 230, 0);
--ridge-far: rgb(34.4941176471%, 14.1019607843%, 10.7450980392%);
--ridge-near: rgb(15.3882352941%, 9.4078431373%, 10.3333333333%);
--tree: rgb(11.7490196078%, 8.5137254902%, 10.2549019608%);
--cloud: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__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;
}
.ls__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.ls__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ff4d1f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.18);
cursor: crosshair;
touch-action: none;
}
.ls__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__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;
}
.ls__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ls__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 124px;
}
.ls__badge {
font-size: 10px;
padding: 0 4px;
}
.ls__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
.ls.is-still .ls__badge {
opacity: 0.92 !important;
}
}// 빛 산란 광선 — 밑그림(그라디언트 하늘 + 실루엣 능선·나무)을 광원 기준으로 매번 1.03배씩
// 키우며 lighter 합성으로 열두 번 겹쳐 더한다. 겹칠수록 밝아지는 성질 때문에 나무 사이 틈으로
// 새어 나온 밝은 하늘이 부챗살 광선으로 자란다 — 광선을 그린 게 아니라 쌓아 자라게 한 것이다.
// 겹마다 알파를 광원에서 멀어지는 선형 그라디언트로 깎아, 광원 가까운 밝기만 누적에 기여하게 한다.
var root = document.getElementById('ls');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(n) { return css.getPropertyValue(n).trim(); }
var SKY0 = v('--sky-0'), SKY1 = v('--sky-1');
var GLOW0 = v('--glow-0'), GLOW1 = v('--glow-1');
var RIDGE_FAR = v('--ridge-far'), RIDGE_NEAR = v('--ridge-near'), TREE = v('--tree');
var CLOUD = v('--cloud');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var STACK = 12; // 겹치는 횟수 — 늘리면 광선이 더 길게 자란다
var GROW = 1.03; // 한 겹이 광원 기준으로 자라는 배율
var LERP = 0.05; // 광원이 목표를 따라오는 비율(프레임당) — 느리게 따라와 빛이 흐르는 느낌
var TOUR = 7; // 자율 광원이 한 바퀴 도는 초
var raf = 0, W = 0, H = 0, dpr = 1;
var lx = 0, ly = 0, tx = 0, ty = 0, manual = false;
var scene = document.createElement('canvas');
var sctx = scene.getContext('2d');
var veil = document.createElement('canvas');
var vctx = veil.getContext('2d');
// 시드 고정 난수 — 새로고침해도 같은 능선·나무가 자란다
function rng(seed) {
return function () { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
}
// 능선 점렬(정규화 0..1) — 조절점 사이를 코사인으로 부드럽게 잇는 값 노이즈. 양 끝이 이어진다
function ridgePts(seed, bumps) {
var rr = rng(seed);
var cp = [];
for (var i = 0; i < bumps; i++) cp.push(rr());
cp.push(cp[0]);
var pts = [];
var n = bumps * 12;
for (var k = 0; k <= n; k++) {
var t = k / n * bumps;
var i0 = Math.floor(t), f = t - i0;
var s = (1 - Math.cos(f * Math.PI)) * 0.5;
pts.push(cp[i0] + (cp[i0 + 1] - cp[i0]) * s);
}
return pts;
}
var farPts = ridgePts(11, 3);
var nearPts = ridgePts(77, 5);
var trees = [];
(function () {
var rr = rng(5);
for (var i = 0; i < 16; i++) trees.push({ x: rr(), h: 0.06 + rr() * 0.05 });
})();
var clouds = [];
(function () {
var rr = rng(23);
for (var i = 0; i < 3; i++) clouds.push({ x: 0.08 + rr() * 0.78, y: 0.09 + rr() * 0.17, w: 0.2 + rr() * 0.16, h: 0.026 + rr() * 0.02 });
})();
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = scene.width = veil.width = Math.round(W * dpr);
cv.height = scene.height = veil.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
vctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!lx) { lx = W * 0.34; ly = H * 0.26; tx = lx; ty = ly; }
}
// 능선 위 한 점의 y — 정규화 점렬을 CSS px 로 샘플한다
function ridgeY(pts, base, amp, x) {
var t = x / W * (pts.length - 1);
var i0 = Math.floor(t);
if (i0 >= pts.length - 1) return H * (base - amp * pts[pts.length - 1]);
var f = t - i0;
return H * (base - amp * (pts[i0] + (pts[i0 + 1] - pts[i0]) * f));
}
function fillRidge(c, pts, base, amp, color) {
c.fillStyle = color;
c.beginPath();
c.moveTo(0, H + 2);
for (var x = 0; x <= W; x += 4) c.lineTo(x, ridgeY(pts, base, amp, x));
c.lineTo(W, H + 2);
c.closePath();
c.fill();
}
// 가늘고 긴 구름 세 조각 — 하늘의 가림막. 광원과 구름 사이 틈이 하늘의 광선 뼈대다
function drawClouds(c) {
c.fillStyle = CLOUD;
for (var i = 0; i < clouds.length; i++) {
var cl = clouds[i];
var cx = cl.x * W, cy = cl.y * H, cw = cl.w * W, ch = cl.h * H;
c.beginPath();
c.moveTo(cx + cw * 0.5, cy);
c.ellipse(cx, cy, cw * 0.5, ch, 0, 0, Math.PI * 2);
c.moveTo(cx - cw * 0.32 + cw * 0.22, cy + ch * 0.35);
c.ellipse(cx - cw * 0.32, cy + ch * 0.35, cw * 0.22, ch * 0.62, 0, 0, Math.PI * 2);
c.moveTo(cx + cw * 0.3 + cw * 0.2, cy - ch * 0.3);
c.ellipse(cx + cw * 0.3, cy - ch * 0.3, cw * 0.2, ch * 0.6, 0, 0, Math.PI * 2);
c.fill();
}
}
// 침엽수 실루엣 — 앞 능선 등선선 위에 삼각 두 개를 포개 얹는다. 틈이 벌어질수록 광선이 굵어진다
function drawTrees(c) {
c.fillStyle = TREE;
for (var i = 0; i < trees.length; i++) {
var tr = trees[i];
var x = tr.x * W;
var y = ridgeY(nearPts, 0.80, 0.15, x) + 1;
var h = tr.h * H;
var w = h * 0.44;
c.beginPath();
c.moveTo(x, y - h);
c.lineTo(x + w * 0.5, y - h * 0.34);
c.lineTo(x + w * 0.28, y - h * 0.34);
c.lineTo(x + w * 0.85, y);
c.lineTo(x - w * 0.85, y);
c.lineTo(x - w * 0.28, y - h * 0.34);
c.lineTo(x - w * 0.5, y - h * 0.34);
c.closePath();
c.fill();
}
}
// 밑그림 — 세로 그라디언트 하늘 + 광원 주위 광운 + 실루엣 두 겹
function drawScene() {
var sky = sctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, SKY0);
sky.addColorStop(1, SKY1);
sctx.fillStyle = sky;
sctx.fillRect(0, 0, W, H);
var glow = sctx.createRadialGradient(lx, ly, 2, lx, ly, Math.max(W, H) * 0.26);
glow.addColorStop(0, GLOW0);
glow.addColorStop(0.22, GLOW0);
glow.addColorStop(1, GLOW1);
sctx.fillStyle = glow;
sctx.fillRect(0, 0, W, H);
drawClouds(sctx);
fillRidge(sctx, farPts, 0.58, 0.09, RIDGE_FAR);
fillRidge(sctx, nearPts, 0.80, 0.15, RIDGE_NEAR);
drawTrees(sctx);
}
// 베일 — 밑그림을 복사한 뒤 광원에서 장면 반대편으로 빠지는 선형 그라디언트로 알파를 깎는다
function drawVeil() {
vctx.globalCompositeOperation = 'source-over';
vctx.clearRect(0, 0, W, H);
vctx.drawImage(scene, 0, 0, W, H);
var ux = W * 0.5 - lx, uy = H * 0.5 - ly;
var ul = Math.sqrt(ux * ux + uy * uy) || 1;
var far = Math.sqrt(W * W + H * H) * 1.05;
var fx = lx + ux / ul * far, fy = ly + uy / ul * far;
var g = vctx.createLinearGradient(lx, ly, fx, fy);
g.addColorStop(0, 'rgba(0, 0, 0, .95)');
g.addColorStop(0.5, 'rgba(0, 0, 0, .5)');
g.addColorStop(1, 'rgba(0, 0, 0, 0)');
vctx.globalCompositeOperation = 'destination-in';
vctx.fillStyle = g;
vctx.fillRect(0, 0, W, H);
}
function paint() {
drawScene();
drawVeil();
// ① 밑그림을 그대로 깐다
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.drawImage(scene, 0, 0, W, H);
// ② 광원 기준 1.03배씩 커지는 겹 12장 — 겹칠수록 밝아지는 lighter 합성으로 광선이 자란다
var s = 1;
for (var i = 1; i <= STACK; i++) {
s *= GROW;
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = 0.55 * (1 - i / (STACK + 1));
ctx.drawImage(veil, lx * (1 - s), ly * (1 - s), W * s, H * s);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function frame(now) {
var sec = now / 1000;
if (!manual) {
var th = sec / TOUR * Math.PI * 2;
tx = W * (0.34 + Math.sin(th) * 0.18);
ty = H * (0.25 + Math.sin(th * 2 + 0.8) * 0.12);
}
lx += (tx - lx) * LERP;
ly += (ty - ly) * LERP;
paint();
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
tx = Math.max(W * 0.08, Math.min(W * 0.92, e.clientX - r.left));
ty = Math.max(H * 0.06, Math.min(H * 0.6, e.clientY - r.top));
manual = true;
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-12, 0], ArrowRight: [12, 0], ArrowUp: [0, -12], ArrowDown: [0, 12] }[e.key];
if (!d) { if (e.key === 'Escape') manual = false; return; }
e.preventDefault();
manual = true;
tx = Math.max(W * 0.08, Math.min(W * 0.92, tx + d[0]));
ty = Math.max(H * 0.06, Math.min(H * 0.6, ty + d[1]));
if (reduce) { lx = tx; ly = ty; paint(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) paint(); });
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 광선이 다 자란 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint();
} else {
raf = requestAnimationFrame(frame);
}// LightScatter — vanilla 판(run/077/demo/08-fx-light-scatter/index.html) 의 React 포트.
// 밑그림(그라디언트 하늘 + 실루엣 능선·나무)을 광원 기준으로 매번 1.03배씩 키우며
// lighter 합성으로 열두 번 겹쳐 더해 부챗살 광선을 자라게 한다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LightScatter.module.scss";
export type LightScatterProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
const STACK = 12; // 겹치는 횟수 — 늘리면 광선이 더 길게 자란다
const GROW = 1.03; // 한 겹이 광원 기준으로 자라는 배율
const LERP = 0.05; // 광원이 목표를 따라오는 비율(프레임당)
const TOUR = 7; // 자율 광원이 한 바퀴 도는 초
const FIELD_LABEL = "빛 산란 실험판";
type Size = { w: number; h: number; dpr: number };
type Light = { lx: number; ly: number; tx: number; ty: number; manual: boolean };
// 시드 고정 난수와 능선 점렬(정규화 0..1) — 조절점 사이 코사인 보간, 양 끝이 이어진다
function rng(seed: number): () => number {
return function (): number {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
}
function ridgePts(seed: number, bumps: number): number[] {
const rr = rng(seed);
const cp: number[] = [];
for (let i = 0; i < bumps; i++) cp.push(rr());
cp.push(cp[0]);
const pts: number[] = [];
const n = bumps * 12;
for (let k = 0; k <= n; k++) {
const t = k / n * bumps;
const i0 = Math.floor(t), f = t - i0;
const s = (1 - Math.cos(f * Math.PI)) * 0.5;
pts.push(cp[i0] + (cp[i0 + 1] - cp[i0]) * s);
}
return pts;
}
const CLOUDS: { x: number; y: number; w: number; h: number }[] = [];
{
const rr = rng(23);
for (let i = 0; i < 3; i++) CLOUDS.push({ x: 0.08 + rr() * 0.78, y: 0.09 + rr() * 0.17, w: 0.2 + rr() * 0.16, h: 0.026 + rr() * 0.02 });
}
const FAR_PTS = ridgePts(11, 3);
const NEAR_PTS = ridgePts(77, 5);
const TREES: { x: number; h: number }[] = [];
{
const rr = rng(5);
for (let i = 0; i < 16; i++) TREES.push({ x: rr(), h: 0.06 + rr() * 0.05 });
}
export default function LightScatter({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#fff7e6", // vanilla $color: $subject-cream
}: LightScatterProps) {
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 sceneRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const veilRef = useRef<HTMLCanvasElement | null>(null);
const vctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<Size>;
const lightRef = useRef({ lx: 0, ly: 0, tx: 0, ty: 0, manual: false }) as Box<Light>;
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 scene = sceneRef.current;
const veil = veilRef.current;
if (!field || !cv || !scene || !veil) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const s = sizeRef.current;
s.w = r.width; s.h = r.height; s.dpr = dpr;
cv.width = scene.width = veil.width = Math.round(r.width * dpr);
cv.height = scene.height = veil.height = Math.round(r.height * dpr);
ctxRef.current?.setTransform(dpr, 0, 0, dpr, 0, 0);
sctxRef.current?.setTransform(dpr, 0, 0, dpr, 0, 0);
vctxRef.current?.setTransform(dpr, 0, 0, dpr, 0, 0);
const li = lightRef.current;
if (!li.lx) { li.lx = r.width * 0.34; li.ly = r.height * 0.26; li.tx = li.lx; li.ty = li.ly; }
}
// 능선 위 한 점의 y — 정규화 점렬을 CSS px 로 샘플한다
function ridgeY(pts: number[], base: number, amp: number, x: number): number {
const s = sizeRef.current;
if (!s.w) return 0;
const t = x / s.w * (pts.length - 1);
const i0 = Math.floor(t);
if (i0 >= pts.length - 1) return s.h * (base - amp * pts[pts.length - 1]);
const f = t - i0;
return s.h * (base - amp * (pts[i0] + (pts[i0 + 1] - pts[i0]) * f));
}
function fillRidge(c: CanvasRenderingContext2D, pts: number[], base: number, amp: number, color: string): void {
const s = sizeRef.current;
c.fillStyle = color;
c.beginPath();
c.moveTo(0, s.h + 2);
for (let x = 0; x <= s.w; x += 4) c.lineTo(x, ridgeY(pts, base, amp, x));
c.lineTo(s.w, s.h + 2);
c.closePath();
c.fill();
}
// 가늘고 긴 구름 세 조각 — 하늘의 가림막. 광원과 구름 사이 틈이 하늘의 광선 뼈대다
function drawClouds(c: CanvasRenderingContext2D): void {
const s = sizeRef.current;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
c.fillStyle = cssv("--cloud");
for (const cl of CLOUDS) {
const cx = cl.x * s.w, cy = cl.y * s.h, cw = cl.w * s.w, ch = cl.h * s.h;
c.beginPath();
c.moveTo(cx + cw * 0.5, cy);
c.ellipse(cx, cy, cw * 0.5, ch, 0, 0, Math.PI * 2);
c.moveTo(cx - cw * 0.32 + cw * 0.22, cy + ch * 0.35);
c.ellipse(cx - cw * 0.32, cy + ch * 0.35, cw * 0.22, ch * 0.62, 0, 0, Math.PI * 2);
c.moveTo(cx + cw * 0.3 + cw * 0.2, cy - ch * 0.3);
c.ellipse(cx + cw * 0.3, cy - ch * 0.3, cw * 0.2, ch * 0.6, 0, 0, Math.PI * 2);
c.fill();
}
}
// 침엽수 실루엣 — 앞 능선 등선선 위에 삼각 두 개를 포개 얹는다
function drawTrees(c: CanvasRenderingContext2D): void {
const s = sizeRef.current;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
c.fillStyle = cssv("--tree");
for (const tr of TREES) {
const x = tr.x * s.w;
const y = ridgeY(NEAR_PTS, 0.80, 0.15, x) + 1;
const h = tr.h * s.h;
const w = h * 0.44;
c.beginPath();
c.moveTo(x, y - h);
c.lineTo(x + w * 0.5, y - h * 0.34);
c.lineTo(x + w * 0.28, y - h * 0.34);
c.lineTo(x + w * 0.85, y);
c.lineTo(x - w * 0.85, y);
c.lineTo(x - w * 0.28, y - h * 0.34);
c.lineTo(x - w * 0.5, y - h * 0.34);
c.closePath();
c.fill();
}
}
// 밑그림 — 세로 그라디언트 하늘 + 광원 주위 광운 + 실루엣 두 겹
function drawScene(): void {
const sctx = sctxRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!sctx || !s.w) return;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
const sky = sctx.createLinearGradient(0, 0, 0, s.h);
sky.addColorStop(0, cssv("--sky-0"));
sky.addColorStop(1, cssv("--sky-1"));
sctx.fillStyle = sky;
sctx.fillRect(0, 0, s.w, s.h);
const glow = sctx.createRadialGradient(li.lx, li.ly, 2, li.lx, li.ly, Math.max(s.w, s.h) * 0.26);
glow.addColorStop(0, cssv("--glow-0"));
glow.addColorStop(0.22, cssv("--glow-0"));
glow.addColorStop(1, cssv("--glow-1"));
sctx.fillStyle = glow;
sctx.fillRect(0, 0, s.w, s.h);
drawClouds(sctx);
fillRidge(sctx, FAR_PTS, 0.58, 0.09, cssv("--ridge-far"));
fillRidge(sctx, NEAR_PTS, 0.80, 0.15, cssv("--ridge-near"));
drawTrees(sctx);
}
// 베일 — 밑그림을 복사한 뒤 광원에서 장면 반대편으로 빠지는 선형 그라디언트로 알파를 깎는다
function drawVeil(): void {
const vctx = vctxRef.current;
const scene = sceneRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!vctx || !scene || !s.w) return;
vctx.globalCompositeOperation = "source-over";
vctx.clearRect(0, 0, s.w, s.h);
vctx.drawImage(scene, 0, 0, s.w, s.h);
const ux = s.w * 0.5 - li.lx, uy = s.h * 0.5 - li.ly;
const ul = Math.sqrt(ux * ux + uy * uy) || 1;
const far = Math.sqrt(s.w * s.w + s.h * s.h) * 1.05;
const fx = li.lx + ux / ul * far, fy = li.ly + uy / ul * far;
const g = vctx.createLinearGradient(li.lx, li.ly, fx, fy);
g.addColorStop(0, "rgba(0, 0, 0, .95)");
g.addColorStop(0.5, "rgba(0, 0, 0, .5)");
g.addColorStop(1, "rgba(0, 0, 0, 0)");
vctx.globalCompositeOperation = "destination-in";
vctx.fillStyle = g;
vctx.fillRect(0, 0, s.w, s.h);
}
function paint(): void {
const ctx = ctxRef.current;
const scene = sceneRef.current;
const veil = veilRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!ctx || !scene || !veil || !s.w) return;
drawScene();
drawVeil();
// ① 밑그림을 그대로 깐다
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.drawImage(scene, 0, 0, s.w, s.h);
// ② 광원 기준 1.03배씩 커지는 겹 12장 — 겹칠수록 밝아지는 lighter 합성으로 광선이 자란다
let sc = 1;
for (let i = 1; i <= STACK; i++) {
sc *= GROW;
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = 0.55 * (1 - i / (STACK + 1));
ctx.drawImage(veil, li.lx * (1 - sc), li.ly * (1 - sc), s.w * sc, s.h * sc);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
const s = sizeRef.current;
const li = lightRef.current;
if (!li.manual) {
const th = now / 1000 / TOUR * Math.PI * 2;
li.tx = s.w * (0.34 + Math.sin(th) * 0.18);
li.ty = s.h * (0.25 + Math.sin(th * 2 + 0.8) * 0.12);
}
li.lx += (li.tx - li.lx) * LERP;
li.ly += (li.ty - li.ly) * LERP;
paint();
rafRef.current = requestAnimationFrame(frame);
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
li.tx = Math.max(s.w * 0.08, Math.min(s.w * 0.92, e.clientX - r.left));
li.ty = Math.max(s.h * 0.06, Math.min(s.h * 0.6, e.clientY - r.top));
li.manual = true;
}
function onLeave(): void {
lightRef.current.manual = false;
}
function onKey(e: KeyEvt): void {
const s = sizeRef.current;
const li = lightRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-12, 0], ArrowRight: [12, 0], ArrowUp: [0, -12], ArrowDown: [0, 12],
};
const dd = map[e.key];
if (!dd) { if (e.key === "Escape") li.manual = false; return; }
e.preventDefault();
li.manual = true;
li.tx = Math.max(s.w * 0.08, Math.min(s.w * 0.92, li.tx + dd[0]));
li.ty = Math.max(s.h * 0.06, Math.min(s.h * 0.6, li.ty + dd[1]));
if (reduced()) { li.lx = li.tx; li.ly = li.ty; paint(); }
}
function onResize(): void {
fit();
if (reduced()) paint();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
const scene = document.createElement("canvas");
sceneRef.current = scene;
sctxRef.current = scene.getContext("2d");
const veil = document.createElement("canvas");
veilRef.current = veil;
vctxRef.current = veil.getContext("2d");
if (!ctxRef.current || !sctxRef.current || !vctxRef.current) return;
fit();
if (reduced()) {
// reduce — 루프를 시작하지 않고 광선이 다 자란 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
paint();
} else {
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
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="M184 128a56 56 0 1 1-56-56a56 56 0 0 1 56 56" opacity=".2"/><path d="M120 40V16a8 8 0 0 1 16 0v24a8 8 0 0 1-16 0m72 88a64 64 0 1 1-64-64a64.07 64.07 0 0 1 64 64m-16 0a48 48 0 1 0-48 48a48.05 48.05 0 0 0 48-48M58.34 69.66a8 8 0 0 0 11.32-11.32l-16-16a8 8 0 0 0-11.32 11.32Zm0 116.68l-16 16a8 8 0 0 0 11.32 11.32l16-16a8 8 0 0 0-11.32-11.32M192 72a8 8 0 0 0 5.66-2.34l16-16a8 8 0 0 0-11.32-11.32l-16 16A8 8 0 0 0 192 72m5.66 114.34a8 8 0 0 0-11.32 11.32l16 16a8 8 0 0 0 11.32-11.32ZM48 128a8 8 0 0 0-8-8H16a8 8 0 0 0 0 16h24a8 8 0 0 0 8-8m80 80a8 8 0 0 0-8 8v24a8 8 0 0 0 16 0v-24a8 8 0 0 0-8-8m112-88h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16"/></g></svg>
<span>커서를 움직이면 광원이 천천히 따라와 빛이 부챗살로 갈라집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>12</span><span className={styles.badgeUnit}>겹 누적</span></b>
</div>
</div>
</main>
);
}08 · Scattered light rays — Measured area 46.398% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Rays grow where the stack brightens
ctx.globalAlpha = 1;
ctx.drawImage(scene, 0, 0, W, H);
// 2. twelve copies growing 1.03x each about the light — the lighter composite
// grows the rays where copies overlap
var s = 1;
for (var i = 1; i <= STACK; i++) {
s *= GROW;
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = 0.55 * (1 - i / (STACK + 1));
ctx.drawImage(veil, lx * (1 - s), ly * (1 - s), W * s, H * s);
}
Overlaying the dark veil twelve times, each copy drifting away from the light, accumulates brightness only in the gaps between trees. The light source eases toward your pointer (measured: 77,145 alpha pixels changed), and the arrow keys move it too.
09Depth fog scene
Six ridge layers stand in order of distance and fog color is mixed in exponentially with distance — a quiet, distant background for a travel landing page.

<main class="stage">
<div class="df" id="df">
<p class="df__hint">
<svg class="df__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 100a68 68 0 0 1-68 68H76a44 44 0 1 1 14.2-85.66v.11A68.06 68.06 0 0 1 224 100" opacity=".2"/><path d="M120 208H72a8 8 0 0 1 0-16h48a8 8 0 0 1 0 16m64-16h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16m-24 32h-56a8 8 0 0 0 0 16h56a8 8 0 0 0 0-16m72-124a76.08 76.08 0 0 1-76 76H76a52 52 0 0 1 0-104a53 53 0 0 1 8.92.76A76.08 76.08 0 0 1 232 100m-16 0a60.06 60.06 0 0 0-120-3.54a8 8 0 0 1-16-.92q.21-3.66.77-7.23A38 38 0 0 0 76 88a36 36 0 0 0 0 72h80a60.07 60.07 0 0 0 60-60"/></g></svg>
<span>Raise the fog density and the far ridges melt into the sky</span>
</p>
<div class="df__field">
<canvas class="df__cv" id="cv"></canvas>
</div>
<div class="df__bar">
<label class="df__dial">Fog density
<input type="range" id="fog" min="10" max="60" step="1" value="24" aria-label="Fog density">
<b class="df__val" id="fogVal">×2.4</b>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 깊이 안개 씬 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·다이얼만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다(01·05 항목과 같은 방식).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.df {
// 하늘 — 위가 노랑에 가깝고 지평선으로 갈수록 크림에 녹는다(안개의 목표색이기도 하다)
--sky-0: #{mix($stage-yellow, $subject-cream, 62%)};
--sky-1: #{mix($stage-yellow, $subject-cream, 24%)};
--sun-0: #{rgba($stage-orange, .32)};
--sun-1: #{rgba($stage-orange, 0)};
--bird: #{$subject-ink};
--ink: #{$subject-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.df__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;
}
.df__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-orange-deep; }
.df__field {
position: relative; height: 184px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.df__cv { display: block; width: 100%; height: 100%; }
.df__bar { display: flex; align-items: center; gap: $sp-3; }
.df__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.df__dial input { width: 116px; margin: 0; accent-color: $subject-blue; }
.df__val { color: $subject-ink; font-size: 11px; font-weight: 700; }
@media (max-width: 340px) {
.df { gap: $sp-1; }
.df__field { height: 104px; }
.df__dial { gap: $sp-1; }
.df__dial input { width: 68px; }
.df__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 여섯 층 깊이가 다 보이는 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.df.is-still .df__val { 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);
}
.df {
--sky-0: rgb(100%, 87.8666666667%, 49.5921568627%);
--sky-1: rgb(100%, 93.3803921569%, 74.4784313725%);
--sun-0: rgba(255, 77, 31, 0.32);
--sun-1: rgba(255, 77, 31, 0);
--bird: #17141a;
--ink: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.df__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;
}
.df__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c93000;
}
.df__field {
position: relative;
height: 184px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.df__cv {
display: block;
width: 100%;
height: 100%;
}
.df__bar {
display: flex;
align-items: center;
gap: 12px;
}
.df__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.df__dial input {
width: 116px;
margin: 0;
accent-color: #2f6df6;
}
.df__val {
color: #17141a;
font-size: 11px;
font-weight: 700;
}
@media (max-width: 340px) {
.df {
gap: 4px;
}
.df__field {
height: 104px;
}
.df__dial {
gap: 4px;
}
.df__dial input {
width: 68px;
}
.df__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.df.is-still .df__val {
opacity: 0.92 !important;
}
}// 깊이 안개 씬 — 능선 실루엣 여섯 겹을 거리순으로 뒤에서 앞으로 그린다. 각 능선은 시드 고정
// 노이즈로 미리 계산한 봉우리 점렬(Path2D)이고, 거리별 안개는 지수 혼합이다:
// 그 층이 남길 대비 t = exp(-거리/밀도). 멀수록 하늘색에 녹아 대비가 사라지고, 앞쪽은 어둡고
// 대비가 강하며 서로 다른 속도로 가로 흘러(시차) 같은 색 도형 여섯 개가 깊이로 읽힌다.
var root = document.getElementById('df');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var fogIn = document.getElementById('fog');
var fogOut = document.getElementById('fogVal');
var css = getComputedStyle(root);
function v(n) { return css.getPropertyValue(n).trim(); }
var SKY0 = v('--sky-0'), SKY1 = v('--sky-1');
var SUN0 = v('--sun-0'), SUN1 = v('--sun-1');
var BIRD = v('--bird');
var INK = hex3(v('--ink'));
var SKY1C = hex3(SKY1); // 안개가 희석해 가는 목표색 — 지평선 근처 하늘색
// '#rrggbb' → [r, g, b]. 토큰 mix 값은 컴파일 타임에 hex 로 굳는다
function hex3(s) {
var m = /^#([0-9a-f]{6})$/i.exec(s);
return m ? [parseInt(s.substr(1, 2), 16), parseInt(s.substr(3, 2), 16), parseInt(s.substr(5, 2), 16)] : [0, 0, 0];
}
function mix3(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function css3(c) {
return 'rgb(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ')';
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LAYERS = 6;
var density = 2.4; // Fog density(지수 혼합의 분모) — 슬라이더로 바뀐다
var STILL_T = 3; // reduce 정지판을 찍는 시각(초) — 새가 하늘에 떠 있는 순간
var raf = 0, W = 0, H = 0, dpr = 1;
var paths = []; // 능선별 Path2D — fit() 에서 한 번만 만든다
var meta = []; // 능선별 기준선·진폭·흐름 속도
// 시드 고정 난수와 능선 점렬 — 08 빛 산란과 같은 방식(조절점 사이 코사인 보간, 양 끝 이어짐)
function rng(seed) {
return function () { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
}
function ridgePts(seed, bumps) {
var rr = rng(seed);
var cp = [];
for (var i = 0; i < bumps; i++) cp.push(rr());
cp.push(cp[0]);
var pts = [];
var n = bumps * 12;
for (var k = 0; k <= n; k++) {
var t = k / n * bumps;
var i0 = Math.floor(t), f = t - i0;
var s = (1 - Math.cos(f * Math.PI)) * 0.5;
pts.push(cp[i0] + (cp[i0 + 1] - cp[i0]) * s);
}
return pts;
}
var ridgeData = [];
for (var li = 0; li < LAYERS; li++) {
ridgeData.push(ridgePts(31 + li * 17, 3 + li)); // 앞으로 올수록 봉우리가 촘촘해진다
meta.push({
base: 0.40 + li * 0.082, // 능선 기준선(정규) — 뒤가 높고 앞이 낮게 내려온다
amp: 0.045 + li * 0.026, // 진폭 — 앞으로 올수록 산이 크다
speed: 4 + li * 6 // 흐름 속도(px/초) — 앞이 빠르다(시차)
});
}
// 새 두 마리 — 하늘을 가로지르는 작은 v 자. 날개각이 떨린다
var BIRDS = [
{ y: 0.15, speed: 0.11, phase: 0.1, size: 9 },
{ y: 0.23, speed: 0.07, phase: 0.62, size: 7 }
];
function fit() {
var r = cv.parentElement.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);
paths = [];
for (var i = 0; i < LAYERS; i++) {
var pts = ridgeData[i], m = meta[i];
var p = new Path2D();
p.moveTo(-2, H + 2);
for (var k = 0; k < pts.length; k++) {
p.lineTo(k / (pts.length - 1) * W, H * (m.base - m.amp * pts[k]));
}
p.lineTo(W + 2, H + 2);
p.closePath();
paths.push(p);
}
}
function birds(sec) {
ctx.strokeStyle = BIRD;
ctx.lineCap = 'round';
ctx.globalAlpha = 0.8;
for (var i = 0; i < BIRDS.length; i++) {
var b = BIRDS[i];
var span = W + 80;
var x = -40 + ((sec * b.speed * W + b.phase * span) % span);
var y = H * (b.y + Math.sin(sec * 0.8 + b.phase * 6) * 0.02);
var wing = 0.18 + (Math.sin(sec * 6.5 + b.phase * 9) * 0.5 + 0.5) * 0.62; // 날개각(라디안, 0.18~0.8)
var dy = Math.sin(wing) * b.size;
var dx = Math.cos(wing) * b.size;
ctx.lineWidth = Math.max(1, b.size * 0.14);
ctx.beginPath();
ctx.moveTo(x - dx, y - dy);
ctx.quadraticCurveTo(x - dx * 0.3, y - dy * 0.2, x, y);
ctx.quadraticCurveTo(x + dx * 0.3, y - dy * 0.2, x + dx, y - dy);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function paint(sec) {
// 하늘 — 위가 진하고 지평선으로 갈수록 옅어지는 세로 그라디언트
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, SKY0);
sky.addColorStop(1, SKY1);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
// 옅은 해 — 거리 감각을 잡아 주는 반점 하나(빛줄기는 없다. 그건 08 의 소재다)
var sun = ctx.createRadialGradient(W * 0.74, H * 0.2, 1, W * 0.74, H * 0.2, H * 0.3);
sun.addColorStop(0, SUN0);
sun.addColorStop(1, SUN1);
ctx.fillStyle = sun;
ctx.fillRect(0, 0, W, H);
birds(sec);
// 능선 — 뒤에서 앞으로. 거리마다 안개 색을 지수로 섞는다: t = exp(-거리/밀도)
for (var i = 0; i < LAYERS; i++) {
var d = LAYERS - i; // 거리 — 앞층이 1
var t = Math.exp(-d / density); // 그 층이 남길 대비
var off = (sec * meta[i].speed) % W;
ctx.fillStyle = css3(mix3(SKY1C, INK, t));
ctx.save();
ctx.translate(-off, 0);
ctx.fill(paths[i]);
ctx.translate(W, 0);
ctx.fill(paths[i]);
ctx.restore();
}
}
function frame(now) {
paint(now / 1000);
raf = requestAnimationFrame(frame);
}
function setFog(val) {
density = Number(val) / 10;
fogOut.textContent = '×' + density.toFixed(1);
}
fogIn.addEventListener('input', function () { setFog(fogIn.value); if (reduce) paint(STILL_T); });
window.addEventListener('resize', function () { fit(); if (reduce) paint(STILL_T); });
fit();
setFog(fogIn.value);
if (reduce) {
// reduce — 루프를 시작하지 않고 여섯 층 깊이가 다 보이는 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint(STILL_T);
} else {
raf = requestAnimationFrame(frame);
}// DepthFog — vanilla 판(run/077/demo/09-fx-depth-fog/index.html) 의 React 포트.
// 능선 실루엣 여섯 겹을 거리순으로 세우고 거리마다 안개 색을 지수로 섞는다(t = exp(-거리/밀도)).
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./DepthFog.module.scss";
export type DepthFogProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type RngEvt = { target: { value: string } };
const LAYERS = 6;
const STILL_T = 3; // reduce 정지판을 찍는 시각(초) — 새가 하늘에 떠 있는 순간
const FOG_LABEL = "안개 밀도";
const BASE_FOG = "24";
const BIRDS = [
{ y: 0.15, speed: 0.11, phase: 0.1, size: 9 },
{ y: 0.23, speed: 0.07, phase: 0.62, size: 7 },
];
type Size = { w: number; h: number; dpr: number };
type RGB = [number, number, number];
// 시드 고정 난수와 능선 점렬(정규화 0..1) — 조절점 사이 코사인 보간, 양 끝이 이어진다
function rng(seed: number): () => number {
return function (): number {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
}
function ridgePts(seed: number, bumps: number): number[] {
const rr = rng(seed);
const cp: number[] = [];
for (let i = 0; i < bumps; i++) cp.push(rr());
cp.push(cp[0]);
const pts: number[] = [];
const n = bumps * 12;
for (let k = 0; k <= n; k++) {
const t = k / n * bumps;
const i0 = Math.floor(t), f = t - i0;
const s = (1 - Math.cos(f * Math.PI)) * 0.5;
pts.push(cp[i0] + (cp[i0 + 1] - cp[i0]) * s);
}
return pts;
}
const RIDGE_DATA: number[][] = [];
const META: { base: number; amp: number; speed: number }[] = [];
for (let i = 0; i < LAYERS; i++) {
RIDGE_DATA.push(ridgePts(31 + i * 17, 3 + i)); // 앞으로 올수록 봉우리가 촘촘해진다
META.push({
base: 0.40 + i * 0.082, // 능선 기준선(정규) — 뒤가 높고 앞이 낮게 내려온다
amp: 0.045 + i * 0.026, // 진폭 — 앞으로 올수록 산이 크다
speed: 4 + i * 6, // 흐름 속도(px/초) — 앞이 빠르다(시차)
});
}
// '#rrggbb' → [r, g, b]. 토큰 mix 값은 컴파일 타임에 hex 로 굳는다
function hex3(s: string): RGB {
const m = /^#([0-9a-f]{6})$/i.exec(s);
return m ? [parseInt(s.substr(1, 2), 16), parseInt(s.substr(3, 2), 16), parseInt(s.substr(5, 2), 16)] : [0, 0, 0];
}
function mix3(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function css3(c: RGB): string {
return "rgb(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + ")";
}
export default function DepthFog({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#17141a", // vanilla $color: $subject-ink
}: DepthFogProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const fogOutRef = useRef<HTMLElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<Size>;
const pathsRef = useRef<Path2D[]>([]) as Box<Path2D[]>;
const densityRef = useRef(2.4) 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 cv = cvRef.current;
const ctx = ctxRef.current;
if (!cv || !ctx || !cv.parentElement) return;
const r = cv.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const s = sizeRef.current;
s.w = r.width; s.h = r.height; s.dpr = dpr;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const paths: Path2D[] = [];
for (let i = 0; i < LAYERS; i++) {
const pts = RIDGE_DATA[i], m = META[i];
const p = new Path2D();
p.moveTo(-2, r.height + 2);
for (let k = 0; k < pts.length; k++) {
p.lineTo(k / (pts.length - 1) * r.width, r.height * (m.base - m.amp * pts[k]));
}
p.lineTo(r.width + 2, r.height + 2);
p.closePath();
paths.push(p);
}
pathsRef.current = paths;
}
// 새 두 마리 — 하늘을 가로지르는 작은 v 자. 날개각이 떨린다
function birds(ctx: CanvasRenderingContext2D, sec: number): void {
const s = sizeRef.current;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
ctx.strokeStyle = cssv("--bird");
ctx.lineCap = "round";
ctx.globalAlpha = 0.8;
for (const b of BIRDS) {
const span = s.w + 80;
const x = -40 + ((sec * b.speed * s.w + b.phase * span) % span);
const y = s.h * (b.y + Math.sin(sec * 0.8 + b.phase * 6) * 0.02);
const wing = 0.18 + (Math.sin(sec * 6.5 + b.phase * 9) * 0.5 + 0.5) * 0.62;
const dy = Math.sin(wing) * b.size;
const dx = Math.cos(wing) * b.size;
ctx.lineWidth = Math.max(1, b.size * 0.14);
ctx.beginPath();
ctx.moveTo(x - dx, y - dy);
ctx.quadraticCurveTo(x - dx * 0.3, y - dy * 0.2, x, y);
ctx.quadraticCurveTo(x + dx * 0.3, y - dy * 0.2, x + dx, y - dy);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function paint(sec: number): void {
const ctx = ctxRef.current;
const s = sizeRef.current;
if (!ctx || !s.w) return;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
const sky1c = hex3(cssv("--sky-1"));
const ink = hex3(cssv("--ink"));
// 하늘 — 위가 진하고 지평선으로 갈수록 옅어지는 세로 그라디언트
const sky = ctx.createLinearGradient(0, 0, 0, s.h);
sky.addColorStop(0, cssv("--sky-0"));
sky.addColorStop(1, cssv("--sky-1"));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, s.w, s.h);
// 옅은 해 — 거리 감각을 잡아 주는 반점 하나(빛줄기는 없다. 그건 08 의 소재다)
const sun = ctx.createRadialGradient(s.w * 0.74, s.h * 0.2, 1, s.w * 0.74, s.h * 0.2, s.h * 0.3);
sun.addColorStop(0, cssv("--sun-0"));
sun.addColorStop(1, cssv("--sun-1"));
ctx.fillStyle = sun;
ctx.fillRect(0, 0, s.w, s.h);
birds(ctx, sec);
// 능선 — 뒤에서 앞으로. 거리마다 안개 색을 지수로 섞는다: t = exp(-거리/밀도)
for (let i = 0; i < LAYERS; i++) {
const d = LAYERS - i; // 거리 — 앞층이 1
const t = Math.exp(-d / densityRef.current); // 그 층이 남길 대비
const off = (sec * META[i].speed) % s.w;
ctx.fillStyle = css3(mix3(sky1c, ink, t));
ctx.save();
ctx.translate(-off, 0);
ctx.fill(pathsRef.current[i]);
ctx.translate(s.w, 0);
ctx.fill(pathsRef.current[i]);
ctx.restore();
}
}
function frame(now: number): void {
paint(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function setFog(val: string): void {
densityRef.current = Number(val) / 10;
if (fogOutRef.current) fogOutRef.current.textContent = "×" + densityRef.current.toFixed(1);
}
function onRange(e: RngEvt): void {
setFog(e.target.value);
if (reduced()) paint(STILL_T);
}
function onResize(): void {
fit();
if (reduced()) paint(STILL_T);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
setFog(BASE_FOG);
if (reduced()) {
// reduce — 루프를 시작하지 않고 여섯 층 깊이가 다 보이는 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
paint(STILL_T);
} else {
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
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="M224 100a68 68 0 0 1-68 68H76a44 44 0 1 1 14.2-85.66v.11A68.06 68.06 0 0 1 224 100" opacity=".2"/><path d="M120 208H72a8 8 0 0 1 0-16h48a8 8 0 0 1 0 16m64-16h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16m-24 32h-56a8 8 0 0 0 0 16h56a8 8 0 0 0 0-16m72-124a76.08 76.08 0 0 1-76 76H76a52 52 0 0 1 0-104a53 53 0 0 1 8.92.76A76.08 76.08 0 0 1 232 100m-16 0a60.06 60.06 0 0 0-120-3.54a8 8 0 0 1-16-.92q.21-3.66.77-7.23A38 38 0 0 0 76 88a36 36 0 0 0 0 72h80a60.07 60.07 0 0 0 60-60"/></g></svg>
<span>안개 밀도를 올리면 먼 능선이 하늘에 녹아 사라집니다</span>
</p>
<div className={styles.field}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>{FOG_LABEL}
<input type="range" min="10" max="60" step="1" defaultValue={BASE_FOG} aria-label={FOG_LABEL} onChange={onRange}></input>
<b className={styles.val} ref={fogOutRef}>×2.4</b>
</label>
</div>
</div>
</main>
);
}09 · Depth fog scene — Measured area 1.434% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Contrast melts away exponentially
// ridges — back to front. Fog color mixes by distance: t = exp(-distance/density)
for (var i = 0; i < LAYERS; i++) {
var d = LAYERS - i; // distance — the front layer is 1
var t = Math.exp(-d / density); // the contrast this layer keeps
var off = (sec * meta[i].speed) % W;
ctx.fillStyle = css3(mix3(SKY1C, INK, t));
ctx.save();
ctx.translate(-off, 0);
ctx.fill(paths[i]);
ctx.translate(W, 0);
ctx.fill(paths[i]);
Exponential blending quickly melts distant layers into the sky color, so six ridges of the same ink read as a stepped panorama. Each layer drifts sideways at its own speed, doubling the depth cue, and raising the density from x2.4 to x5.0 thickens the fog until only the nearest ridge survives (measured: 46,650 alpha pixels changed).
How do html5 canvas animation examples bend real pixels?
The way html5 canvas 2d produces distortion is not drawing but re-reading. Take pixels already painted with getImageData, move only the sample coordinates and put them back (01), pull them by the height difference of their neighbors (07), or scale up the previous frame (05) — the same picture becomes a lens, a ripple or a tunnel. Changing only the paint order and color, as in the painter's algorithm and the exponential mix (02, 09), belongs to the same family.
All nine take their clock from the time requestAnimationFrame hands over; only 02 measures the gap between frames, and it clamps that gap at 0.05 seconds. In measurement the average 60-frame gap held at 16.4 to 16.6 milliseconds — pinned to 60fps — with every one of the 23 gap frames moving, and the accumulated area ranging from 1.4 percent on 09 to 57.6 percent on 07. Three functions of the html5 canvas api — read, write, composite — are the whole of the droplet and the black hole.
Where it breaks — glass turns into a gray ball
The first cut of the droplet read as a translucent gray ball, not a lens. Computing the refraction with the water thickness at half the radius made the magnification too weak: the background showed through almost unchanged, and the rim shadow layered on top became the protagonist. Only after raising the thickness to 0.9 of the radius did the grid visibly bend, and the source notes the boundary too: past 1.0 the sample coordinates near the rim go negative and the image flips.
The measurement side had its own trap. The refraction check was first run on the blue channel, which was wrong — the blue grid and the cream background differ by only 5 on that channel, so a perfectly bent grid read as an empty screen. Re-measured on the red channel, the grid spacing inside the circle comes out at about 1.3 times the outside. The nine finished effects, sources and measurements are all in the password zip below the article. The zip password is w2k393bp, and the download buttons of all three locale editions share that one password.
Three variants and accessibility
| Name | Changed value | What it feels like |
|---|---|---|
| Feedback zoom | 05 scale 1.06 to 1.02 | The pull slows and the tunnel grows longer |
| Noise layers | 03 octaves 4 to 2 | Clouds turn chunky and coarse-grained |
| Color | $subject-blue to $subject-mint | Cold blue becomes fresh mint |
Accessibility follows one rule across all nine boards. For users with prefers-reduced-motion: reduce, the rAF loop never starts; each board draws a single still frame at its most readable moment — the droplet resting on the background (01), the finished tunnel (05), the birds hanging in the sky (09) — and each still frame measured a non-zero alpha-pixel count. Six boards (01, 02, 03, 06, 07, 08) carry tabindex and role: Enter and Space start a ripple on 07, and the arrow keys move the droplet, camera, noise pan or light. The cursor on 08 is a thin crosshair that never blocks the scene.
FAQ
Is this html5 canvas animation tutorial for beginners or for advanced coders?
The core of each effect is one short formula, so the entry point is gentle: a single division for perspective (02) and one drawImage for the tunnel (05). The heavier parts — the wave grid (07) and the noise buffer (03) — are self-contained loops you can paste first and tune later.
Why canvas instead of CSS animations here?
Because real pixel values have to be read. CSS transforms move whole elements, but swelling the text under a droplet (01) or refracting the picture under a ripple (07) is pixel-level relocation that CSS cannot express. For simple movement and fades, CSS remains the better choice for battery and performance.
Do these slow down on phones?
Keep the computed pixel count below the screen size. The noise is calculated at 160x100 and scaled up (03), the water simulation solves on a 96x47 grid and renders at three times that (07), and the tunnel draws only one enlargement and a few specks per frame, which makes it the lightest of the nine.









