02A Bubble Whose Film Drains Down, Thinning the Crown to Black as the Bottom Bands Climb
It splits crown to bottom into 72 bands and passes a share of each band down to the next, in proportion to the boundary circumference and the upper band's thickness. The upper color bands slide down while the thick film pooling at the bottom climbs, and once the crown reaches the thinnest black film, that bubble is about to pop.
Best forLoading wait screens and time-remaining cues
DRAIN_KBLACK_FILM_NMSWIRL_AMP

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="A soap bubble whose film drains downward and thins from the crown, popping soon after the crown reaches black film. Press to fast-forward the drainage"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Film draining downward</p>
<div class="hs__tools">
<button class="hs__btn" data-role="new" type="button">New bubble</button>
<button class="hs__btn" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막을 얹어 명도 차로 방울이 읽힌다 (막의 무지갯빛은 이 표가 아니라 두께 계산에서 나온다)
const BG = [29, 26, 44];
const DEEP = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 장면이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 112 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 2D 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값(−1~1)을 두고 그 사이를 부드러운 곡선으로 잇는다
const NOISE_SEED = 465;
const noisePerm = new Uint8Array(512);
const noiseVal = new Float32Array(256);
{
const nr = makeRng(NOISE_SEED);
for (let i = 0; i < 256; i += 1) {
noisePerm[i] = i;
noiseVal[i] = nr() * 2 - 1;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(nr() * (i + 1));
const t = noisePerm[i];
noisePerm[i] = noisePerm[j];
noisePerm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
noisePerm[i + 256] = noisePerm[i];
}
}
function noiseHash(ix, iy) {
return noiseVal[noisePerm[noisePerm[ix & 255] + (iy & 255)]];
}
function noise2(x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = noiseHash(xi, yi);
const b = noiseHash(xi + 1, yi);
const c = noiseHash(xi, yi + 1);
const d = noiseHash(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 막의 굴절률 — 비눗물 막을 물과 같은 값으로 둔다
const FILM_N = 1.33;
// 세 파장(nm) — 화면의 빨강·초록·파랑 채널을 이 파장 하나씩으로 대신한다
const WAVE_RGB_NM = [650, 530, 450];
// 표를 만들 두께 범위(nm) — 1nm 눈금마다 세 채널 값을 미리 구해 둔다
const LUT_D_MAX = 1600;
const filmLut = new Float32Array((LUT_D_MAX + 1) * 3);
// 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
// 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
function reflectRGB(dNm) {
const out = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
out[c] = s * s;
}
return out;
}
for (let d = 0; d <= LUT_D_MAX; d += 1) {
const r = reflectRGB(d);
filmLut[d * 3] = r[0];
filmLut[d * 3 + 1] = r[1];
filmLut[d * 3 + 2] = r[2];
}
// 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
function insideCos(cosView) {
const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
return Math.sqrt(1 - s2);
}
// 두께 d(nm) 에서 채널 c 의 몫 — 표를 1nm 사이에서 선형 보간해 읽는다
function filmAt(dNm, c) {
const d = clamp(dNm, 0, LUT_D_MAX - 1);
const i = Math.floor(d);
const f = d - i;
return filmLut[i * 3 + c] * (1 - f) + filmLut[(i + 1) * 3 + c] * f;
}
// 캔버스 선·면에 쓸 막 색 — 어두운 무대 위에 세 채널 몫을 밝기로 얹는다
function filmColor(dNm, gain) {
return [BG[0] + filmAt(dNm, 0) * 235 * gain, BG[1] + filmAt(dNm, 1) * 235 * gain, BG[2] + filmAt(dNm, 2) * 235 * gain];
}
// 화소 하나를 막 색으로 — under 는 방울 뒤 바탕색, nx·ny·z 는 구면 위 법선(z 는 시선 쪽 성분)
function shadeFilm(px, o, dNm, nx, ny, z, under, alpha) {
const dEff = dNm * insideCos(z);
// 가장자리일수록 시선이 막을 비스듬히 지나 색이 진하다 — 가운데는 뒤 바탕이 비쳐 보이게 낮춘다
const amp = 0.22 + 0.78 * Math.pow(1 - z, 0.6);
// 왼쪽 위에서 오는 빛 하나의 반짝임
const hl = Math.pow(Math.max(0, nx * -0.42 + ny * -0.5 + z * 0.76), 60);
for (let c = 0; c < 3; c += 1) {
const v = under[c] + filmAt(dEff, c) * 235 * amp + hl * 220;
px[o + c] = under[c] + (v - under[c]) * alpha;
}
px[o + 3] = 255;
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const pc = c.getContext('2d');
if (!pc) {
return null;
}
const img = pc.createImageData(w, h);
return { cvs: c, pc: pc, img: img, px: img.data, w: w, h: h };
}
// putImageData 는 캔버스 변환(배율)을 무시한다 — 작은 캔버스에 올린 뒤 drawImage 로 장면 좌표에 그린다
function showPix(p, x, y) {
p.pc.putImageData(p.img, 0, 0);
ctx.drawImage(p.cvs, x, y, p.w, p.h);
}
// 02 — 막이 아래로 흘러내려 꼭대기부터 얇아지고, 꼭대기가 검은 막에 닿으면 곧 터진다
// 흘러내림 빠르기(1/s) — 띠 경계로 내려가는 양 = DRAIN_K × 경계 둘레(sinθ) × 위 띠 두께. 두꺼운 막일수록 많이 흐른다
const DRAIN_K = 0.28;
// 바닥 띠에 모인 막이 이 두께(nm)를 넘으면 넘친 만큼 방울져 떨어진다
const D_DRIP = 1500;
// 이 두께(nm) 아래면 세 파장이 거의 남지 않는 검은 막 — 꼭대기가 여기 닿으면 터질 때가 가까웠다
const BLACK_FILM_NM = 30;
// 흐르는 막의 소용돌이 흔들림(nm) — 얇은 곳일수록 줄여 검은 막은 검은 채로 둔다
const SWIRL_AMP = 60;
// 꼭대기(0)에서 바닥(π)까지를 나눈 띠 수와 처음 두께(nm)
const BAND_N = 72;
const D_TOP0 = 360;
const D_BOT0 = 980;
// 검은 막이 닿은 뒤 터지기까지, 터지는 데 걸리는 시간(ms)
const POP_WAIT_MS = 700;
const POP_MS = 320;
// 시연은 이만큼(초) 미리 흘려 둔 방울에서 시작한다 — 2초 안에 검은 막과 터짐이 보이게
const DEMO_WARM_S = 3.9;
const BCX = 318;
const BCY = 150;
const BR = 124;
const TXT_DRAIN = 'Film draining downward';
const TXT_BLACK = 'Crown reached black film — about to pop';
const TXT_POP = 'Popped — new bubble';
const TXT_PAUSE = 'Pause';
const newBtn = root.querySelector('[data-role="new"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const thick = new Float64Array(BAND_N);
const bandArea = new Float64Array(BAND_N);
for (let i = 0; i < BAND_N; i += 1) {
bandArea[i] = Math.sin((i + 0.5) / BAND_N * Math.PI);
}
const BOX = BR * 2 + 2;
const PX0 = BCX - BR - 1;
const PY0 = BCY - BR - 1;
const pix = makePix(BOX, BOX);
const under = [BG[0], BG[1], BG[2]];
let age = 0;
let blackAt = -1;
let popAt = -1;
let paused = false;
let filmT = 0;
function resetFilm() {
for (let i = 0; i < BAND_N; i += 1) {
thick[i] = lerp(D_TOP0, D_BOT0, (i + 0.5) / BAND_N);
}
age = 0;
blackAt = -1;
popAt = -1;
}
// 띠 i 와 i+1 사이 경계로 내려가는 양 — 위 띠의 두께로 정한다. 두 띠의 (두께×넓이) 합은 그대로라 막의 양이 보존된다
function drainStep(dt) {
const h = dt / 4;
for (let s = 0; s < 4; s += 1) {
for (let i = 0; i < BAND_N - 1; i += 1) {
const e = Math.sin((i + 1) / BAND_N * Math.PI);
const q = DRAIN_K * e * thick[i] * h;
const moved = Math.min(q, thick[i] * bandArea[i] * 0.5);
thick[i] -= moved / bandArea[i];
thick[i + 1] += moved / bandArea[i + 1];
}
if (thick[BAND_N - 1] > D_DRIP) {
thick[BAND_N - 1] = D_DRIP;
}
}
age += dt;
}
function filmMass() {
let m = 0;
for (let i = 0; i < BAND_N; i += 1) {
m += thick[i] * bandArea[i];
}
return m;
}
// θ(꼭대기에서 잰 각, 0~π) 자리의 두께 — 띠 사이를 선형 보간
function bandThick(theta) {
const f = clamp(theta / Math.PI * BAND_N - 0.5, 0, BAND_N - 1);
const i = Math.floor(f);
const k = f - i;
return i >= BAND_N - 1 ? thick[BAND_N - 1] : thick[i] * (1 - k) + thick[i + 1] * k;
}
function paintFilm() {
if (!pix) {
return;
}
const px = pix.px;
const popR = popAt >= 0 ? (popAt / POP_MS) * BR * 2.1 : -1;
for (let j = 0; j < BOX; j += 1) {
const ny = (PY0 + j + 0.5 - BCY) / BR;
for (let i = 0; i < BOX; i += 1) {
const nx = (PX0 + i + 0.5 - BCX) / BR;
const r2 = nx * nx + ny * ny;
const o = (j * BOX + i) * 4;
if (r2 >= 1) {
px[o + 3] = 0;
continue;
}
if (popR >= 0) {
// 터짐 — 꼭대기 점에서 구멍이 번진다
const dd = Math.hypot(nx, ny + 1) * BR;
if (dd < popR) {
px[o + 3] = 0;
continue;
}
if (dd < popR + 3) {
px[o] = PAPER[0];
px[o + 1] = PAPER[1];
px[o + 2] = PAPER[2];
px[o + 3] = 230;
continue;
}
}
const z = Math.sqrt(1 - r2);
const base = bandThick(Math.acos(clamp(-ny, -1, 1)));
const sw = SWIRL_AMP * smooth(base / 150) * noise2(nx * 2.2 + filmT * 0.25, ny * 2.2 + filmT * 0.45);
shadeFilm(px, o, Math.max(base + sw, 0), nx, ny, z, under, 1);
px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * BR, 0, 1);
}
}
showPix(pix, PX0, PY0);
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (popAt >= 0) {
say(TXT_POP);
}
else if (blackAt >= 0) {
say(TXT_BLACK);
}
else {
say(TXT_DRAIN);
}
}
function advance(dt) {
drainStep(dt);
filmT += dt;
if (blackAt < 0 && thick[0] <= BLACK_FILM_NM) {
blackAt = 0;
}
}
function step() {
if (paused) {
return;
}
const dt = FIXED_DT / 1000;
if (popAt >= 0) {
popAt += FIXED_DT;
if (popAt >= POP_MS) {
resetFilm();
}
report();
return;
}
advance(dt);
if (blackAt >= 0) {
blackAt += FIXED_DT;
if (blackAt >= POP_WAIT_MS) {
popAt = 0;
}
}
report();
}
// 움직임 줄이기 — 꼭대기가 검은 막에 닿은 순간까지 흘려 두고 멈춘 한 장
function settle() {
popAt = -1;
let n = 0;
while (blackAt < 0 && n < 4000) {
advance(FIXED_DT / 1000);
n += 1;
}
report();
}
function warm(sec) {
const n = Math.round(sec * 1000 / FIXED_DT);
for (let k = 0; k < n && blackAt < 0; k += 1) {
advance(FIXED_DT / 1000);
}
}
function demoTick(t) {
if (t < 0) {
resetFilm();
}
}
function draw() {
clearStage();
ctx.fillStyle = rgba(DEEP, 0.9);
ctx.beginPath();
ctx.ellipse(BCX, BCY + BR + 10, BR * 0.62, 7, 0, 0, Math.PI * 2);
ctx.fill();
paintFilm();
if (popAt < 0) {
ctx.strokeStyle = rgba(PAPER, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(BCX, BCY, BR, 0, Math.PI * 2);
ctx.stroke();
}
panelRow(0, 'Crown thickness', String(Math.round(thick[0])) + ' nm');
panelRow(1, 'Bottom thickness', String(Math.round(thick[BAND_N - 1])) + ' nm');
panelRow(2, 'Elapsed', age.toFixed(1) + ' s');
}
function after() {
report();
draw();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
after();
}
function fresh() {
resetFilm();
after();
}
// 흘러내림을 앞당긴다 — 움직임 줄이기에서도 두께가 바로 바뀐 한 장을 그린다
function hurry() {
if (popAt >= 0) {
return;
}
warm(0.6);
after();
}
cv.addEventListener('pointerdown', function (ev) {
ev.preventDefault();
wake();
hurry();
});
cv.addEventListener('keydown', function (ev) {
if (ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
wake();
setPaused(!paused);
}
else if (ev.key === 'ArrowRight') {
ev.preventDefault();
wake();
hurry();
}
else if (ev.key === 'Home' || ev.key === 'Enter') {
ev.preventDefault();
wake();
fresh();
}
});
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); fresh(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); });
}
resetFilm();
if (demo && !still) {
warm(DEMO_WARM_S);
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
let looping = false;
function frame(ts) {
if (!looping) {
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (looping) {
return;
}
looping = true;
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
looping = false;
window.cancelAnimationFrame(rafId);
}
// 움직임 줄이기 설정을 페이지를 연 채로 바꿔도 바로 따른다 — 켜면 루프를 멈추고 끝난 장면 한 장, 끄면 다시 돈다
function applyMotion() {
still = reduceQuery.matches;
if (still) {
stopLoop();
settle();
draw();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', applyMotion);
applyMotion();
return function () {
stopLoop();
reduceQuery.removeEventListener('change', applyMotion);
window.removeEventListener('resize', onResize);
};
})();// SbGravityDrainBlackCap - vanilla 판(02-sb-gravity-drain-band-rise) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SbGravityDrainBlackCap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SbGravityDrainBlackCap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막을 얹어 명도 차로 방울이 읽힌다 (막의 무지갯빛은 이 표가 아니라 두께 계산에서 나온다)
const BG: RGB = [29, 26, 44];
const DEEP: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 장면이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 112 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 2D 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값(−1~1)을 두고 그 사이를 부드러운 곡선으로 잇는다
const NOISE_SEED = 465;
const noisePerm = new Uint8Array(512);
const noiseVal = new Float32Array(256);
{
const nr = makeRng(NOISE_SEED);
for (let i = 0; i < 256; i += 1) {
noisePerm[i] = i;
noiseVal[i] = nr() * 2 - 1;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(nr() * (i + 1));
const t = noisePerm[i];
noisePerm[i] = noisePerm[j];
noisePerm[j] = t;
}
for (let i = 0; i < 256; i += 1) { noisePerm[i + 256] = noisePerm[i]; }
}
function noiseHash(ix: number, iy: number): number {
return noiseVal[noisePerm[noisePerm[ix & 255] + (iy & 255)]];
}
function noise2(x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = noiseHash(xi, yi);
const b = noiseHash(xi + 1, yi);
const c = noiseHash(xi, yi + 1);
const d = noiseHash(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 막의 굴절률 — 비눗물 막을 물과 같은 값으로 둔다
const FILM_N = 1.33;
// 세 파장(nm) — 화면의 빨강·초록·파랑 채널을 이 파장 하나씩으로 대신한다
const WAVE_RGB_NM: RGB = [650, 530, 450];
// 표를 만들 두께 범위(nm) — 1nm 눈금마다 세 채널 값을 미리 구해 둔다
const LUT_D_MAX = 1600;
const filmLut = new Float32Array((LUT_D_MAX + 1) * 3);
// 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
// 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
function reflectRGB(dNm: number): RGB {
const out: RGB = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
out[c] = s * s;
}
return out;
}
for (let d = 0; d <= LUT_D_MAX; d += 1) {
const r = reflectRGB(d);
filmLut[d * 3] = r[0];
filmLut[d * 3 + 1] = r[1];
filmLut[d * 3 + 2] = r[2];
}
// 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
function insideCos(cosView: number): number {
const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
return Math.sqrt(1 - s2);
}
// 두께 d(nm) 에서 채널 c 의 몫 — 표를 1nm 사이에서 선형 보간해 읽는다
function filmAt(dNm: number, c: number): number {
const d = clamp(dNm, 0, LUT_D_MAX - 1);
const i = Math.floor(d);
const f = d - i;
return filmLut[i * 3 + c] * (1 - f) + filmLut[(i + 1) * 3 + c] * f;
}
// 캔버스 선·면에 쓸 막 색 — 어두운 무대 위에 세 채널 몫을 밝기로 얹는다
function filmColor(dNm: number, gain: number): RGB {
return [BG[0] + filmAt(dNm, 0) * 235 * gain, BG[1] + filmAt(dNm, 1) * 235 * gain, BG[2] + filmAt(dNm, 2) * 235 * gain];
}
// 화소 하나를 막 색으로 — under 는 방울 뒤 바탕색, nx·ny·z 는 구면 위 법선(z 는 시선 쪽 성분)
function shadeFilm(px: Uint8ClampedArray, o: number, dNm: number, nx: number, ny: number, z: number, under: RGB, alpha: number): void {
const dEff = dNm * insideCos(z);
// 가장자리일수록 시선이 막을 비스듬히 지나 색이 진하다 — 가운데는 뒤 바탕이 비쳐 보이게 낮춘다
const amp = 0.22 + 0.78 * Math.pow(1 - z, 0.6);
// 왼쪽 위에서 오는 빛 하나의 반짝임
const hl = Math.pow(Math.max(0, nx * -0.42 + ny * -0.5 + z * 0.76), 60);
for (let c = 0; c < 3; c += 1) {
const v = under[c] + filmAt(dEff, c) * 235 * amp + hl * 220;
px[o + c] = under[c] + (v - under[c]) * alpha;
}
px[o + 3] = 255;
}
// 화소 계산판 — 작은 캔버스 하나에 ImageData 를 붙여 두고 화소를 직접 채운다
type Pix = { cvs: HTMLCanvasElement; pc: CanvasRenderingContext2D; img: ImageData; px: Uint8ClampedArray; w: number; h: number };
function makePix(w: number, h: number): Pix | null {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const pc = c.getContext('2d');
if (!pc) { return null; }
const img = pc.createImageData(w, h);
return { cvs: c, pc: pc, img: img, px: img.data, w: w, h: h };
}
// putImageData 는 캔버스 변환(배율)을 무시한다 — 작은 캔버스에 올린 뒤 drawImage 로 장면 좌표에 그린다
function showPix(p: Pix, x: number, y: number): void {
p.pc.putImageData(p.img, 0, 0);
ctx.drawImage(p.cvs, x, y, p.w, p.h);
}
// 02 — 막이 아래로 흘러내려 꼭대기부터 얇아지고, 꼭대기가 검은 막에 닿으면 곧 터진다
// 흘러내림 빠르기(1/s) — 띠 경계로 내려가는 양 = DRAIN_K × 경계 둘레(sinθ) × 위 띠 두께. 두꺼운 막일수록 많이 흐른다
const DRAIN_K = 0.28;
// 바닥 띠에 모인 막이 이 두께(nm)를 넘으면 넘친 만큼 방울져 떨어진다
const D_DRIP = 1500;
// 이 두께(nm) 아래면 세 파장이 거의 남지 않는 검은 막 — 꼭대기가 여기 닿으면 터질 때가 가까웠다
const BLACK_FILM_NM = 30;
// 흐르는 막의 소용돌이 흔들림(nm) — 얇은 곳일수록 줄여 검은 막은 검은 채로 둔다
const SWIRL_AMP = 60;
// 꼭대기(0)에서 바닥(π)까지를 나눈 띠 수와 처음 두께(nm)
const BAND_N = 72;
const D_TOP0 = 360;
const D_BOT0 = 980;
// 검은 막이 닿은 뒤 터지기까지, 터지는 데 걸리는 시간(ms)
const POP_WAIT_MS = 700;
const POP_MS = 320;
// 시연은 이만큼(초) 미리 흘려 둔 방울에서 시작한다 — 2초 안에 검은 막과 터짐이 보이게
const DEMO_WARM_S = 3.9;
const BCX = 318;
const BCY = 150;
const BR = 124;
const TXT_DRAIN = '막이 아래로 흘러내리는 중';
const TXT_BLACK = '꼭대기가 검은 막에 닿음 — 곧 터진다';
const TXT_POP = '터짐 — 새 방울';
const TXT_PAUSE = '멈춤';
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const thick = new Float64Array(BAND_N);
const bandArea = new Float64Array(BAND_N);
for (let i = 0; i < BAND_N; i += 1) { bandArea[i] = Math.sin((i + 0.5) / BAND_N * Math.PI); }
const BOX = BR * 2 + 2;
const PX0 = BCX - BR - 1;
const PY0 = BCY - BR - 1;
const pix = makePix(BOX, BOX);
const under: RGB = [BG[0], BG[1], BG[2]];
let age = 0;
let blackAt = -1;
let popAt = -1;
let paused = false;
let filmT = 0;
function resetFilm(): void {
for (let i = 0; i < BAND_N; i += 1) { thick[i] = lerp(D_TOP0, D_BOT0, (i + 0.5) / BAND_N); }
age = 0;
blackAt = -1;
popAt = -1;
}
// 띠 i 와 i+1 사이 경계로 내려가는 양 — 위 띠의 두께로 정한다. 두 띠의 (두께×넓이) 합은 그대로라 막의 양이 보존된다
function drainStep(dt: number): void {
const h = dt / 4;
for (let s = 0; s < 4; s += 1) {
for (let i = 0; i < BAND_N - 1; i += 1) {
const e = Math.sin((i + 1) / BAND_N * Math.PI);
const q = DRAIN_K * e * thick[i] * h;
const moved = Math.min(q, thick[i] * bandArea[i] * 0.5);
thick[i] -= moved / bandArea[i];
thick[i + 1] += moved / bandArea[i + 1];
}
if (thick[BAND_N - 1] > D_DRIP) { thick[BAND_N - 1] = D_DRIP; }
}
age += dt;
}
function filmMass(): number {
let m = 0;
for (let i = 0; i < BAND_N; i += 1) { m += thick[i] * bandArea[i]; }
return m;
}
// θ(꼭대기에서 잰 각, 0~π) 자리의 두께 — 띠 사이를 선형 보간
function bandThick(theta: number): number {
const f = clamp(theta / Math.PI * BAND_N - 0.5, 0, BAND_N - 1);
const i = Math.floor(f);
const k = f - i;
return i >= BAND_N - 1 ? thick[BAND_N - 1] : thick[i] * (1 - k) + thick[i + 1] * k;
}
function paintFilm(): void {
if (!pix) { return; }
const px = pix.px;
const popR = popAt >= 0 ? (popAt / POP_MS) * BR * 2.1 : -1;
for (let j = 0; j < BOX; j += 1) {
const ny = (PY0 + j + 0.5 - BCY) / BR;
for (let i = 0; i < BOX; i += 1) {
const nx = (PX0 + i + 0.5 - BCX) / BR;
const r2 = nx * nx + ny * ny;
const o = (j * BOX + i) * 4;
if (r2 >= 1) { px[o + 3] = 0; continue; }
if (popR >= 0) {
// 터짐 — 꼭대기 점에서 구멍이 번진다
const dd = Math.hypot(nx, ny + 1) * BR;
if (dd < popR) { px[o + 3] = 0; continue; }
if (dd < popR + 3) {
px[o] = PAPER[0]; px[o + 1] = PAPER[1]; px[o + 2] = PAPER[2]; px[o + 3] = 230;
continue;
}
}
const z = Math.sqrt(1 - r2);
const base = bandThick(Math.acos(clamp(-ny, -1, 1)));
const sw = SWIRL_AMP * smooth(base / 150) * noise2(nx * 2.2 + filmT * 0.25, ny * 2.2 + filmT * 0.45);
shadeFilm(px, o, Math.max(base + sw, 0), nx, ny, z, under, 1);
px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * BR, 0, 1);
}
}
showPix(pix, PX0, PY0);
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (popAt >= 0) { say(TXT_POP); }
else if (blackAt >= 0) { say(TXT_BLACK); }
else { say(TXT_DRAIN); }
}
function advance(dt: number): void {
drainStep(dt);
filmT += dt;
if (blackAt < 0 && thick[0] <= BLACK_FILM_NM) { blackAt = 0; }
}
function step(): void {
if (paused) { return; }
const dt = FIXED_DT / 1000;
if (popAt >= 0) {
popAt += FIXED_DT;
if (popAt >= POP_MS) { resetFilm(); }
report();
return;
}
advance(dt);
if (blackAt >= 0) {
blackAt += FIXED_DT;
if (blackAt >= POP_WAIT_MS) { popAt = 0; }
}
report();
}
// 움직임 줄이기 — 꼭대기가 검은 막에 닿은 순간까지 흘려 두고 멈춘 한 장
function settle(): void {
popAt = -1;
let n = 0;
while (blackAt < 0 && n < 4000) { advance(FIXED_DT / 1000); n += 1; }
report();
}
function warm(sec: number): void {
const n = Math.round(sec * 1000 / FIXED_DT);
for (let k = 0; k < n && blackAt < 0; k += 1) { advance(FIXED_DT / 1000); }
}
function demoTick(t: number): void {
if (t < 0) { resetFilm(); }
}
function draw(): void {
clearStage();
ctx.fillStyle = rgba(DEEP, 0.9);
ctx.beginPath();
ctx.ellipse(BCX, BCY + BR + 10, BR * 0.62, 7, 0, 0, Math.PI * 2);
ctx.fill();
paintFilm();
if (popAt < 0) {
ctx.strokeStyle = rgba(PAPER, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(BCX, BCY, BR, 0, Math.PI * 2);
ctx.stroke();
}
panelRow(0, '꼭대기 두께', String(Math.round(thick[0])) + ' nm');
panelRow(1, '바닥 두께', String(Math.round(thick[BAND_N - 1])) + ' nm');
panelRow(2, '흐른 시간', age.toFixed(1) + ' s');
}
function after(): void {
report();
draw();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
after();
}
function fresh(): void {
resetFilm();
after();
}
// 흘러내림을 앞당긴다 — 움직임 줄이기에서도 두께가 바로 바뀐 한 장을 그린다
function hurry(): void {
if (popAt >= 0) { return; }
warm(0.6);
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
ev.preventDefault();
wake();
hurry();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key === ' ' || ev.key === 'Spacebar') { ev.preventDefault(); wake(); setPaused(!paused); }
else if (ev.key === 'ArrowRight') { ev.preventDefault(); wake(); hurry(); }
else if (ev.key === 'Home' || ev.key === 'Enter') { ev.preventDefault(); wake(); fresh(); }
});
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); fresh(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); }); }
resetFilm();
if (demo && !still) { warm(DEMO_WARM_S); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
let looping = false;
function frame(ts: number): void {
if (!looping) { return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (looping) { return; }
looping = true;
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
looping = false;
window.cancelAnimationFrame(rafId);
}
// 움직임 줄이기 설정을 페이지를 연 채로 바꿔도 바로 따른다 — 켜면 루프를 멈추고 끝난 장면 한 장, 끄면 다시 돈다
function applyMotion(): void {
still = reduceQuery.matches;
if (still) {
stopLoop();
settle();
draw();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', applyMotion);
applyMotion();
return function (): void {
stopLoop();
reduceQuery.removeEventListener('change', applyMotion);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="막이 아래로 흘러내려 꼭대기부터 얇아지고 꼭대기가 검은 막에 닿으면 곧 터지는 비눗방울. 누르면 흘러내림을 앞당긴다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">막이 아래로 흘러내리는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="new" type="button">새 방울</button>
<button className={cx("hs__btn")} data-role="pause" type="button" aria-pressed="false">멈춤</button>
</div>
</div>
);
}02 A Bubble Whose Film Drains Down, Thinning the Crown to Black as the Bottom Bands Climb — Measured area 29.149% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Bubble Animation CSS: 9 Soap-Film Scenes That Pop
The post this demo lives in
Bubble Animation CSS: 9 Soap-Film Scenes That PopA soap bubble's rainbow is not painted on — 01 computes color from film thickness, 04 pops from a poked rim that peels b See all demos in this post
If you like this effect - similar demos
Interactions04 Character whose eyes chase the cursor3D Hover Effect CSS: 9 Pointer-Following Scenes
Interactions07 Debounced search — a search that fires once a wait value finishes counting down from the last inputMicro Interactions: 9 Essential UI Patterns
Interactions06 A Double Helix of Beads Whose Twist Speed Follows the Pointer's Horizontal PositionJavaScript 3D Animation: 9 Pointer-Led Models