09Concentric Rings Spreading From Centers, Swaying and Overlapping
Each center draws concentric rings that spread outward over time, with flowing noise added to the ring radius so the grain sways. Rings from two centers meet and stack into interference figures.
Best forCalm brand introduction backgrounds
RING_SPEEDRADIUS_NOISEINTERFERE_K

<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="Concentric rings spreading from centers, swaying and overlapping"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Concentric rings spread outward</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">Drop ink</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.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(58, 48, 76, .9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 46%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 252, 244, .92);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: $sp-3;
bottom: $sp-3;
display: flex;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 252, 244, .92);
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(58, 48, 76, .9);
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: #ffffff;
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(58, 48, 76, 0.9);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 46%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 252, 244, 0.92);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
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(58, 48, 76, 0.9);
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;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER = [252, 246, 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];
const TINTS = [SKY, PINK, MINT, LILAC, PEACH];
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)');
const 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;
}
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t, at, loop) {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas() {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx = null;
let buf = null;
let px = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF = new Float32Array(0);
function initField(nw, nh) {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) {
return;
}
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) {
grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh);
}
}
}
function blitField(nw, nh) {
if (!sctx || !buf) {
return;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n, seed) {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t) { return t * t * (3 - 2 * t); }
function vnoise2(x, y, seed) {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x, y, oct, seed) {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x, y) {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 09 먹 흐름 — 시간 확산 동심원 + 반경 노이즈 흔들림, 이중심 간섭
const RING_SPEED = 0.9; // 동심원이 16ms 스텝마다 바깥으로 자라는 반경(px)
const RADIUS_NOISE = 0.16; // 반경에 흐르는 노이즈의 흔들림 진폭(비율)
const INTERFERE_K = 0.75; // 이중심 간섭 계수 — 원이 겹칠 때 밀어 세우는 힘
const RING_GAP = 26; // 동심원 이웃 고리 간격(px)
const RING_MAX_R = 320;
const DEMO_LOOP = 3600;
const FW = 192;
const FH = 120;
const TXT_SPREAD = 'Concentric rings spread outward';
const TXT_SWAY = 'Radius noise sways the grain';
const TXT_INTER = 'Rings from the two centers interfere';
let rings = [];
let centers = [[W * 0.36, H * 0.5], [W * 0.66, H * 0.46]];
let inkN = 0;
function addCenter(x, y, t0) {
centers.push([x, y]);
if (centers.length > 3) {
centers.shift();
}
rings.push({ x: x, y: y, r: 2, born: t0 });
inkN += 1;
}
function ringField(x, y, now) {
// 시간 확산 동심원 — 스텝마다 반경이 자라고, 이웃 고리 간격으로 파동 무늬를 이룬다
let v = 0;
let v2 = 0;
for (let i = 0; i < rings.length; i += 1) {
const rg = rings[i];
const dx = x - rg.x, dy = y - rg.y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r > rg.r + 2) {
continue;
}
// 반경 노이즈 — 각도마다 흐르는 노이즈로 결이 흔들린다
const ang = Math.atan2(dy, dx);
const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
const d = r - (rg.r + sway);
const w = RING_GAP * 1.3;
if (Math.abs(d) < w) {
const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
v += c;
v2 += c * c;
}
}
// 이중심 간섭 — 고리끼리 겹치는 자리에 곱 항 ((Σc)² − Σc²)/2 를 INTERFERE_K 만큼 더한다.
// 한 중심의 고리끼리는 간격(RING_GAP)만큼 떨어져 거의 안 겹치므로 이 항은 두 중심의 고리가 만나는 마디에서 선다
return v + INTERFERE_K * (v * v - v2) * 0.5;
}
function draw() {
if (!px || !sctx) {
return;
}
const now = clock;
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g0 = grainF[y * FW + x];
// 먹 흐름 바닥 — 종이가 약간 우유빛으로 받쳐 준다
const base = mix(mix(PAPER, MINT, 0.07), INK, g0 * 0.07 + 0.012);
let col = base;
const v = ringField(wx, wy, now);
if (v > 0.04) {
const stepK = Math.floor(v * 4) / 4;
col = mix(base, INK, clamp(stepK * 0.82, 0, 0.92));
}
px[i4] = col[0];
px[i4 + 1] = col[1];
px[i4 + 2] = col[2];
px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle() {
// reduce — 퍼진 상태의 고리들을 계산으로 만든다
rings = [];
for (let c = 0; c < centers.length; c += 1) {
for (let k = 0; k < 5; k += 1) {
rings.push({ x: centers[c][0], y: centers[c][1], r: 40 + k * 42, born: -1e9 });
}
}
say(TXT_SWAY);
}
function step() {
for (let i = 0; i < rings.length; i += 1) {
rings[i].r += RING_SPEED;
}
if (rings.length > 24) {
rings.splice(0, rings.length - 24);
}
if (demo) {
say(TXT_SPREAD);
}
}
function drop(x, y) {
addCenter(x, y, clock);
if (still) {
settle();
draw();
return;
}
say(TXT_INTER);
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
say(TXT_SPREAD);
}
if (crossed(t, 480, DEMO_LOOP) || crossed(t, 760, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
}
if (crossed(t, 1400, DEMO_LOOP)) {
say(TXT_SWAY);
}
if (crossed(t, 1000, DEMO_LOOP) || crossed(t, 1300, DEMO_LOOP)) {
rings.push({ x: centers[1][0], y: centers[1][1], r: 2, born: clock });
say(TXT_INTER);
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]');
if (btn) {
btn.addEventListener('click', function () {
wake();
drop(W * (0.3 + hash1(inkN + 1, 23) * 0.4), H * (0.35 + hash1(inkN + 3, 29) * 0.3));
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
const p = toScene(ev);
drop(clamp(p[0], 30, W - 30), clamp(p[1], 24, H - 24));
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'Enter' && k !== ' ') {
return;
}
ev.preventDefault();
wake();
drop(W * (0.32 + hash1(inkN + 7, 31) * 0.36), H * (0.36 + hash1(inkN + 11, 37) * 0.28));
});
settle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts) {
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);
}
if (still) {
settle();
draw();
}
else {
rafId = window.requestAnimationFrame(frame);
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
})();// MblSuminagashiInkConcentricRings - vanilla 판(09-mbl-suminagashi-ink-concentric-rings) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblSuminagashiInkConcentricRings.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblSuminagashiInkConcentricRings() {
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;
// 마블링 팔레트 — 종이 물면(PAPER) 위에 파스텔 잉크 결이 흐른다
const PAPER: RGB = [252, 246, 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];
const TINTS: RGB[] = [SKY, PINK, MINT, LILAC, PEACH];
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)');
const 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; }
}
// 시연 시계가 loop 주기 안의 at 시각을 이번 스텝에 막 지났는가
function crossed(t: number, at: number, loop: number): boolean {
const p = t % loop;
return p >= at && p - FIXED_DT < at;
}
function fitCanvas(): void {
// 캔버스 내부 해상도는 480x300 고정, 배율은 devicePixelRatio 에 상한 2 를 둔다
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];
}
// ── 필드 버퍼(077 방식) — 항목이 정한 낮은 해상도 버퍼에 픽셀 값을 굽고 drawImage 로 부드럽게 확대한다
const small = document.createElement('canvas');
let sctx: CanvasRenderingContext2D | null = null;
let buf: ImageData | null = null;
let px: Uint8ClampedArray | null = null;
// 종이 결은 시간이 지나도 안 변한다 — 필드를 만들 때 한 번만 구워 두고 픽셀마다 꺼내 쓴다
let grainF: Float32Array = new Float32Array(0);
function initField(nw: number, nh: number): void {
small.width = nw;
small.height = nh;
const c = small.getContext('2d');
if (!c) { return; }
sctx = c;
buf = c.createImageData(nw, nh);
px = buf.data;
grainF = new Float32Array(nw * nh);
for (let y = 0; y < nh; y += 1) {
for (let x = 0; x < nw; x += 1) { grainF[y * nw + x] = paperGrain(x * W / nw, y * H / nh); }
}
}
function blitField(nw: number, nh: number): void {
if (!sctx || !buf) { return; }
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, nw, nh, 0, 0, W, H);
}
// ── 결정적 값노이즈 — 난수 소스를 못 쓰므로 정수 해시로 같은 시드에 같은 값
const SEED = 463;
function hash1(n: number, seed: number): number {
let h = Math.imul(n ^ seed, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) | 0, seed);
}
function fade(t: number): number { return t * t * (3 - 2 * t); }
function vnoise2(x: number, y: number, seed: number): number {
const xi = Math.floor(x), yi = Math.floor(y);
const u = fade(x - xi), v = fade(y - yi);
const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed);
const c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm2(x: number, y: number, oct: number, seed: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < oct; i += 1) {
sum += vnoise2(x * f, y * f, seed + i * 131) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 종이 결 — 옅은 얼룩으로 종이가 느껴지게 하는 바닥 값(0~1)
function paperGrain(x: number, y: number): number {
return fbm2(x * 0.021, y * 0.021, 3, 71);
}
// 09 먹 흐름 — 시간 확산 동심원 + 반경 노이즈 흔들림, 이중심 간섭
const RING_SPEED = 0.9; // 동심원이 16ms 스텝마다 바깥으로 자라는 반경(px)
const RADIUS_NOISE = 0.16; // 반경에 흐르는 노이즈의 흔들림 진폭(비율)
const INTERFERE_K = 0.75; // 이중심 간섭 계수 — 원이 겹칠 때 밀어 세우는 힘
const RING_GAP = 26; // 동심원 이웃 고리 간격(px)
const RING_MAX_R = 320;
const DEMO_LOOP = 3600;
const FW = 192;
const FH = 120;
const TXT_SPREAD = '동심원이 바깥으로 퍼진다';
const TXT_SWAY = '반경 노이즈로 결이 흔들린다';
const TXT_INTER = '두 중심의 원이 간섭한다';
type Ring = { x: number; y: number; r: number; born: number };
let rings: Ring[] = [];
let centers: [number, number][] = [[W * 0.36, H * 0.5], [W * 0.66, H * 0.46]];
let inkN = 0;
function addCenter(x: number, y: number, t0: number): void {
centers.push([x, y]);
if (centers.length > 3) { centers.shift(); }
rings.push({ x: x, y: y, r: 2, born: t0 });
inkN += 1;
}
function ringField(x: number, y: number, now: number): number {
// 시간 확산 동심원 — 스텝마다 반경이 자라고, 이웃 고리 간격으로 파동 무늬를 이룬다
let v = 0;
let v2 = 0;
for (let i = 0; i < rings.length; i += 1) {
const rg = rings[i];
const dx = x - rg.x, dy = y - rg.y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r > rg.r + 2) { continue; }
// 반경 노이즈 — 각도마다 흐르는 노이즈로 결이 흔들린다
const ang = Math.atan2(dy, dx);
const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
const d = r - (rg.r + sway);
const w = RING_GAP * 1.3;
if (Math.abs(d) < w) {
const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
v += c;
v2 += c * c;
}
}
// 이중심 간섭 — 고리끼리 겹치는 자리에 곱 항 ((Σc)² − Σc²)/2 를 INTERFERE_K 만큼 더한다.
// 한 중심의 고리끼리는 간격(RING_GAP)만큼 떨어져 거의 안 겹치므로 이 항은 두 중심의 고리가 만나는 마디에서 선다
return v + INTERFERE_K * (v * v - v2) * 0.5;
}
function draw(): void {
if (!px || !sctx) { return; }
const now = clock;
const sx = FW / W, sy = FH / H;
let i4 = 0;
for (let y = 0; y < FH; y += 1) {
const wy = y / sy;
for (let x = 0; x < FW; x += 1) {
const wx = x / sx;
const g0 = grainF[y * FW + x];
// 먹 흐름 바닥 — 종이가 약간 우유빛으로 받쳐 준다
const base = mix(mix(PAPER, MINT, 0.07), INK, g0 * 0.07 + 0.012);
let col = base;
const v = ringField(wx, wy, now);
if (v > 0.04) {
const stepK = Math.floor(v * 4) / 4;
col = mix(base, INK, clamp(stepK * 0.82, 0, 0.92));
}
px[i4] = col[0]; px[i4 + 1] = col[1]; px[i4 + 2] = col[2]; px[i4 + 3] = 255;
i4 += 4;
}
}
blitField(FW, FH);
}
function settle(): void {
// reduce — 퍼진 상태의 고리들을 계산으로 만든다
rings = [];
for (let c = 0; c < centers.length; c += 1) {
for (let k = 0; k < 5; k += 1) {
rings.push({ x: centers[c][0], y: centers[c][1], r: 40 + k * 42, born: -1e9 });
}
}
say(TXT_SWAY);
}
function step(): void {
for (let i = 0; i < rings.length; i += 1) {
rings[i].r += RING_SPEED;
}
if (rings.length > 24) { rings.splice(0, rings.length - 24); }
if (demo) { say(TXT_SPREAD); }
}
function drop(x: number, y: number): void {
addCenter(x, y, clock);
if (still) {
settle();
draw();
return;
}
say(TXT_INTER);
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
say(TXT_SPREAD);
}
if (crossed(t, 480, DEMO_LOOP) || crossed(t, 760, DEMO_LOOP)) {
rings.push({ x: centers[0][0], y: centers[0][1], r: 2, born: clock });
}
if (crossed(t, 1400, DEMO_LOOP)) {
say(TXT_SWAY);
}
if (crossed(t, 1000, DEMO_LOOP) || crossed(t, 1300, DEMO_LOOP)) {
rings.push({ x: centers[1][0], y: centers[1][1], r: 2, born: clock });
say(TXT_INTER);
}
}
initField(FW, FH);
const btn = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btn) {
btn.addEventListener('click', function (): void {
wake();
drop(W * (0.3 + hash1(inkN + 1, 23) * 0.4), H * (0.35 + hash1(inkN + 3, 29) * 0.3));
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
const p = toScene(ev);
drop(clamp(p[0], 30, W - 30), clamp(p[1], 24, H - 24));
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'Enter' && k !== ' ') { return; }
ev.preventDefault();
wake();
drop(W * (0.32 + hash1(inkN + 7, 31) * 0.36), H * (0.36 + hash1(inkN + 11, 37) * 0.28));
});
settle();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
function frame(ts: number): void {
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);
}
if (still) {
settle();
draw();
} else {
rafId = window.requestAnimationFrame(frame);
}
return function (): void {
window.cancelAnimationFrame(rafId);
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="btn" type="button">먹물 떨어뜨리기</button>
</div>
</div>
);
}09 Concentric Rings Spreading From Centers, Swaying and Overlapping — Measured area 72.013% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Marble Effect CSS: 9 Ink, Comb and Agate Patterns
The post this demo lives in
Marble Effect CSS: 9 Ink, Comb and Agate PatternsIn this marble effect css set, ink on water is a thin film held by surface tension — 04 pushes it into skewed wave bands See all demos in this post
If you like this effect - similar demos
Creative UI05 Sketch and ink layersSignature Pad JavaScript: 9 Canvas Drawing UIs
Creative UI04 Attractor atlas — Clifford and de Jong, probabilistic flowers from millions of pointsLorenz Attractor: 9 Butterfly Chaos Demos
Creative UI03 Drag from a port to connectNode Based UI Design: 9 Patterns, No Library