04Wave Bands Pushed One Way and Set Into Chevrons
A phase-offset sine is added across the inked rows to make wave bands, and the phase keeps drifting by the pushed amount until the waves set into skewed chevrons.
Best forFooters and banner strip accents
WAVE_AMPPHASE_DRIFTCHEVRON_SKEW

<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="Wave bands pushed one way and set into chevrons"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Combing to the right</p>
<div class="hs__tools">
<button class="hs__btn" data-role="btn" type="button">Comb direction</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);
}
// 04 빗질 결 — 어긋난 위상 사인파 합성 + 민 만큼 위상 표류 누적
const WAVE_AMP = 26; // 사인파 진폭(px)
const PHASE_DRIFT = 0.022; // 16ms 스텝마다 쌓이는 위상 표류(빗이 민 만큼)
const CHEVRON_SKEW = 0.34; // 가로 위치에 비례해 띠를 비스듬히 누르는 기울기
const BAND_PX = 13; // 물결 띠 한 줄 폭(px)
const DEMO_LOOP = 3600;
const FW = 240;
const FH = 150;
const TXT_R = 'Combing to the right';
const TXT_L = 'Combing to the left';
const TXT_SET = 'The wave bands set into a skew';
const TXT_GUIDE = 'Arrow keys set the comb direction';
let drift = 0; // 누적 위상 표류
let dir = 1; // Comb direction
let saidSet = false;
let btnEl = null;
function bandAt(x, y) {
// 어긋난 위상 사인파 합성 — 줄마다 위상이 달라 물결, 가로 기울기가 셰브런을 만든다
const tilt = CHEVRON_SKEW * (x - W / 2);
const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
return (y + tilt + wave + fine) / BAND_PX;
}
function draw() {
if (!px || !sctx) {
return;
}
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 g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const b = bandAt(wx, wy);
const bi = Math.floor(b);
const fr = b - bi;
const tint = TINTS[Math.abs(bi + 40) % TINTS.length];
let col = base;
const k = smooth(1 - Math.abs(fr - 0.5) * 2);
if (bi % 2 === 0) {
col = mix(base, tint, k * 0.5 + 0.1);
if (fr < 0.16) {
col = mix(col, INK, (0.16 - fr) * 2.2);
}
}
else {
col = mix(base, INK, 0.06 + k * 0.05);
}
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() {
drift = 3.2;
say(TXT_SET);
}
function step() {
drift += dir * PHASE_DRIFT;
if (demo) {
if (!saidSet && Math.abs(drift - 3.2) < 0.05) {
saidSet = true;
say(TXT_SET);
}
else if (!saidSet) {
say(dir > 0 ? TXT_R : TXT_L);
}
}
}
function setDir(d) {
dir = d;
saidSet = false;
if (still) {
drift += d * 0.9;
draw();
return;
}
say(d > 0 ? TXT_R : TXT_L);
if (btnEl) {
btnEl.textContent = d > 0 ? 'Comb left' : 'Comb right';
}
}
function demoTick(t) {
if (crossed(t, 200, DEMO_LOOP)) {
dir = 1;
saidSet = false;
}
if (crossed(t, 1900, DEMO_LOOP)) {
dir = -1;
}
}
initField(FW, FH);
btnEl = root.querySelector('[data-role="btn"]');
if (btnEl) {
btnEl.textContent = 'Comb left';
btnEl.addEventListener('click', function () {
wake();
setDir(-dir);
});
}
cv.addEventListener('pointerdown', function (ev) {
wake();
setDir(toScene(ev)[0] < W / 2 ? -1 : 1);
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight') {
return;
}
ev.preventDefault();
wake();
setDir(k === 'ArrowRight' ? 1 : -1);
});
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);
};
})();// MblRakeWaveChevronBands - vanilla 판(04-mbl-rake-wave-chevron-bands) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MblRakeWaveChevronBands.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MblRakeWaveChevronBands() {
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);
}
// 04 빗질 결 — 어긋난 위상 사인파 합성 + 민 만큼 위상 표류 누적
const WAVE_AMP = 26; // 사인파 진폭(px)
const PHASE_DRIFT = 0.022; // 16ms 스텝마다 쌓이는 위상 표류(빗이 민 만큼)
const CHEVRON_SKEW = 0.34; // 가로 위치에 비례해 띠를 비스듬히 누르는 기울기
const BAND_PX = 13; // 물결 띠 한 줄 폭(px)
const DEMO_LOOP = 3600;
const FW = 240;
const FH = 150;
const TXT_R = '오른쪽으로 빗질하는 중';
const TXT_L = '왼쪽으로 빗질하는 중';
const TXT_SET = '물결 띠가 비스듬히 굳는다';
const TXT_GUIDE = '화살표로 빗질 방향';
let drift = 0; // 누적 위상 표류
let dir = 1; // 빗질 방향
let saidSet = false;
let btnEl: HTMLButtonElement | null = null;
function bandAt(x: number, y: number): number {
// 어긋난 위상 사인파 합성 — 줄마다 위상이 달라 물결, 가로 기울기가 셰브런을 만든다
const tilt = CHEVRON_SKEW * (x - W / 2);
const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
return (y + tilt + wave + fine) / BAND_PX;
}
function draw(): void {
if (!px || !sctx) { return; }
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 g = grainF[y * FW + x];
const base = mix(PAPER, INK, g * 0.07 + 0.015);
const b = bandAt(wx, wy);
const bi = Math.floor(b);
const fr = b - bi;
const tint = TINTS[Math.abs(bi + 40) % TINTS.length];
let col = base;
const k = smooth(1 - Math.abs(fr - 0.5) * 2);
if (bi % 2 === 0) {
col = mix(base, tint, k * 0.5 + 0.1);
if (fr < 0.16) { col = mix(col, INK, (0.16 - fr) * 2.2); }
} else {
col = mix(base, INK, 0.06 + k * 0.05);
}
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 {
drift = 3.2;
say(TXT_SET);
}
function step(): void {
drift += dir * PHASE_DRIFT;
if (demo) {
if (!saidSet && Math.abs(drift - 3.2) < 0.05) { saidSet = true; say(TXT_SET); }
else if (!saidSet) { say(dir > 0 ? TXT_R : TXT_L); }
}
}
function setDir(d: number): void {
dir = d;
saidSet = false;
if (still) { drift += d * 0.9; draw(); return; }
say(d > 0 ? TXT_R : TXT_L);
if (btnEl) { btnEl.textContent = d > 0 ? '왼쪽으로 빗질' : '오른쪽으로 빗질'; }
}
function demoTick(t: number): void {
if (crossed(t, 200, DEMO_LOOP)) { dir = 1; saidSet = false; }
if (crossed(t, 1900, DEMO_LOOP)) { dir = -1; }
}
initField(FW, FH);
btnEl = root.querySelector('[data-role="btn"]') as HTMLButtonElement | null;
if (btnEl) {
btnEl.textContent = '왼쪽으로 빗질';
btnEl.addEventListener('click', function (): void {
wake();
setDir(-dir);
});
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
setDir(toScene(ev)[0] < W / 2 ? -1 : 1);
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight') { return; }
ev.preventDefault();
wake();
setDir(k === 'ArrowRight' ? 1 : -1);
});
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>
);
}04 Wave Bands Pushed One Way and Set Into Chevrons — Measured area 71.056% · 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 UI02 Stripe bands — one parameter turns dots into lines9 Reaction Diffusion Simulations, No Library
Creative UI06 Burning Ship variant — one absolute value turns the set into a ship ablazeMandelbrot Zoom, 9 Ways (Escape-Time, No Library)
Creative UI05 A Sine Ripple With a Different Phase per ColorGlitch Text Effect: 9 Canvas Pixel Glitch Scenes