css ボタン クリック アニメーション 9選 — キャンバス計算
この記事ではcss ボタン クリック アニメーションを9種類、ピクセル計算で作ります。ボタンがガラスのようにひびが入って見える原因は、押下状態の値がオンになると伸びるひびの角度値ひとつだ。2番はその値でひびを描き、5番は同じ値を縁を走る電流の太さに使い回し、8番は押した位置からその値だけ画面を押し出す。
01 btn-canvas-ink-splash-diffuse
02 btn-canvas-crack-shatter-press
03 btn-canvas-ember-trail-drag
04 btn-canvas-liquid-metal-merge
05 btn-canvas-electric-arc-edge
06 btn-canvas-smoke-dissolve-disable
07 btn-canvas-sand-fill-progress
08 btn-canvas-ripple-refract-press
09 btn-canvas-noise-static-glitch
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 クリック地点から広がるインク拡散ボタン
- 02 押下状態の値でひびが入る亀裂線ボタン
- 03 ドラッグ速度の値で散る残り火トレイルボタン
- 04 ポインター距離の値で融合し広がるリキッドメタルボタン
- 05 ホバー強度の値が上がるほど太くなる電気アーク縁取りボタン
- 06 無効値がオンになると煙のように消えるボタン
- 07 進捗値の分だけ砂のように積もるプログレスボタン
- 08 押した座標からピクセルを押し出す屈折リップルボタン
- 09 エラー値が反転すると砂嵐のように揺れるノイズボタン
同じキャンバスループの上で、9つの定数だけが違う
9つとも同じ土台から始まります。ボタンに合わせたサイズの <canvas>、1本の requestAnimationFrame ループ、そして click・Enter/Space用の keydown・座標を取る pointerdown・resize の4つのリスナーです。パターンごとに変わるのは各デモの計算が中心に置く一つの定数だけ — 拡散率、分岐数、ジッター間隔、減衰時間 — で、ライブラリもWebGLコンテキストもCDNスクリプトもどれ一つ読み込んでいません。
01クリック地点から広がるインク拡散ボタン
クリックした地点に配列上の一マスへ高い値を書き込むと、diffuseStep() が毎フレーム呼ばれ、DIFFUSE_RATE = 0.46 で隣接マスへ値を平均化しながら、DECAY_PER_FRAME = 0.978 でグリッド全体を同時に減衰させます。render() は毎フレームそのグリッドをそのままピクセルへ描き直すので、見えているにじみはCSSのradial-gradientがサイズを変えているのではなく、グリッド自身が持つ平均化された値そのものです。

<main class="stage">
<div class="ik is-demo" id="root">
<canvas class="ik__cv" id="cv" width="480" height="300"></canvas>
<button class="ik__btn" id="btn" type="button">送信する</button>
<p class="ik__hint">ボタンを押してみてください — 押した場所からインクが広がります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue-light;
@include stage($stage-ink);
.ik {
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;
}
.ik__cv {
display: block;
width: 100%;
height: 100%;
}
.ik__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: pointer;
padding: $sp-3 $sp-6;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
box-shadow: $shadow-press;
}
.ik__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.ik__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.ik__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);
}
.ik {
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);
}
.ik__cv {
display: block;
width: 100%;
height: 100%;
}
.ik__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: pointer;
padding: 12px 24px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ik__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.ik__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.ik__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var W = 480, H = 300;
var COLS = 60, ROWS = 38;
var CELL_W = W / COLS, CELL_H = H / ROWS;
var SEED = 10102;
var rnd = makeLcg(SEED);
var DIFFUSE_RATE = 0.46; // 이웃 평균으로 끌려가는 비율 — 클수록 빨리 번진다
var DECAY_PER_FRAME = 0.978; // 프레임마다 농도가 줄어드는 비율
var INK_COLOR = [91, 140, 248]; // $subject-blue-light
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var field = new Float32Array(COLS * ROWS);
var next = new Float32Array(COLS * ROWS);
function idx(gx, gy) { return gy * COLS + gx; }
function injectAt(px, py, rIn) {
var gx = Math.max(0, Math.min(COLS - 1, Math.round(px / CELL_W)));
var gy = Math.max(0, Math.min(ROWS - 1, Math.round(py / CELL_H)));
var r = rIn || 4;
for (var dy = -r; dy <= r; dy++) {
for (var dx = -r; dx <= r; dx++) {
var x = gx + dx, y = gy + dy;
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) continue;
var d = Math.sqrt(dx * dx + dy * dy);
if (d > r) continue;
var v = 1 - d / (r + 1);
var i = idx(x, y);
field[i] = Math.max(field[i], v);
}
}
}
function diffuseStep() {
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
var i = idx(x, y);
var sum = 0, n = 0;
if (x > 0) { sum += field[idx(x - 1, y)]; n++; }
if (x < COLS - 1) { sum += field[idx(x + 1, y)]; n++; }
if (y > 0) { sum += field[idx(x, y - 1)]; n++; }
if (y < ROWS - 1) { sum += field[idx(x, y + 1)]; n++; }
var avg = n ? sum / n : field[i];
var v = field[i] + (avg - field[i]) * DIFFUSE_RATE;
next[i] = v * DECAY_PER_FRAME;
}
}
var tmp = field; field = next; next = tmp;
}
function render() {
ctx.fillStyle = '#17141a';
ctx.fillRect(0, 0, W, H);
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
var v = field[idx(x, y)];
if (v < 0.02) continue;
var a = Math.min(1, v);
ctx.fillStyle = 'rgba(' + INK_COLOR[0] + ',' + INK_COLOR[1] + ',' + INK_COLOR[2] + ',' + a.toFixed(3) + ')';
ctx.fillRect(x * CELL_W, y * CELL_H, CELL_W + 1, CELL_H + 1);
}
}
}
function press(px, py) {
root.classList.remove('is-demo');
injectAt(px, py, 5);
injectAt(px + 70, py - 40, 3);
injectAt(px - 80, py + 30, 3);
if (reduceMQ.matches) { render(); }
}
function pointFromEvent(e) {
var r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
}
btn.addEventListener('click', function () {
var r = btn.getBoundingClientRect(), c = cv.getBoundingClientRect();
var cx = (r.left + r.width / 2 - c.left) * (W / c.width);
var cy = (r.top + r.height / 2 - c.top) * (H / c.height);
press(cx, cy);
});
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
var r = btn.getBoundingClientRect(), c = cv.getBoundingClientRect();
press((r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height));
});
cv.addEventListener('pointerdown', function (e) {
var p = pointFromEvent(e);
press(p[0], p[1]);
});
window.addEventListener('resize', function () { render(); });
var autoStart = null;
var nextAutoAt = 0;
function syntheticPoint(k) {
var a = rnd() * Math.PI * 2, r2 = 40 + rnd() * 170;
return [W / 2 + Math.cos(a) * r2, H / 2 + Math.sin(a) * r2 - k * 0];
}
function frame(now) {
if (!reduceMQ.matches) {
if (autoStart === null) autoStart = now;
var t = now - autoStart;
if (root.classList.contains('is-demo') && t >= nextAutoAt) {
var p = syntheticPoint(t);
injectAt(p[0], p[1], 4);
nextAutoAt = t + 260;
}
diffuseStep();
render();
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
injectAt(W / 2, H / 2, 5);
render();
} else {
injectAt(W / 2, H / 2, 6);
injectAt(W / 2 - 130, H / 2 + 60, 4);
injectAt(W / 2 + 140, H / 2 - 55, 4);
requestAnimationFrame(frame);
}
})();// IkSplashButton — vanilla 판(run/404/demo/01-btn-canvas-ink-splash-diffuse/index.html) 의 React 포트.
// 클릭 좌표를 중심으로 확산 필드(field)에 값을 주입하고, 매 프레임 이웃 평균으로 끌리는 확산 계산을 돌려
// 캔버스에 격자 블록으로 그린다. 값·상수는 vanilla 와 같다.
import { useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./IkSplashButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const W = 480, H = 300;
const COLS = 60, ROWS = 38;
const CELL_W = W / COLS, CELL_H = H / ROWS;
const DIFFUSE_RATE = 0.46;
const DECAY_PER_FRAME = 0.978;
const INK_COLOR: [number, number, number] = [91, 140, 248];
const SEED = 10102;
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function idx(gx: number, gy: number): number {
return gy * COLS + gx;
}
export default function IkSplashButton() {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const fieldRef = useRef<Float32Array>(new Float32Array(COLS * ROWS)) as Box<Float32Array>;
const nextRef = useRef<Float32Array>(new Float32Array(COLS * ROWS)) as Box<Float32Array>;
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const autoStartRef = useRef<number | null>(null) as Box<number | null>;
const nextAutoRef = useRef<number>(0) as Box<number>;
function injectAt(px: number, py: number, rIn?: number): void {
const field = fieldRef.current;
const gx = Math.max(0, Math.min(COLS - 1, Math.round(px / CELL_W)));
const gy = Math.max(0, Math.min(ROWS - 1, Math.round(py / CELL_H)));
const r = rIn || 4;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = gx + dx, y = gy + dy;
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) continue;
const d = Math.sqrt(dx * dx + dy * dy);
if (d > r) continue;
const v = 1 - d / (r + 1);
const i = idx(x, y);
field[i] = Math.max(field[i], v);
}
}
}
function diffuseStep(): void {
const field = fieldRef.current;
const next = nextRef.current;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const i = idx(x, y);
let sum = 0, n = 0;
if (x > 0) { sum += field[idx(x - 1, y)]; n++; }
if (x < COLS - 1) { sum += field[idx(x + 1, y)]; n++; }
if (y > 0) { sum += field[idx(x, y - 1)]; n++; }
if (y < ROWS - 1) { sum += field[idx(x, y + 1)]; n++; }
const avg = n ? sum / n : field[i];
const v = field[i] + (avg - field[i]) * DIFFUSE_RATE;
next[i] = v * DECAY_PER_FRAME;
}
}
fieldRef.current = next;
nextRef.current = field;
}
function render(): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const field = fieldRef.current;
ctx.fillStyle = "#17141a";
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const v = field[idx(x, y)];
if (v < 0.02) continue;
const a = Math.min(1, v);
ctx.fillStyle = "rgba(" + INK_COLOR[0] + "," + INK_COLOR[1] + "," + INK_COLOR[2] + "," + a.toFixed(3) + ")";
ctx.fillRect(x * CELL_W, y * CELL_H, CELL_W + 1, CELL_H + 1);
}
}
}
function press(px: number, py: number): void {
demoRef.current = false;
injectAt(px, py, 5);
injectAt(px + 70, py - 40, 3);
injectAt(px - 80, py + 30, 3);
if (reduceRef.current) render();
}
function pointFromClient(cx: number, cy: number): [number, number] {
const cv = cvRef.current;
if (!cv) return [0, 0];
const r = cv.getBoundingClientRect();
return [(cx - r.left) * (W / r.width), (cy - r.top) * (H / r.height)];
}
function pressAtButtonCenter(): void {
const btn = btnRef.current;
const cv = cvRef.current;
if (!btn || !cv) return;
const r = btn.getBoundingClientRect();
const c = cv.getBoundingClientRect();
press((r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height));
}
function onPointerDown(e: ReactPointerEvent<HTMLCanvasElement>): void {
const p = pointFromClient(e.clientX, e.clientY);
press(p[0], p[1]);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
pressAtButtonCenter();
}
function syntheticPoint(): [number, number] {
const rnd = rndRef.current;
const a = rnd() * Math.PI * 2;
const r2 = 40 + rnd() * 170;
return [W / 2 + Math.cos(a) * r2, H / 2 + Math.sin(a) * r2];
}
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
injectAt(W / 2, H / 2, 5);
render();
return;
}
injectAt(W / 2, H / 2, 6);
injectAt(W / 2 - 130, H / 2 + 60, 4);
injectAt(W / 2 + 140, H / 2 - 55, 4);
render();
function frame(now: number): void {
if (autoStartRef.current === null) autoStartRef.current = now;
const t = now - (autoStartRef.current as number);
if (demoRef.current && t >= nextAutoRef.current) {
const p = syntheticPoint();
injectAt(p[0], p[1], 4);
nextAutoRef.current = t + 260;
}
diffuseStep();
render();
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.ik} ref={rootRef}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={onPointerDown} />
<button className={styles.btn} ref={btnRef} type="button" onClick={pressAtButtonCenter} onKeyDown={onKeyDown}>
제출하기
</button>
<p className={styles.hint}>버튼을 눌러 보세요 — 누른 자리에서 잉크가 번집니다</p>
</div>
</main>
);
}01 · クリック地点から広がるインク拡散ボタン — 計測 累積面積 29.288% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function diffuseStep() {
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
var i = idx(x, y);
var sum = 0, n = 0;
if (x > 0) { sum += field[idx(x - 1, y)]; n++; }
if (x < COLS - 1) { sum += field[idx(x + 1, y)]; n++; }
if (y > 0) { sum += field[idx(x, y - 1)]; n++; }
if (y < ROWS - 1) { sum += field[idx(x, y + 1)]; n++; }
var avg = n ? sum / n : field[i];
var v = field[i] + (avg - field[i]) * DIFFUSE_RATE;
next[i] = v * DECAY_PER_FRAME;
}
}
var tmp = field; field = next; next = tmp;
}
02押下状態の値でひびが入る亀裂線ボタン
押下状態の値がオンになった瞬間、buildTree() が BTN_CANVAS_CRACK_BRANCH_COUNT(既定6)回だけ genBranch() を呼び出し、各呼び出しは CRACK_MAX_DEPTH = 3 まで再帰しながら CRACK_ANGLE_SPREAD で枝分かれし CRACK_LEN_DECAY で世代ごとに縮んでいきます。それぞれの枝は自分専用の t0/t1 を持つので、ひびは一瞬でなく数百ミリ秒かけて外へ伸びていくように見えます。

<main class="stage">
<div class="cc is-demo" id="root">
<canvas class="cc__cv" id="cv" width="480" height="300"></canvas>
<button class="cc__btn" id="btn" type="button">削除する</button>
<p class="cc__hint">ボタンを押すと中心からひびが枝分かれして伸びます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.cc {
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;
}
.cc__cv {
display: block;
width: 100%;
height: 100%;
}
.cc__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: pointer;
padding: $sp-3 $sp-6;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
box-shadow: $shadow-press;
}
.cc__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.cc__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.cc__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cc {
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);
}
.cc__cv {
display: block;
width: 100%;
height: 100%;
}
.cc__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: pointer;
padding: 12px 24px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cc__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.cc__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.cc__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var W = 480, H = 300;
var COLS = 60, ROWS = 38;
var CELL_W = W / COLS, CELL_H = H / ROWS;
var SEED = 20205;
var rnd = makeLcg(SEED);
var BTN_CANVAS_CRACK_BRANCH_COUNT = 6; // 중심에서 뻗는 1차 균열 가지 수 — 변형 칩이 바꾸는 값
var CRACK_MAX_DEPTH = 3; // 가지가 다시 갈라지는 깊이
var CRACK_SEG_LEN = 44; // 1차 가지 길이(px)
var CRACK_LEN_DECAY = 0.6; // 갈라질 때마다 길이가 줄어드는 비율
var CRACK_ANGLE_SPREAD = 0.85; // 두 자식 가지가 벌어지는 각도(rad)
var GLOW_DECAY = 0.90; // 균열 앞머리 잔광이 사그라드는 비율
var MAX_TREES = 3; // 화면에 동시에 남겨 둘 균열 나무 수
var LINE_COLOR = [23, 20, 26]; // $subject-ink
var GLOW_COLOR = [255, 247, 230]; // $subject-cream
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var field = new Float32Array(COLS * ROWS);
function fidx(gx, gy) { return gy * COLS + gx; }
function stampGlow(px, py, r) {
var gx = Math.round(px / CELL_W), gy = Math.round(py / CELL_H);
for (var dy = -r; dy <= r; dy++) {
for (var dx = -r; dx <= r; dx++) {
var x = gx + dx, y = gy + dy;
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) continue;
var d = Math.sqrt(dx * dx + dy * dy);
if (d > r) continue;
var v = 1 - d / (r + 1);
var i = fidx(x, y);
field[i] = Math.min(1, field[i] + v * 0.6);
}
}
}
function genBranch(x, y, angle, len, depth, t0, segs) {
if (depth > CRACK_MAX_DEPTH || len < 5) return;
var ang = angle + (rnd() - 0.5) * 0.35;
var x2 = x + Math.cos(ang) * len;
var y2 = y + Math.sin(ang) * len;
var dur = 120 + len * 4;
segs.push({ x1: x, y1: y, x2: x2, y2: y2, t0: t0, t1: t0 + dur, w: Math.max(1, 3 - depth) });
var childT0 = t0 + dur * 0.6;
var childA = [ang + CRACK_ANGLE_SPREAD * 0.5, ang - CRACK_ANGLE_SPREAD * 0.5];
for (var i = 0; i < childA.length; i++) {
genBranch(x2, y2, childA[i] + (rnd() - 0.5) * 0.3, len * CRACK_LEN_DECAY, depth + 1, childT0, segs);
}
}
function buildTree(cx, cy) {
var segs = [];
for (var i = 0; i < BTN_CANVAS_CRACK_BRANCH_COUNT; i++) {
var base = (Math.PI * 2 * i) / BTN_CANVAS_CRACK_BRANCH_COUNT;
var ang = base + (rnd() - 0.5) * 0.5;
genBranch(cx, cy, ang, CRACK_SEG_LEN, 0, 0, segs);
}
return segs;
}
var trees = [];
function paint(now) {
for (var i = 0; i < field.length; i++) field[i] *= GLOW_DECAY;
var lineDraws = [];
for (var t = 0; t < trees.length; t++) {
var tree = trees[t];
var elapsed = now - tree.t0;
for (var s = 0; s < tree.segs.length; s++) {
var seg = tree.segs[s];
if (elapsed < seg.t0) continue;
var progress = Math.max(0, Math.min(1, (elapsed - seg.t0) / (seg.t1 - seg.t0)));
var tipx = seg.x1 + (seg.x2 - seg.x1) * progress;
var tipy = seg.y1 + (seg.y2 - seg.y1) * progress;
if (progress < 1) stampGlow(tipx, tipy, 3);
lineDraws.push([seg.x1, seg.y1, tipx, tipy, seg.w]);
}
}
ctx.fillStyle = '#ffd23f';
ctx.fillRect(0, 0, W, H);
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
var v = field[fidx(x, y)];
if (v < 0.03) continue;
var a = Math.min(1, v);
ctx.fillStyle = 'rgba(' + GLOW_COLOR[0] + ',' + GLOW_COLOR[1] + ',' + GLOW_COLOR[2] + ',' + a.toFixed(3) + ')';
ctx.fillRect(x * CELL_W, y * CELL_H, CELL_W + 1, CELL_H + 1);
}
}
ctx.strokeStyle = 'rgba(' + LINE_COLOR[0] + ',' + LINE_COLOR[1] + ',' + LINE_COLOR[2] + ',.92)';
ctx.lineCap = 'round';
for (var d = 0; d < lineDraws.length; d++) {
var ln = lineDraws[d];
ctx.lineWidth = ln[4];
ctx.beginPath();
ctx.moveTo(ln[0], ln[1]);
ctx.lineTo(ln[2], ln[3]);
ctx.stroke();
}
}
function press(px, py) {
root.classList.remove('is-demo');
trees.push({ segs: buildTree(px, py), t0: performance.now() });
if (trees.length > MAX_TREES) trees.shift();
if (reduceMQ.matches) {
trees[trees.length - 1].t0 = performance.now() - 999999;
paint(performance.now());
}
}
function pointFromEvent(e) {
var r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
}
function buttonCenter() {
var r = btn.getBoundingClientRect(), c = cv.getBoundingClientRect();
return [(r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height)];
}
btn.addEventListener('click', function () { var p = buttonCenter(); press(p[0], p[1]); });
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
var p = buttonCenter(); press(p[0], p[1]);
});
cv.addEventListener('pointerdown', function (e) { var p = pointFromEvent(e); press(p[0], p[1]); });
window.addEventListener('resize', function () { paint(performance.now()); });
var nextAutoAt = 0;
function frame(now) {
if (!reduceMQ.matches) {
if (root.classList.contains('is-demo') && now >= nextAutoAt) {
var p = buttonCenter();
trees.push({ segs: buildTree(p[0], p[1]), t0: now });
if (trees.length > MAX_TREES) trees.shift();
nextAutoAt = now + 640;
}
paint(now);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
var c0 = buttonCenter();
trees.push({ segs: buildTree(c0[0], c0[1]), t0: performance.now() - 999999 });
paint(performance.now());
} else {
var c1 = buttonCenter();
trees.push({ segs: buildTree(c1[0], c1[1]), t0: performance.now() });
requestAnimationFrame(frame);
}
})();// CrackShatterButton — vanilla 판(run/404/demo/02-btn-canvas-crack-shatter-press/index.html) 의 React 포트.
// 누른 좌표를 중심으로 균열 가지 나무(재귀 분기)를 만들어 시간에 따라 자라게 그린다. 값·상수는 vanilla 와 같다.
import { useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./CrackShatterButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Segment = { x1: number; y1: number; x2: number; y2: number; t0: number; t1: number; w: number };
type Tree = { segs: Segment[]; t0: number };
const W = 480, H = 300;
const COLS = 60, ROWS = 38;
const CELL_W = W / COLS, CELL_H = H / ROWS;
const SEED = 20205;
const BTN_CANVAS_CRACK_BRANCH_COUNT = 6;
const CRACK_MAX_DEPTH = 3;
const CRACK_SEG_LEN = 44;
const CRACK_LEN_DECAY = 0.6;
const CRACK_ANGLE_SPREAD = 0.85;
const GLOW_DECAY = 0.90;
const MAX_TREES = 3;
const LINE_COLOR: [number, number, number] = [23, 20, 26];
const GLOW_COLOR: [number, number, number] = [255, 247, 230];
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function fidx(gx: number, gy: number): number {
return gy * COLS + gx;
}
export default function CrackShatterButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const fieldRef = useRef<Float32Array>(new Float32Array(COLS * ROWS)) as Box<Float32Array>;
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const treesRef = useRef<Tree[]>([]) as Box<Tree[]>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const nextAutoRef = useRef<number>(0) as Box<number>;
function stampGlow(px: number, py: number, r: number): void {
const field = fieldRef.current;
const gx = Math.round(px / CELL_W), gy = Math.round(py / CELL_H);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = gx + dx, y = gy + dy;
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) continue;
const d = Math.sqrt(dx * dx + dy * dy);
if (d > r) continue;
const v = 1 - d / (r + 1);
const i = fidx(x, y);
field[i] = Math.min(1, field[i] + v * 0.6);
}
}
}
function genBranch(x: number, y: number, angle: number, len: number, depth: number, t0: number, segs: Segment[]): void {
if (depth > CRACK_MAX_DEPTH || len < 5) return;
const rnd = rndRef.current;
const ang = angle + (rnd() - 0.5) * 0.35;
const x2 = x + Math.cos(ang) * len;
const y2 = y + Math.sin(ang) * len;
const dur = 120 + len * 4;
segs.push({ x1: x, y1: y, x2: x2, y2: y2, t0: t0, t1: t0 + dur, w: Math.max(1, 3 - depth) });
const childT0 = t0 + dur * 0.6;
const childA = [ang + CRACK_ANGLE_SPREAD * 0.5, ang - CRACK_ANGLE_SPREAD * 0.5];
for (let i = 0; i < childA.length; i++) {
genBranch(x2, y2, childA[i] + (rnd() - 0.5) * 0.3, len * CRACK_LEN_DECAY, depth + 1, childT0, segs);
}
}
function buildTree(cx: number, cy: number): Segment[] {
const rnd = rndRef.current;
const segs: Segment[] = [];
for (let i = 0; i < BTN_CANVAS_CRACK_BRANCH_COUNT; i++) {
const base = (Math.PI * 2 * i) / BTN_CANVAS_CRACK_BRANCH_COUNT;
const ang = base + (rnd() - 0.5) * 0.5;
genBranch(cx, cy, ang, CRACK_SEG_LEN, 0, 0, segs);
}
return segs;
}
function paint(now: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const field = fieldRef.current;
for (let i = 0; i < field.length; i++) field[i] *= GLOW_DECAY;
const trees = treesRef.current;
const lineDraws: [number, number, number, number, number][] = [];
for (let t = 0; t < trees.length; t++) {
const tree = trees[t];
const elapsed = now - tree.t0;
for (let s = 0; s < tree.segs.length; s++) {
const seg = tree.segs[s];
if (elapsed < seg.t0) continue;
const progress = Math.max(0, Math.min(1, (elapsed - seg.t0) / (seg.t1 - seg.t0)));
const tipx = seg.x1 + (seg.x2 - seg.x1) * progress;
const tipy = seg.y1 + (seg.y2 - seg.y1) * progress;
if (progress < 1) stampGlow(tipx, tipy, 3);
lineDraws.push([seg.x1, seg.y1, tipx, tipy, seg.w]);
}
}
ctx.fillStyle = "#ffd23f";
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const v = field[fidx(x, y)];
if (v < 0.03) continue;
const a = Math.min(1, v);
ctx.fillStyle = "rgba(" + GLOW_COLOR[0] + "," + GLOW_COLOR[1] + "," + GLOW_COLOR[2] + "," + a.toFixed(3) + ")";
ctx.fillRect(x * CELL_W, y * CELL_H, CELL_W + 1, CELL_H + 1);
}
}
ctx.strokeStyle = "rgba(" + LINE_COLOR[0] + "," + LINE_COLOR[1] + "," + LINE_COLOR[2] + ",.92)";
ctx.lineCap = "round";
for (let d = 0; d < lineDraws.length; d++) {
const ln = lineDraws[d];
ctx.lineWidth = ln[4];
ctx.beginPath();
ctx.moveTo(ln[0], ln[1]);
ctx.lineTo(ln[2], ln[3]);
ctx.stroke();
}
}
function pushTree(px: number, py: number, t0: number): void {
const trees = treesRef.current;
trees.push({ segs: buildTree(px, py), t0: t0 });
if (trees.length > MAX_TREES) trees.shift();
}
function press(px: number, py: number): void {
demoRef.current = false;
pushTree(px, py, performance.now());
if (reduceRef.current) {
const trees = treesRef.current;
trees[trees.length - 1].t0 = performance.now() - 999999;
paint(performance.now());
}
}
function pointFromClient(cx: number, cy: number): [number, number] {
const cv = cvRef.current;
if (!cv) return [0, 0];
const r = cv.getBoundingClientRect();
return [(cx - r.left) * (W / r.width), (cy - r.top) * (H / r.height)];
}
function buttonCenter(): [number, number] {
const btn = btnRef.current;
const cv = cvRef.current;
if (!btn || !cv) return [W / 2, H / 2];
const r = btn.getBoundingClientRect();
const c = cv.getBoundingClientRect();
return [(r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height)];
}
function pressAtButtonCenter(): void {
const p = buttonCenter();
press(p[0], p[1]);
}
function onPointerDown(e: ReactPointerEvent<HTMLCanvasElement>): void {
const p = pointFromClient(e.clientX, e.clientY);
press(p[0], p[1]);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
pressAtButtonCenter();
}
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const c0 = buttonCenter();
if (reduceRef.current) {
pushTree(c0[0], c0[1], performance.now() - 999999);
paint(performance.now());
return;
}
pushTree(c0[0], c0[1], performance.now());
function frame(now: number): void {
if (demoRef.current && now >= nextAutoRef.current) {
const p = buttonCenter();
pushTree(p[0], p[1], now);
nextAutoRef.current = now + 640;
}
paint(now);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.cc}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={onPointerDown} />
<button className={styles.btn} ref={btnRef} type="button" onClick={pressAtButtonCenter} onKeyDown={onKeyDown}>
삭제하기
</button>
<p className={styles.hint}>버튼을 누르면 중심에서 균열이 갈라져 뻗습니다</p>
</div>
</main>
);
}02 · 押下状態の値でひびが入る亀裂線ボタン — 計測 累積面積 20.131% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function genBranch(x, y, angle, len, depth, t0, segs) {
if (depth > CRACK_MAX_DEPTH || len < 5) return;
var ang = angle + (rnd() - 0.5) * 0.35;
var x2 = x + Math.cos(ang) * len;
var y2 = y + Math.sin(ang) * len;
var dur = 120 + len * 4;
segs.push({ x1: x, y1: y, x2: x2, y2: y2, t0: t0, t1: t0 + dur, w: Math.max(1, 3 - depth) });
var childT0 = t0 + dur * 0.6;
var childA = [ang + CRACK_ANGLE_SPREAD * 0.5, ang - CRACK_ANGLE_SPREAD * 0.5];
for (var i = 0; i < childA.length; i++) {
genBranch(x2, y2, childA[i] + (rnd() - 0.5) * 0.3, len * CRACK_LEN_DECAY, depth + 1, childT0, segs);
}
}
function buildTree(cx, cy) {
var segs = [];
for (var i = 0; i < BTN_CANVAS_CRACK_BRANCH_COUNT; i++) {
var base = (Math.PI * 2 * i) / BTN_CANVAS_CRACK_BRANCH_COUNT;
var ang = base + (rnd() - 0.5) * 0.5;
genBranch(cx, cy, ang, CRACK_SEG_LEN, 0, 0, segs);
}
return segs;
}
03ドラッグ速度の値で散る残り火トレイルボタン
dragTo() は直前フレームからの移動距離を速度の値に変え、spawnEmbers() はその速度を Math.round(speed / 26)(1〜9に制限)個の粒子数へ変換し、ポインターが動き続ける限り毎フレーム発生させます。生成された残り火はそれぞれ固有の初速を持ち、GRAVITY = 520 と AIR_DRAG = 0.988 が働いて速度を落としながら消えていきます。

<main class="stage">
<div class="et is-demo" id="root">
<canvas class="et__cv" id="cv" width="480" height="300"></canvas>
<button class="et__btn" id="btn" type="button">送信する</button>
<p class="et__hint">ボタンをドラッグしてみてください — 速くドラッグするほど残り火が多く散ります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.et {
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;
}
.et__cv {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.et__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: grab;
padding: $sp-3 $sp-6;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-size: 16px;
font-weight: 700;
box-shadow: $shadow-press;
}
.et__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.et__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.et__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.et {
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);
}
.et__cv {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.et__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: grab;
padding: 12px 24px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.et__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.et__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.et__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var W = 480, H = 300;
var SEED = 30309;
var rnd = makeLcg(SEED);
var GRAVITY = 520; // px/s^2 — 잔불을 아래로 끌어내리는 중력 가속도
var AIR_DRAG = 0.988; // 프레임마다 속도에 곱해지는 공기 저항
var EMBER_LIFE_MIN = 620; // ms
var EMBER_LIFE_SPAN = 620;
var EMBER_HOT = [255, 214, 120];
var EMBER_COOL = [196, 40, 10];
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var particles = [];
var dragging = false;
var lastX = null, lastY = null, lastT = null;
function spawnEmbers(x, y, speed, now) {
var count = Math.max(1, Math.min(9, Math.round(speed / 26)));
for (var i = 0; i < count; i++) {
var ang = rnd() * Math.PI * 2;
var spd = 24 + rnd() * 96;
particles.push({
x: x, y: y,
vx: Math.cos(ang) * spd * 0.34,
vy: Math.sin(ang) * spd * 0.34 - 34 - rnd() * 46,
born: now,
life: EMBER_LIFE_MIN + rnd() * EMBER_LIFE_SPAN,
size: 2.6 + rnd() * 3.2
});
}
}
function stepParticles(now, dt) {
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
var age = now - p.born;
if (age > p.life) { particles.splice(i, 1); continue; }
p.vy += GRAVITY * dt;
p.vx *= AIR_DRAG;
p.vy *= AIR_DRAG;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
}
function render(now) {
ctx.fillStyle = '#ff4d1f';
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var age = Math.max(0, now - p.born);
var frac = Math.max(0, Math.min(1, age / p.life));
var r = EMBER_HOT[0] + (EMBER_COOL[0] - EMBER_HOT[0]) * frac;
var g = EMBER_HOT[1] + (EMBER_COOL[1] - EMBER_HOT[1]) * frac;
var b = EMBER_HOT[2] + (EMBER_COOL[2] - EMBER_HOT[2]) * frac;
var a = 1 - frac;
ctx.beginPath();
ctx.fillStyle = 'rgba(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ',' + a.toFixed(3) + ')';
ctx.arc(p.x, p.y, p.size * (1 - frac * 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
function pointFromEvent(e) {
var r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
}
function buttonCenter() {
var r = btn.getBoundingClientRect(), c = cv.getBoundingClientRect();
return [(r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height)];
}
function dragTo(x, y, now) {
if (lastX !== null) {
var dt = Math.max(1, now - lastT);
var dist = Math.hypot(x - lastX, y - lastY);
var speed = dist / dt * 1000;
spawnEmbers(x, y, speed, now);
}
lastX = x; lastY = y; lastT = now;
}
function press(px, py) {
root.classList.remove('is-demo');
lastX = null; lastY = null; lastT = null;
spawnEmbers(px, py, 240, performance.now());
if (reduceMQ.matches) render(performance.now());
}
btn.addEventListener('click', function () { var p = buttonCenter(); press(p[0], p[1]); });
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
var p = buttonCenter(); press(p[0], p[1]);
});
cv.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
cv.setPointerCapture(e.pointerId);
var p = pointFromEvent(e);
lastX = null; lastY = null; lastT = null;
dragTo(p[0], p[1], performance.now());
if (reduceMQ.matches) render(performance.now());
});
cv.addEventListener('pointermove', function (e) {
if (!dragging) return;
var p = pointFromEvent(e);
dragTo(p[0], p[1], performance.now());
if (reduceMQ.matches) render(performance.now());
});
window.addEventListener('pointerup', function () { dragging = false; });
window.addEventListener('pointercancel', function () { dragging = false; });
window.addEventListener('resize', function () { render(performance.now()); });
function pathPoint(t) {
var cx = W / 2, cy = H / 2;
return [cx + Math.sin(t * 0.0026) * 190, cy + Math.sin(t * 0.0044 + 1) * 108];
}
var autoStart = null;
function frame(now) {
if (!reduceMQ.matches) {
if (autoStart === null) autoStart = now;
var dt = lastT === null ? 0.016 : Math.min(0.05, (now - lastT) / 1000);
if (root.classList.contains('is-demo')) {
var t = now - autoStart;
var p = pathPoint(t);
dragTo(p[0], p[1], now);
}
stepParticles(now, dt);
render(now);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
var t0 = performance.now();
var pts = [pathPoint(0), pathPoint(120), pathPoint(240)];
for (var i = 0; i < pts.length; i++) spawnEmbers(pts[i][0], pts[i][1], 200, t0 - 260 + i * 90);
render(t0);
} else {
var p0 = pathPoint(0);
dragTo(p0[0], p0[1], performance.now());
requestAnimationFrame(frame);
}
})();// EmberTrailButton — vanilla 판(run/404/demo/03-btn-canvas-ember-trail-drag/index.html) 의 React 포트.
// 드래그(또는 자동 경로)의 이동 속도를 계산해 그 속도에 비례한 개수의 잔불 입자를 만들고,
// 중력·공기저항으로 매 프레임 물리 업데이트하며 나이에 따라 뜨거운 색에서 식은 색으로 보간해 그린다.
import { useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./EmberTrailButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Ember = { x: number; y: number; vx: number; vy: number; born: number; life: number; size: number };
const W = 480, H = 300;
const SEED = 30309;
const GRAVITY = 520;
const AIR_DRAG = 0.988;
const EMBER_LIFE_MIN = 620;
const EMBER_LIFE_SPAN = 620;
const EMBER_HOT: [number, number, number] = [255, 214, 120];
const EMBER_COOL: [number, number, number] = [196, 40, 10];
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
export default function EmberTrailButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const particlesRef = useRef<Ember[]>([]) as Box<Ember[]>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const lastRef = useRef<{ x: number | null; y: number | null; t: number | null }>({ x: null, y: null, t: null }) as Box<{ x: number | null; y: number | null; t: number | null }>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const autoStartRef = useRef<number | null>(null) as Box<number | null>;
function spawnEmbers(x: number, y: number, speed: number, now: number): void {
const rnd = rndRef.current;
const particles = particlesRef.current;
const count = Math.max(1, Math.min(9, Math.round(speed / 26)));
for (let i = 0; i < count; i++) {
const ang = rnd() * Math.PI * 2;
const spd = 24 + rnd() * 96;
particles.push({
x: x, y: y,
vx: Math.cos(ang) * spd * 0.34,
vy: Math.sin(ang) * spd * 0.34 - 34 - rnd() * 46,
born: now,
life: EMBER_LIFE_MIN + rnd() * EMBER_LIFE_SPAN,
size: 2.6 + rnd() * 3.2,
});
}
}
function stepParticles(now: number, dt: number): void {
const particles = particlesRef.current;
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
const age = now - p.born;
if (age > p.life) { particles.splice(i, 1); continue; }
p.vy += GRAVITY * dt;
p.vx *= AIR_DRAG;
p.vy *= AIR_DRAG;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
}
function render(now: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ff4d1f";
ctx.fillRect(0, 0, W, H);
const particles = particlesRef.current;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const age = Math.max(0, now - p.born);
const frac = Math.max(0, Math.min(1, age / p.life));
const r = EMBER_HOT[0] + (EMBER_COOL[0] - EMBER_HOT[0]) * frac;
const g = EMBER_HOT[1] + (EMBER_COOL[1] - EMBER_HOT[1]) * frac;
const b = EMBER_HOT[2] + (EMBER_COOL[2] - EMBER_HOT[2]) * frac;
const a = 1 - frac;
ctx.beginPath();
ctx.fillStyle = "rgba(" + (r | 0) + "," + (g | 0) + "," + (b | 0) + "," + a.toFixed(3) + ")";
ctx.arc(p.x, p.y, p.size * (1 - frac * 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
function pointFromClient(cx: number, cy: number): [number, number] {
const cv = cvRef.current;
if (!cv) return [0, 0];
const r = cv.getBoundingClientRect();
return [(cx - r.left) * (W / r.width), (cy - r.top) * (H / r.height)];
}
function buttonCenter(): [number, number] {
const btn = btnRef.current;
const cv = cvRef.current;
if (!btn || !cv) return [W / 2, H / 2];
const r = btn.getBoundingClientRect();
const c = cv.getBoundingClientRect();
return [(r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height)];
}
function dragTo(x: number, y: number, now: number): void {
const last = lastRef.current;
if (last.x !== null && last.y !== null && last.t !== null) {
const dt = Math.max(1, now - last.t);
const dist = Math.hypot(x - last.x, y - last.y);
const speed = (dist / dt) * 1000;
spawnEmbers(x, y, speed, now);
}
last.x = x; last.y = y; last.t = now;
}
function press(px: number, py: number): void {
demoRef.current = false;
const last = lastRef.current;
last.x = null; last.y = null; last.t = null;
spawnEmbers(px, py, 240, performance.now());
if (reduceRef.current) render(performance.now());
}
function pressAtButtonCenter(): void {
const p = buttonCenter();
press(p[0], p[1]);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
pressAtButtonCenter();
}
function onPointerDown(e: ReactPointerEvent<HTMLCanvasElement>): void {
draggingRef.current = true;
demoRef.current = false;
const cv = cvRef.current;
if (cv) { try { cv.setPointerCapture(e.pointerId); } catch (err) { /* noop */ } }
const p = pointFromClient(e.clientX, e.clientY);
const last = lastRef.current;
last.x = null; last.y = null; last.t = null;
dragTo(p[0], p[1], performance.now());
if (reduceRef.current) render(performance.now());
}
function onPointerMove(e: ReactPointerEvent<HTMLCanvasElement>): void {
if (!draggingRef.current) return;
const p = pointFromClient(e.clientX, e.clientY);
dragTo(p[0], p[1], performance.now());
if (reduceRef.current) render(performance.now());
}
function onPointerUp(): void {
draggingRef.current = false;
}
function pathPoint(t: number): [number, number] {
const cx = W / 2, cy = H / 2;
return [cx + Math.sin(t * 0.0021) * 150, cy + Math.sin(t * 0.0037 + 1) * 82];
}
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
const t0 = performance.now();
const pts = [pathPoint(0), pathPoint(120), pathPoint(240)];
for (let i = 0; i < pts.length; i++) spawnEmbers(pts[i][0], pts[i][1], 200, t0 - 260 + i * 90);
render(t0);
return;
}
const p0 = pathPoint(0);
dragTo(p0[0], p0[1], performance.now());
function frame(now: number): void {
if (autoStartRef.current === null) autoStartRef.current = now;
const last = lastRef.current;
const dt = last.t === null ? 0.016 : Math.min(0.05, (now - last.t) / 1000);
const t = now - (autoStartRef.current as number);
if (demoRef.current) {
const p = pathPoint(t);
dragTo(p[0], p[1], now);
}
stepParticles(now, dt);
render(now);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.et}>
<canvas
className={styles.cv}
ref={cvRef}
width={W}
height={H}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
<button className={styles.btn} ref={btnRef} type="button" onClick={pressAtButtonCenter} onKeyDown={onKeyDown}>
전송하기
</button>
<p className={styles.hint}>버튼을 끌어 보세요 — 빠르게 끌수록 잔불이 많이 흩어집니다</p>
</div>
</main>
);
}03 · ドラッグ速度の値で散る残り火トレイルボタン — 計測 累積面積 28.606% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function spawnEmbers(x, y, speed, now) {
var count = Math.max(1, Math.min(9, Math.round(speed / 26)));
for (var i = 0; i < count; i++) {
var ang = rnd() * Math.PI * 2;
var spd = 24 + rnd() * 96;
particles.push({
x: x, y: y,
vx: Math.cos(ang) * spd * 0.34,
vy: Math.sin(ang) * spd * 0.34 - 34 - rnd() * 46,
born: now,
life: EMBER_LIFE_MIN + rnd() * EMBER_LIFE_SPAN,
size: 2.6 + rnd() * 3.2
});
}
}
04ポインター距離の値で融合し広がるリキッドメタルボタン
BLOB_COUNT = 7 個の粒それぞれが自分の半径を持ち、fieldAt() はキャンバス上の任意の点について7個分の (effR * effR) / dist2 を合計し、ポインターが近いと POINTER_BOOST、クリック直後は BURST_BOOST でその値を押し上げます。合計した場の値が THRESH = 1.0 を超えた場所同士は、7つの円ではなく1つの融合面として描かれます。

<main class="stage">
<div class="lm is-demo" id="root">
<canvas class="lm__cv" id="cv" width="480" height="300"></canvas>
<button class="lm__btn" id="btn" type="button">送信</button>
<p class="lm__hint">ボタンの上でカーソルを動かすと雫が一つに集まります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.lm {
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;
}
.lm__cv {
display: block;
width: 100%;
height: 100%;
}
.lm__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
background: transparent;
cursor: pointer;
padding: $sp-3 $sp-6;
font-size: 16px;
font-weight: 700;
color: $subject-cream;
text-shadow: 0 1px 3px rgba(10, 20, 60, .45);
}
.lm__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 3px;
border-radius: $r-control;
}
.lm__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, .82);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.lm__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);
}
.lm {
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);
}
.lm__cv {
display: block;
width: 100%;
height: 100%;
}
.lm__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
background: transparent;
cursor: pointer;
padding: 12px 24px;
font-size: 16px;
font-weight: 700;
color: #fff7e6;
text-shadow: 0 1px 3px rgba(10, 20, 60, 0.45);
}
.lm__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 3px;
border-radius: 12px;
}
.lm__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, 0.82);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.lm__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var W = 480, H = 300;
var SEED = 40404;
var rnd = makeLcg(SEED);
var GRID_STEP = 4;
var GRID_W = Math.floor(W / GRID_STEP);
var GRID_H = Math.floor(H / GRID_STEP);
var BLOB_COUNT = 7;
var R_BASE = 32;
var POINTER_BOOST = 1.5; // 포인터 거리 값 → 세기 값으로 바뀔 때의 최대 배수
var POINTER_FALLOFF = 112; // px — 이 거리만큼 멀어지면 세기 값이 절반 아래로 줄어든다
var BURST_BOOST = 1.7; // 클릭 시 전체 방울에 한 번에 더해지는 세기 값
var BURST_MS = 520;
var THRESH = 1.0; // 세기 값을 합친 장(場)이 이 문턱을 넘으면 하나로 뭉쳐 보인다
var EDGE = 0.42;
var HILITE_SPAN = 1.15;
var METAL_DARK = [10, 79, 232];
var METAL_LIGHT = [186, 209, 255];
var PAPER_BG = [255, 255, 255];
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var small = document.createElement('canvas');
small.width = GRID_W; small.height = GRID_H;
var sctx = small.getContext('2d');
var img = sctx.createImageData(GRID_W, GRID_H);
var blobs = [];
(function buildBlobs() {
var cx0 = W / 2, cy0 = H / 2;
var rx = 152, ry = 66;
for (var i = 0; i < BLOB_COUNT; i++) {
var ang = rnd() * Math.PI * 2;
var rad = 0.32 + rnd() * 0.68;
blobs.push({
x: cx0 + Math.cos(ang) * rx * rad,
y: cy0 + Math.sin(ang) * ry * rad,
r: R_BASE * (0.72 + rnd() * 0.56)
});
}
})();
var pointer = null; // 실제 포인터 좌표 {x,y}
var burstUntil = 0; // 클릭 버스트가 끝나는 시각
var burstFrom = 0;
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function fieldAt(px, py, now) {
var burstT = now < burstUntil ? clamp01((burstUntil - now) / BURST_MS) : 0;
var burstBoost = burstT * BURST_BOOST;
var sum = 0;
for (var i = 0; i < blobs.length; i++) {
var b = blobs[i];
var boost = burstBoost;
if (pointer) {
var dpx = pointer.x - b.x, dpy = pointer.y - b.y;
var distToPointer = Math.sqrt(dpx * dpx + dpy * dpy);
boost += POINTER_BOOST * Math.exp(-distToPointer / POINTER_FALLOFF);
}
var effR = b.r * (1 + boost);
var dx = px - b.x, dy = py - b.y;
var dist2 = dx * dx + dy * dy + 1;
sum += (effR * effR) / dist2;
}
return sum;
}
function render(now) {
var data = img.data;
var p = 0;
for (var gy = 0; gy < GRID_H; gy++) {
var py = gy * GRID_STEP + GRID_STEP / 2;
for (var gx = 0; gx < GRID_W; gx++) {
var px = gx * GRID_STEP + GRID_STEP / 2;
var f = fieldAt(px, py, now);
var alpha = clamp01((f - (THRESH - EDGE)) / (2 * EDGE));
var hi = clamp01((f - THRESH) / HILITE_SPAN);
var r = METAL_DARK[0] + (METAL_LIGHT[0] - METAL_DARK[0]) * hi;
var g = METAL_DARK[1] + (METAL_LIGHT[1] - METAL_DARK[1]) * hi;
var b2 = METAL_DARK[2] + (METAL_LIGHT[2] - METAL_DARK[2]) * hi;
data[p] = r | 0; data[p + 1] = g | 0; data[p + 2] = b2 | 0; data[p + 3] = (alpha * 255) | 0;
p += 4;
}
}
sctx.putImageData(img, 0, 0);
ctx.fillStyle = 'rgb(' + PAPER_BG[0] + ',' + PAPER_BG[1] + ',' + PAPER_BG[2] + ')';
ctx.fillRect(0, 0, W, H);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(small, 0, 0, GRID_W, GRID_H, 0, 0, W, H);
}
function pointFromEvent(e) {
var r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * (W / r.width), (e.clientY - r.top) * (H / r.height)];
}
function buttonCenter() {
var r = btn.getBoundingClientRect(), c = cv.getBoundingClientRect();
return [(r.left + r.width / 2 - c.left) * (W / c.width), (r.top + r.height / 2 - c.top) * (H / c.height)];
}
function press() {
root.classList.remove('is-demo');
burstFrom = performance.now();
burstUntil = burstFrom + BURST_MS;
if (reduceMQ.matches) render(burstFrom);
}
btn.addEventListener('click', press);
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
press();
});
cv.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
var p = pointFromEvent(e);
pointer = { x: p[0], y: p[1] };
if (reduceMQ.matches) render(performance.now());
});
cv.addEventListener('pointermove', function (e) {
if (root.classList.contains('is-demo')) return;
var p = pointFromEvent(e);
pointer = { x: p[0], y: p[1] };
if (reduceMQ.matches) render(performance.now());
});
cv.addEventListener('pointerleave', function () {
pointer = null;
if (reduceMQ.matches) render(performance.now());
});
window.addEventListener('resize', function () { render(performance.now()); });
function pathPoint(t) {
var cx = W / 2, cy = H / 2;
return {
x: cx + Math.sin(t * 0.0031) * 168 + Math.sin(t * 0.021) * 10,
y: cy + Math.sin(t * 0.0052 + 1) * 74 + Math.cos(t * 0.017) * 8
};
}
var autoStart = null;
function frame(now) {
if (!reduceMQ.matches) {
if (autoStart === null) autoStart = now;
if (root.classList.contains('is-demo')) {
var t = now - autoStart;
pointer = pathPoint(t);
}
render(now);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
pointer = pathPoint(180);
render(performance.now());
} else {
requestAnimationFrame(frame);
}
})();// LiquidMetalButton — vanilla 판(run/404/demo/04-btn-canvas-liquid-metal-merge/index.html) 의 React 포트.
// 방울마다 포인터까지의 거리를 세기 값으로 바꾸고, 그 세기 값을 합친 장(場)을 그리드 단위로 계산해
// 문턱을 넘는 자리끼리 색이 이어지도록 렌더한다(메타볼).
import { useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./LiquidMetalButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Blob = { x: number; y: number; r: number };
type Pointer = { x: number; y: number };
const W = 480, H = 300;
const SEED = 40404;
const GRID_STEP = 4;
const GRID_W = Math.floor(W / GRID_STEP);
const GRID_H = Math.floor(H / GRID_STEP);
const BLOB_COUNT = 7;
const R_BASE = 32;
const POINTER_BOOST = 1.5;
const POINTER_FALLOFF = 112;
const BURST_BOOST = 1.7;
const BURST_MS = 520;
const THRESH = 1.0;
const EDGE = 0.42;
const HILITE_SPAN = 1.15;
const METAL_DARK: [number, number, number] = [10, 79, 232];
const METAL_LIGHT: [number, number, number] = [186, 209, 255];
const PAPER_BG: [number, number, number] = [255, 255, 255];
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function buildBlobs(rnd: () => number): Blob[] {
const list: Blob[] = [];
const cx0 = W / 2, cy0 = H / 2;
const rx = 152, ry = 66;
for (let i = 0; i < BLOB_COUNT; i++) {
const ang = rnd() * Math.PI * 2;
const rad = 0.32 + rnd() * 0.68;
list.push({
x: cx0 + Math.cos(ang) * rx * rad,
y: cy0 + Math.sin(ang) * ry * rad,
r: R_BASE * (0.72 + rnd() * 0.56),
});
}
return list;
}
export default function LiquidMetalButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const smallRef = useRef<HTMLCanvasElement | null>(null) as Box<HTMLCanvasElement | null>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const blobsRef = useRef<Blob[]>(buildBlobs(makeLcg(SEED))) as Box<Blob[]>;
const pointerRef = useRef<Pointer | null>(null) as Box<Pointer | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const burstUntilRef = useRef<number>(0) as Box<number>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const autoStartRef = useRef<number | null>(null) as Box<number | null>;
function fieldAt(px: number, py: number, now: number): number {
const blobs = blobsRef.current;
const pointer = pointerRef.current;
const burstUntil = burstUntilRef.current;
const burstT = now < burstUntil ? clamp01((burstUntil - now) / BURST_MS) : 0;
const burstBoost = burstT * BURST_BOOST;
let sum = 0;
for (let i = 0; i < blobs.length; i++) {
const b = blobs[i];
let boost = burstBoost;
if (pointer) {
const dpx = pointer.x - b.x, dpy = pointer.y - b.y;
const distToPointer = Math.sqrt(dpx * dpx + dpy * dpy);
boost += POINTER_BOOST * Math.exp(-distToPointer / POINTER_FALLOFF);
}
const effR = b.r * (1 + boost);
const dx = px - b.x, dy = py - b.y;
const dist2 = dx * dx + dy * dy + 1;
sum += (effR * effR) / dist2;
}
return sum;
}
function render(now: number): void {
const cv = cvRef.current;
const small = smallRef.current;
const img = imgRef.current;
if (!cv || !small || !img) return;
const ctx = cv.getContext("2d");
const sctx = small.getContext("2d");
if (!ctx || !sctx) return;
const data = img.data;
let p = 0;
for (let gy = 0; gy < GRID_H; gy++) {
const py = gy * GRID_STEP + GRID_STEP / 2;
for (let gx = 0; gx < GRID_W; gx++) {
const px = gx * GRID_STEP + GRID_STEP / 2;
const f = fieldAt(px, py, now);
const alpha = clamp01((f - (THRESH - EDGE)) / (2 * EDGE));
const hi = clamp01((f - THRESH) / HILITE_SPAN);
const r = METAL_DARK[0] + (METAL_LIGHT[0] - METAL_DARK[0]) * hi;
const g = METAL_DARK[1] + (METAL_LIGHT[1] - METAL_DARK[1]) * hi;
const b2 = METAL_DARK[2] + (METAL_LIGHT[2] - METAL_DARK[2]) * hi;
data[p] = r | 0; data[p + 1] = g | 0; data[p + 2] = b2 | 0; data[p + 3] = (alpha * 255) | 0;
p += 4;
}
}
sctx.putImageData(img, 0, 0);
ctx.fillStyle = "rgb(" + PAPER_BG[0] + "," + PAPER_BG[1] + "," + PAPER_BG[2] + ")";
ctx.fillRect(0, 0, W, H);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(small, 0, 0, GRID_W, GRID_H, 0, 0, W, H);
}
function pointFromClient(cx: number, cy: number): [number, number] {
const cv = cvRef.current;
if (!cv) return [0, 0];
const r = cv.getBoundingClientRect();
return [(cx - r.left) * (W / r.width), (cy - r.top) * (H / r.height)];
}
function press(): void {
demoRef.current = false;
const from = performance.now();
burstUntilRef.current = from + BURST_MS;
if (reduceRef.current) render(from);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
press();
}
function onPointerDown(e: ReactPointerEvent<HTMLCanvasElement>): void {
demoRef.current = false;
const p = pointFromClient(e.clientX, e.clientY);
pointerRef.current = { x: p[0], y: p[1] };
if (reduceRef.current) render(performance.now());
}
function onPointerMove(e: ReactPointerEvent<HTMLCanvasElement>): void {
if (demoRef.current) return;
const p = pointFromClient(e.clientX, e.clientY);
pointerRef.current = { x: p[0], y: p[1] };
if (reduceRef.current) render(performance.now());
}
function onPointerLeave(): void {
pointerRef.current = null;
if (reduceRef.current) render(performance.now());
}
function pathPoint(t: number): Pointer {
const cx = W / 2, cy = H / 2;
return {
x: cx + Math.sin(t * 0.0031) * 168 + Math.sin(t * 0.021) * 10,
y: cy + Math.sin(t * 0.0052 + 1) * 74 + Math.cos(t * 0.017) * 8,
};
}
useEffect(() => {
const small = document.createElement("canvas");
small.width = GRID_W; small.height = GRID_H;
smallRef.current = small;
const sctx = small.getContext("2d");
imgRef.current = sctx ? sctx.createImageData(GRID_W, GRID_H) : null;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
pointerRef.current = pathPoint(180);
render(performance.now());
return;
}
function frame(now: number): void {
if (autoStartRef.current === null) autoStartRef.current = now;
if (demoRef.current) {
const t = now - (autoStartRef.current as number);
pointerRef.current = pathPoint(t);
}
render(now);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.lm}>
<canvas
className={styles.cv}
ref={cvRef}
width={W}
height={H}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
/>
<button className={styles.btn} ref={btnRef} type="button" onClick={press} onKeyDown={onKeyDown}>
전송
</button>
<p className={styles.hint}>버튼 위로 커서를 움직이면 방울이 하나로 뭉칩니다</p>
</div>
</main>
);
}04 · ポインター距離の値で融合し広がるリキッドメタルボタン — 計測 累積面積 51.592% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function fieldAt(px, py, now) {
var burstT = now < burstUntil ? clamp01((burstUntil - now) / BURST_MS) : 0;
var burstBoost = burstT * BURST_BOOST;
var sum = 0;
for (var i = 0; i < blobs.length; i++) {
var b = blobs[i];
var boost = burstBoost;
if (pointer) {
var dpx = pointer.x - b.x, dpy = pointer.y - b.y;
var distToPointer = Math.sqrt(dpx * dpx + dpy * dpy);
boost += POINTER_BOOST * Math.exp(-distToPointer / POINTER_FALLOFF);
}
var effR = b.r * (1 + boost);
var dx = px - b.x, dy = py - b.y;
var dist2 = dx * dx + dy * dy + 1;
sum += (effR * effR) / dist2;
}
return sum;
}
05ホバー強度の値が上がるほど太くなる電気アーク縁取りボタン
pathPoints() がボタンの角丸の縁をたどり、BTN_CANVAS_ARC_JITTER_MS(既定70ms)ごとに ARC_SEGMENTS = 6 個のアンカーそれぞれへ新しいランダムなずれが入るので、線は滑らかに補間されるのではなく震えているように見えます。ジッター幅を広げる同じ強度の値が CRACK_VALUE_ECHO として線幅の計算にも使われるため、強くなるほど震えも線も同時に太くなります。

<main class="stage">
<div class="ae is-demo" id="root">
<canvas class="ae__cv" id="cv" width="480" height="300"></canvas>
<button class="ae__btn" id="btn" type="button">始める</button>
<p class="ae__hint">ボタンにカーソルを乗せると縁が電流のように震えます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-light;
@include stage($stage-ink);
.ae {
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;
}
.ae__cv {
display: block;
width: 100%;
height: 100%;
}
.ae__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: pointer;
padding: $sp-3 $sp-6;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
}
.ae__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ae__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(23, 20, 26, .72);
color: $subject-cream;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.ae__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);
}
.ae {
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);
}
.ae__cv {
display: block;
width: 100%;
height: 100%;
}
.ae__btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
cursor: pointer;
padding: 12px 24px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
}
.ae__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ae__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.ae__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var W = 480, H = 300;
var SEED = 50510;
var rnd = makeLcg(SEED);
// 02번(btn-canvas-crack-shatter-press)의 균열 각도 값(BTN_CANVAS_CRACK_BRANCH_COUNT = 6)을
// 이 편의 "테두리를 도는 전류 굵기" 계산에 그대로 재사용한다 (훅 참고).
var CRACK_VALUE_ECHO = 6;
var BTN_CANVAS_ARC_JITTER_MS = 70; // 앵커 하나가 새 값으로 튀기까지 걸리는 시간
var ARC_SEGMENTS = 6; // 테두리를 나누는 앵커 개수 — 02번 균열 가지 개수와 같다
var SUBDIV = 12; // 앵커 사이를 매끄럽게 잇는 보간 점 수
var JITTER_AMP_MAX = 28; // px — 세기 값 1.0 일 때 앵커가 튀는 최대 폭
var GLOW_DECAY = 0.90;
var GLOW_CELL = 6;
var GLOW_COLS = Math.floor(W / GLOW_CELL);
var GLOW_ROWS = Math.floor(H / GLOW_CELL);
var GLOW_COLOR = [110, 206, 255];
var BG = [23, 20, 26];
var RX = 158, RY = 68;
var CX = W / 2, CY = H / 2;
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var field = new Float32Array(GLOW_COLS * GLOW_ROWS);
var small = document.createElement('canvas');
small.width = GLOW_COLS; small.height = GLOW_ROWS;
var sctx = small.getContext('2d');
var img = sctx.createImageData(GLOW_COLS, GLOW_ROWS);
var anchors = [];
for (var ai = 0; ai < ARC_SEGMENTS; ai++) anchors.push(0);
var intensity = 0, targetIntensity = 0;
var lastJitter = 0;
var lastT = null;
function stampGlow(px, py, strength) {
var gx = Math.floor(px / GLOW_CELL), gy = Math.floor(py / GLOW_CELL);
for (var oy = -2; oy <= 2; oy++) {
for (var ox = -2; ox <= 2; ox++) {
var xx = gx + ox, yy = gy + oy;
if (xx < 0 || yy < 0 || xx >= GLOW_COLS || yy >= GLOW_ROWS) continue;
var d = Math.sqrt(ox * ox + oy * oy);
var w = d === 0 ? 1 : Math.max(0, 1 - d / 2.6);
field[yy * GLOW_COLS + xx] += strength * w;
}
}
}
function pathPoints() {
var pts = [];
for (var i = 0; i < ARC_SEGMENTS; i++) {
var a0 = anchors[i], a1 = anchors[(i + 1) % ARC_SEGMENTS];
for (var s = 0; s < SUBDIV; s++) {
var u = s / SUBDIV;
var w = (1 - Math.cos(u * Math.PI)) / 2; // 코사인 보간 — 앵커 사이를 매끄럽게
var jitter = a0 + (a1 - a0) * w;
var ang = ((i + u) / ARC_SEGMENTS) * Math.PI * 2;
var rr = 1 + jitter / ((RX + RY) / 2);
pts.push({
x: CX + Math.cos(ang) * RX * rr,
y: CY + Math.sin(ang) * RY * rr
});
}
}
return pts;
}
function render(now, dt) {
intensity += (targetIntensity - intensity) * Math.min(1, dt * 7);
if (now - lastJitter >= BTN_CANVAS_ARC_JITTER_MS) {
lastJitter = now;
for (var i = 0; i < ARC_SEGMENTS; i++) {
anchors[i] = (rnd() - 0.5) * 2 * JITTER_AMP_MAX * (0.12 + intensity);
}
}
for (var f = 0; f < field.length; f++) field[f] *= GLOW_DECAY;
var pts = pathPoints();
for (var p = 0; p < pts.length; p++) {
stampGlow(pts[p].x, pts[p].y, 0.62 + intensity * 2.0);
}
var data = img.data, idx = 0;
for (var gy = 0; gy < GLOW_ROWS; gy++) {
for (var gx = 0; gx < GLOW_COLS; gx++) {
var v = Math.min(1, field[gy * GLOW_COLS + gx] / 2.4);
data[idx] = GLOW_COLOR[0]; data[idx + 1] = GLOW_COLOR[1]; data[idx + 2] = GLOW_COLOR[2];
data[idx + 3] = (v * 255) | 0;
idx += 4;
}
}
sctx.putImageData(img, 0, 0);
ctx.fillStyle = 'rgb(' + BG[0] + ',' + BG[1] + ',' + BG[2] + ')';
ctx.fillRect(0, 0, W, H);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(small, 0, 0, GLOW_COLS, GLOW_ROWS, 0, 0, W, H);
var lineWidth = 1 + intensity * CRACK_VALUE_ECHO;
ctx.beginPath();
for (var q = 0; q < pts.length; q++) {
if (q === 0) ctx.moveTo(pts[q].x, pts[q].y); else ctx.lineTo(pts[q].x, pts[q].y);
}
ctx.closePath();
ctx.lineWidth = lineWidth;
ctx.strokeStyle = 'rgba(' + GLOW_COLOR[0] + ',' + GLOW_COLOR[1] + ',' + GLOW_COLOR[2] + ',' + (0.55 + intensity * 0.45).toFixed(3) + ')';
ctx.stroke();
}
function setHover(on) {
targetIntensity = on ? 1 : 0;
}
btn.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); setHover(true); if (reduceMQ.matches) render(performance.now(), 0.05); });
btn.addEventListener('pointerleave', function () { setHover(false); if (reduceMQ.matches) render(performance.now(), 0.05); });
btn.addEventListener('click', function () { root.classList.remove('is-demo'); setHover(true); if (reduceMQ.matches) render(performance.now(), 0.05); });
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
root.classList.remove('is-demo');
setHover(true);
if (reduceMQ.matches) render(performance.now(), 0.05);
});
cv.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
setHover(true);
if (reduceMQ.matches) render(performance.now(), 0.05);
});
window.addEventListener('resize', function () { render(performance.now(), 0.016); });
function frame(now) {
if (!reduceMQ.matches) {
var dt = lastT === null ? 0.016 : Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (root.classList.contains('is-demo')) {
var t = now % 2400;
targetIntensity = t < 1400 ? Math.min(1, t / 500) : Math.max(0, 1 - (t - 1400) / 700);
}
render(now, dt);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
targetIntensity = 0.6;
intensity = 0.6;
for (var i0 = 0; i0 < ARC_SEGMENTS; i0++) anchors[i0] = (rnd() - 0.5) * 2 * JITTER_AMP_MAX * 0.6;
render(performance.now(), 0.05);
} else {
requestAnimationFrame(frame);
}
})();// ElectricArcButton — vanilla 판(run/404/demo/05-btn-canvas-electric-arc-edge/index.html) 의 React 포트.
// 02번 크랙 데모의 균열 각도 값(6)을 전류 선 굵기 계산에 재사용하고, 호버 세기 값이 오르내리는 만큼
// 테두리 앵커가 튀는 폭과 아크 글로우 필드의 밝기가 함께 커진다.
import { useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ElectricArcButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Pt = { x: number; y: number };
const W = 480, H = 300;
const SEED = 50510;
// 02번(btn-canvas-crack-shatter-press)의 BTN_CANVAS_CRACK_BRANCH_COUNT(=6) 값을
// 이 편의 "테두리를 도는 전류 굵기" 계산에 그대로 재사용한다 (훅 참고).
const CRACK_VALUE_ECHO = 6;
const BTN_CANVAS_ARC_JITTER_MS = 70;
const ARC_SEGMENTS = 6;
const SUBDIV = 12;
const JITTER_AMP_MAX = 28;
const GLOW_DECAY = 0.90;
const GLOW_CELL = 6;
const GLOW_COLS = Math.floor(W / GLOW_CELL);
const GLOW_ROWS = Math.floor(H / GLOW_CELL);
const GLOW_COLOR: [number, number, number] = [110, 206, 255];
const BG: [number, number, number] = [23, 20, 26];
const RX = 158, RY = 68;
const CX = W / 2, CY = H / 2;
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
export default function ElectricArcButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const smallRef = useRef<HTMLCanvasElement | null>(null) as Box<HTMLCanvasElement | null>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const fieldRef = useRef<Float32Array>(new Float32Array(GLOW_COLS * GLOW_ROWS)) as Box<Float32Array>;
const anchorsRef = useRef<number[]>(new Array(ARC_SEGMENTS).fill(0)) as Box<number[]>;
const intensityRef = useRef<number>(0) as Box<number>;
const targetRef = useRef<number>(0) as Box<number>;
const lastJitterRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
function stampGlow(px: number, py: number, strength: number): void {
const field = fieldRef.current;
const gx = Math.floor(px / GLOW_CELL), gy = Math.floor(py / GLOW_CELL);
for (let oy = -2; oy <= 2; oy++) {
for (let ox = -2; ox <= 2; ox++) {
const xx = gx + ox, yy = gy + oy;
if (xx < 0 || yy < 0 || xx >= GLOW_COLS || yy >= GLOW_ROWS) continue;
const d = Math.sqrt(ox * ox + oy * oy);
const w = d === 0 ? 1 : Math.max(0, 1 - d / 2.6);
field[yy * GLOW_COLS + xx] += strength * w;
}
}
}
function pathPoints(): Pt[] {
const anchors = anchorsRef.current;
const pts: Pt[] = [];
for (let i = 0; i < ARC_SEGMENTS; i++) {
const a0 = anchors[i], a1 = anchors[(i + 1) % ARC_SEGMENTS];
for (let s = 0; s < SUBDIV; s++) {
const u = s / SUBDIV;
const w = (1 - Math.cos(u * Math.PI)) / 2;
const jitter = a0 + (a1 - a0) * w;
const ang = ((i + u) / ARC_SEGMENTS) * Math.PI * 2;
const rr = 1 + jitter / ((RX + RY) / 2);
pts.push({ x: CX + Math.cos(ang) * RX * rr, y: CY + Math.sin(ang) * RY * rr });
}
}
return pts;
}
function render(now: number, dt: number): void {
const cv = cvRef.current;
const small = smallRef.current;
const img = imgRef.current;
if (!cv || !small || !img) return;
const ctx = cv.getContext("2d");
const sctx = small.getContext("2d");
if (!ctx || !sctx) return;
const rnd = rndRef.current;
const anchors = anchorsRef.current;
const field = fieldRef.current;
intensityRef.current += (targetRef.current - intensityRef.current) * Math.min(1, dt * 7);
const intensity = intensityRef.current;
if (now - lastJitterRef.current >= BTN_CANVAS_ARC_JITTER_MS) {
lastJitterRef.current = now;
for (let i = 0; i < ARC_SEGMENTS; i++) {
anchors[i] = (rnd() - 0.5) * 2 * JITTER_AMP_MAX * (0.12 + intensity);
}
}
for (let f = 0; f < field.length; f++) field[f] *= GLOW_DECAY;
const pts = pathPoints();
for (let p = 0; p < pts.length; p++) {
stampGlow(pts[p].x, pts[p].y, 0.62 + intensity * 2.0);
}
const data = img.data;
let idx = 0;
for (let gy = 0; gy < GLOW_ROWS; gy++) {
for (let gx = 0; gx < GLOW_COLS; gx++) {
const v = Math.min(1, field[gy * GLOW_COLS + gx] / 2.4);
data[idx] = GLOW_COLOR[0]; data[idx + 1] = GLOW_COLOR[1]; data[idx + 2] = GLOW_COLOR[2];
data[idx + 3] = (v * 255) | 0;
idx += 4;
}
}
sctx.putImageData(img, 0, 0);
ctx.fillStyle = "rgb(" + BG[0] + "," + BG[1] + "," + BG[2] + ")";
ctx.fillRect(0, 0, W, H);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(small, 0, 0, GLOW_COLS, GLOW_ROWS, 0, 0, W, H);
const lineWidth = 1 + intensity * CRACK_VALUE_ECHO;
ctx.beginPath();
for (let q = 0; q < pts.length; q++) {
if (q === 0) ctx.moveTo(pts[q].x, pts[q].y); else ctx.lineTo(pts[q].x, pts[q].y);
}
ctx.closePath();
ctx.lineWidth = lineWidth;
ctx.strokeStyle = "rgba(" + GLOW_COLOR[0] + "," + GLOW_COLOR[1] + "," + GLOW_COLOR[2] + "," + (0.55 + intensity * 0.45).toFixed(3) + ")";
ctx.stroke();
}
function setHover(on: boolean): void {
targetRef.current = on ? 1 : 0;
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
demoRef.current = false;
setHover(true);
if (reduceRef.current) render(performance.now(), 0.05);
}
function onPointerEnter(): void {
demoRef.current = false;
setHover(true);
if (reduceRef.current) render(performance.now(), 0.05);
}
function onPointerLeave(): void {
setHover(false);
if (reduceRef.current) render(performance.now(), 0.05);
}
function onClick(): void {
demoRef.current = false;
setHover(true);
if (reduceRef.current) render(performance.now(), 0.05);
}
function onPointerDown(_e: ReactPointerEvent<HTMLCanvasElement>): void {
demoRef.current = false;
setHover(true);
if (reduceRef.current) render(performance.now(), 0.05);
}
useEffect(() => {
const small = document.createElement("canvas");
small.width = GLOW_COLS; small.height = GLOW_ROWS;
smallRef.current = small;
const sctx = small.getContext("2d");
imgRef.current = sctx ? sctx.createImageData(GLOW_COLS, GLOW_ROWS) : null;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
const rnd = rndRef.current;
targetRef.current = 0.6;
intensityRef.current = 0.6;
const anchors = anchorsRef.current;
for (let i = 0; i < ARC_SEGMENTS; i++) anchors[i] = (rnd() - 0.5) * 2 * JITTER_AMP_MAX * 0.6;
render(performance.now(), 0.05);
return;
}
function frame(now: number): void {
const last = lastTRef.current;
const dt = last === null ? 0.016 : Math.min(0.05, (now - last) / 1000);
lastTRef.current = now;
if (demoRef.current) {
const t = now % 2400;
targetRef.current = t < 1400 ? Math.min(1, t / 500) : Math.max(0, 1 - (t - 1400) / 700);
}
render(now, dt);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.ae}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={onPointerDown} />
<button
className={styles.btn}
ref={btnRef}
type="button"
onClick={onClick}
onKeyDown={onKeyDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
시작하기
</button>
<p className={styles.hint}>버튼에 커서를 올리면 테두리가 전류처럼 떨립니다</p>
</div>
</main>
);
}05 · ホバー強度の値が上がるほど太くなる電気アーク縁取りボタン — 計測 累積面積 23.585% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
intensity += (targetIntensity - intensity) * Math.min(1, dt * 7);
if (now - lastJitter >= BTN_CANVAS_ARC_JITTER_MS) {
lastJitter = now;
for (var i = 0; i < ARC_SEGMENTS; i++) {
anchors[i] = (rnd() - 0.5) * 2 * JITTER_AMP_MAX * (0.12 + intensity);
}
}
06無効値がオンになると煙のように消えるボタン
無効値がオンになった瞬間、ボタン自身のピクセルがあらかじめ shards[] 配列へサンプリングされており、render() は assembly の値を ASSEMBLE_SPEED = 2.6 で0へイージングしながら、各破片を自分専用の移動方向と小さな上昇量に沿って動かします。無効値を戻すと同じ assembly のイージングが逆方向に働くので、破片は新しいスプライトではなく元のボタンへ組み直されます。

<main class="stage">
<div class="sd is-demo" id="root">
<canvas class="sd__cv" id="cv" width="480" height="300"></canvas>
<button class="sd__btn" id="btn" type="button">購入する</button>
<p class="sd__hint">ボタンを押すと品切れ処理され、煙のように消えます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.sd {
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;
}
.sd__cv {
display: block;
width: 100%;
height: 100%;
}
.sd__btn {
position: absolute;
left: 50%;
top: 50%;
width: calc(224 / 480 * 100%);
height: calc(68 / 300 * 100%);
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: $r-control;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.sd__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.sd__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, .72);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.sd__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sd {
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);
}
.sd__cv {
display: block;
width: 100%;
height: 100%;
}
.sd__btn {
position: absolute;
left: 50%;
top: 50%;
width: 46.6666666667%;
height: 22.6666666667%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: 12px;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.sd__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.sd__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, 0.72);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.sd__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var W = 480, H = 300;
var SEED = 60611;
var rnd = makeLcg(SEED);
var BW = 224, BH = 68;
var BX = (W - BW) / 2, BY = (H - BH) / 2;
var CELL = 7;
var COLS = Math.ceil(BW / CELL), ROWS = Math.ceil(BH / CELL);
var ASSEMBLE_SPEED = 2.6; // 초당 조립도(0~1)가 목표를 따라가는 속도
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
// 버튼을 한 번 오프스크린에 그려 실제 픽셀을 격자 단위로 표본한다.
var off = document.createElement('canvas');
off.width = BW; off.height = BH;
var octx = off.getContext('2d');
octx.fillStyle = '#17141a';
roundRect(octx, 0, 0, BW, BH, 12);
octx.fill();
octx.fillStyle = '#fff7e6';
octx.font = '700 18px system-ui, sans-serif';
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillText('購入する', BW / 2, BH / 2 + 1);
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
var offData = octx.getImageData(0, 0, BW, BH).data;
var shards = [];
for (var gy = 0; gy < ROWS; gy++) {
for (var gx = 0; gx < COLS; gx++) {
var sx = gx * CELL, sy = gy * CELL;
var sw = Math.min(CELL, BW - sx), sh = Math.min(CELL, BH - sy);
if (sw <= 0 || sh <= 0) continue;
var cxp = Math.min(BW - 1, sx + (sw / 2 | 0));
var cyp = Math.min(BH - 1, sy + (sh / 2 | 0));
var pi = (cyp * BW + cxp) * 4;
var a = offData[pi + 3];
if (a < 10) continue;
var ang = rnd() * Math.PI * 2;
var driftMag = 26 + rnd() * 54;
shards.push({
ox: BX + sx, oy: BY + sy, w: sw, h: sh,
r: offData[pi], g: offData[pi + 1], b: offData[pi + 2],
dirX: Math.cos(ang) * driftMag,
dirY: Math.sin(ang) * driftMag - 40 - rnd() * 46,
spin: (rnd() - 0.5) * 2.4,
delay: rnd() * 0.28
});
}
}
var assembly = 1, target = 1;
function render(dt) {
assembly += (target - assembly) * Math.min(1, dt * ASSEMBLE_SPEED);
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < shards.length; i++) {
var s = shards[i];
var local = Math.max(0, Math.min(1, (assembly - s.delay) / (1 - s.delay)));
var scatter = 1 - local;
var px = s.ox + s.dirX * scatter * scatter;
var py = s.oy + s.dirY * scatter * scatter - scatter * 18;
var alpha = local;
if (alpha <= 0.01) continue;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(px + s.w / 2, py + s.h / 2);
ctx.rotate(s.spin * scatter);
ctx.fillStyle = 'rgb(' + s.r + ',' + s.g + ',' + s.b + ')';
ctx.fillRect(-s.w / 2, -s.h / 2, s.w, s.h);
ctx.restore();
}
}
function toggle() {
root.classList.remove('is-demo');
target = target === 1 ? 0 : 1;
if (reduceMQ.matches) render(0.05);
}
btn.addEventListener('click', toggle);
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
toggle();
});
cv.addEventListener('pointerdown', function () { toggle(); });
window.addEventListener('resize', function () { render(0.016); });
var lastT = null;
function frame(now) {
if (!reduceMQ.matches) {
var dt = lastT === null ? 0.016 : Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (root.classList.contains('is-demo')) {
var t = now % 2200;
target = t < 1100 ? 0 : 1;
}
render(dt);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
target = 0.4; assembly = 0.4;
render(0.05);
} else {
target = 0;
requestAnimationFrame(frame);
}
})();// SmokeDissolveButton — vanilla 판(run/404/demo/06-btn-canvas-smoke-dissolve-disable/index.html) 의 React 포트.
// 버튼을 오프스크린에 한 번 그려 실제 픽셀을 격자로 표본하고, 비활성(품절) 토글이 켜지는 순간
// 칸마다 정해진 방향 값·속도 값을 따라 위로 흩어지며 옅어진다. 다시 누르면 역순으로 모인다.
import { useEffect, useRef } from "react";
import styles from "./SmokeDissolveButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Shard = {
ox: number; oy: number; w: number; h: number;
r: number; g: number; b: number;
dirX: number; dirY: number; spin: number; delay: number;
};
const W = 480, H = 300;
const SEED = 60611;
const BW = 224, BH = 68;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
const CELL = 7;
const COLS = Math.ceil(BW / CELL), ROWS = Math.ceil(BH / CELL);
const ASSEMBLE_SPEED = 2.6;
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function roundRect(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function buildShards(rnd: () => number): Shard[] {
const off = document.createElement("canvas");
off.width = BW; off.height = BH;
const octx = off.getContext("2d");
if (!octx) return [];
octx.fillStyle = "#17141a";
roundRect(octx, 0, 0, BW, BH, 12);
octx.fill();
octx.fillStyle = "#fff7e6";
octx.font = "700 18px system-ui, sans-serif";
octx.textAlign = "center";
octx.textBaseline = "middle";
octx.fillText("구매하기", BW / 2, BH / 2 + 1);
const offData = octx.getImageData(0, 0, BW, BH).data;
const list: Shard[] = [];
for (let gy = 0; gy < ROWS; gy++) {
for (let gx = 0; gx < COLS; gx++) {
const sx = gx * CELL, sy = gy * CELL;
const sw = Math.min(CELL, BW - sx), sh = Math.min(CELL, BH - sy);
if (sw <= 0 || sh <= 0) continue;
const cxp = Math.min(BW - 1, sx + (sw / 2 | 0));
const cyp = Math.min(BH - 1, sy + (sh / 2 | 0));
const pi = (cyp * BW + cxp) * 4;
const a = offData[pi + 3];
if (a < 10) continue;
const ang = rnd() * Math.PI * 2;
const driftMag = 26 + rnd() * 54;
list.push({
ox: BX + sx, oy: BY + sy, w: sw, h: sh,
r: offData[pi], g: offData[pi + 1], b: offData[pi + 2],
dirX: Math.cos(ang) * driftMag,
dirY: Math.sin(ang) * driftMag - 40 - rnd() * 46,
spin: (rnd() - 0.5) * 2.4,
delay: rnd() * 0.28,
});
}
}
return list;
}
export default function SmokeDissolveButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const shardsRef = useRef<Shard[]>([]) as Box<Shard[]>;
const assemblyRef = useRef<number>(1) as Box<number>;
const targetRef = useRef<number>(1) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const lastTRef = useRef<number | null>(null) as Box<number | null>;
const frameRef = useRef<number>(0) as Box<number>;
function render(dt: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
assemblyRef.current += (targetRef.current - assemblyRef.current) * Math.min(1, dt * ASSEMBLE_SPEED);
const assembly = assemblyRef.current;
ctx.clearRect(0, 0, W, H);
const shards = shardsRef.current;
for (let i = 0; i < shards.length; i++) {
const s = shards[i];
const local = Math.max(0, Math.min(1, (assembly - s.delay) / (1 - s.delay)));
const scatter = 1 - local;
const px = s.ox + s.dirX * scatter * scatter;
const py = s.oy + s.dirY * scatter * scatter - scatter * 18;
const alpha = local;
if (alpha <= 0.01) continue;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(px + s.w / 2, py + s.h / 2);
ctx.rotate(s.spin * scatter);
ctx.fillStyle = "rgb(" + s.r + "," + s.g + "," + s.b + ")";
ctx.fillRect(-s.w / 2, -s.h / 2, s.w, s.h);
ctx.restore();
}
}
function toggle(): void {
demoRef.current = false;
targetRef.current = targetRef.current === 1 ? 0 : 1;
if (reduceRef.current) render(0.05);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
toggle();
}
useEffect(() => {
shardsRef.current = buildShards(rndRef.current);
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
targetRef.current = 0.4;
assemblyRef.current = 0.4;
render(0.05);
return;
}
targetRef.current = 0;
function frame(now: number): void {
const last = lastTRef.current;
const dt = last === null ? 0.016 : Math.min(0.05, (now - last) / 1000);
lastTRef.current = now;
if (demoRef.current) {
const t = now % 2200;
targetRef.current = t < 1100 ? 0 : 1;
}
render(dt);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.sd}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={toggle} />
<button className={styles.btn} ref={btnRef} type="button" onClick={toggle} onKeyDown={onKeyDown}>
구매하기
</button>
<p className={styles.hint}>버튼을 누르면 품절 처리되며 연기처럼 흩어집니다</p>
</div>
</main>
);
}06 · 無効値がオンになると煙のように消えるボタン — 計測 累積面積 22.697% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(dt) {
assembly += (target - assembly) * Math.min(1, dt * ASSEMBLE_SPEED);
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < shards.length; i++) {
var s = shards[i];
var local = Math.max(0, Math.min(1, (assembly - s.delay) / (1 - s.delay)));
var scatter = 1 - local;
var px = s.ox + s.dirX * scatter * scatter;
var py = s.oy + s.dirY * scatter * scatter - scatter * 18;
var alpha = local;
if (alpha <= 0.01) continue;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(px + s.w / 2, py + s.h / 2);
ctx.rotate(s.spin * scatter);
ctx.fillStyle = 'rgb(' + s.r + ',' + s.g + ',' + s.b + ')';
ctx.fillRect(-s.w / 2, -s.h / 2, s.w, s.h);
ctx.restore();
}
}
07進捗値の分だけ砂のように積もるプログレスボタン
進捗値が上がるたび spawnGrain() がランダムな列へ砂粒を一つ落とし、updateGrains() は GRAVITY = 560 で各粒を落下させ、現在の積み上げ高さに達した粒を配列から取り除きます。updatePile() は各列の高さを targetFill * BH へ EASE_RATE = 5.5 でイージングするので、山の輪郭は粒が落ち切った直後ではなく少し遅れて落ち着きます。

<main class="stage">
<div class="sf is-demo" id="root">
<canvas class="sf__cv" id="cv" width="480" height="300"></canvas>
<button class="sf__btn" id="btn" type="button">ダウンロード</button>
<p class="sf__hint">押すと進行値が上がり、砂粒が積もっていきます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-orange-deep;
@include stage($stage-orange);
.sf {
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;
}
.sf__cv {
display: block;
width: 100%;
height: 100%;
}
.sf__btn {
position: absolute;
left: 50%;
top: 50%;
width: calc(300 / 480 * 100%);
height: calc(140 / 300 * 100%);
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: $r-control;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.sf__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.sf__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, .72);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.sf__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
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);
}
.sf__cv {
display: block;
width: 100%;
height: 100%;
}
.sf__btn {
position: absolute;
left: 50%;
top: 50%;
width: 62.5%;
height: 46.6666666667%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: 12px;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.sf__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.sf__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, 0.72);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.sf__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function hashNoise(x, y, seed) {
var v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
var W = 480, H = 300;
var SEED = 70722;
var rnd = makeLcg(SEED);
var BW = 300, BH = 140;
var BX = (W - BW) / 2, BY = (H - BH) / 2;
var COLS = 36;
var COL_W = BW / COLS;
var GRAVITY = 560; // px/s^2 — 알갱이가 떨어지는 가속
var SPAWN_Y = 12; // 캔버스 위쪽 빈 공간에서부터 떨어뜨린다
var SPAWN_INTERVAL_MS = 30;
var EASE_RATE = 5.5; // 진행 값 → 쌓인 높이 값이 따라붙는 속도
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var colJitter = [];
for (var ci = 0; ci < COLS; ci++) colJitter.push((hashNoise(ci, 7, SEED) - 0.5) * 10);
var pileHeights = new Float32Array(COLS);
var grains = [];
var targetFill = 0;
var lastSpawn = 0;
function spawnGrain() {
var col = Math.floor(rnd() * COLS);
var size = 3 + rnd() * 3;
grains.push({ x: BX + col * COL_W + COL_W / 2, y: SPAWN_Y, vy: 40 + rnd() * 30, size: size, col: col });
}
function updateGrains(dt) {
if (grains.length > 90) grains.length = 90;
for (var i = grains.length - 1; i >= 0; i--) {
var g = grains[i];
g.vy += GRAVITY * dt;
g.y += g.vy * dt;
var floorY = BY + BH - pileHeights[g.col];
if (g.y + g.size / 2 >= floorY) grains.splice(i, 1);
}
}
function updatePile(dt) {
var targetHeight = targetFill * BH;
for (var c = 0; c < COLS; c++) {
var t = Math.max(0, Math.min(BH, targetHeight + colJitter[c]));
pileHeights[c] += (t - pileHeights[c]) * Math.min(1, dt * EASE_RATE);
}
}
function render(now, dt) {
updateGrains(dt);
updatePile(dt);
if (now - lastSpawn >= SPAWN_INTERVAL_MS) {
lastSpawn = now;
spawnGrain();
}
ctx.clearRect(0, 0, W, H);
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 14);
ctx.clip();
ctx.fillStyle = '#17141a';
ctx.fillRect(BX, BY, BW, BH);
for (var c1 = 0; c1 < COLS; c1++) {
var h = pileHeights[c1];
if (h <= 0) continue;
var x = BX + c1 * COL_W;
var y = BY + BH - h;
var grad = ctx.createLinearGradient(0, y, 0, BY + BH);
grad.addColorStop(0, '#e3c48a');
grad.addColorStop(1, '#b8925c');
ctx.fillStyle = grad;
ctx.fillRect(x, y, COL_W + 1, h + 2);
}
var speckleCell = 4;
for (var sy = 0; sy < BH; sy += speckleCell) {
for (var sx = 0; sx < BW; sx += speckleCell) {
var col = Math.min(COLS - 1, Math.floor(sx / COL_W));
var topY = BH - pileHeights[col];
if (sy < topY) continue;
var n = hashNoise(sx, sy, SEED);
if (n > 0.74) {
ctx.fillStyle = 'rgba(120,90,50,' + (0.25 + n * 0.3).toFixed(3) + ')';
ctx.fillRect(BX + sx, BY + sy, speckleCell - 1, speckleCell - 1);
}
}
}
ctx.fillStyle = '#fff7e6';
ctx.font = '700 18px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ダウンロード', BW / 2 + BX, BY + 20);
ctx.restore();
for (var i2 = 0; i2 < grains.length; i2++) {
var gr = grains[i2];
ctx.beginPath();
ctx.arc(gr.x, gr.y, gr.size / 2, 0, Math.PI * 2);
ctx.fillStyle = '#d9b171';
ctx.fill();
}
}
function trigger() {
root.classList.remove('is-demo');
targetFill = targetFill > 0.5 ? 0 : 1;
if (reduceMQ.matches) render(performance.now(), 0.05);
}
btn.addEventListener('click', trigger);
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
trigger();
});
cv.addEventListener('pointerdown', function () { trigger(); });
window.addEventListener('resize', function () { render(performance.now(), 0.016); });
var lastT = null;
function frame(now) {
if (!reduceMQ.matches) {
var dt = lastT === null ? 0.016 : Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (root.classList.contains('is-demo')) {
var t = now % 900;
if (t < 500) targetFill = t / 500;
else if (t < 600) targetFill = 1;
else targetFill = Math.max(0, 1 - (t - 600) / 300);
}
render(now, dt);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
targetFill = 0.55;
var th = targetFill * BH;
for (var c2 = 0; c2 < COLS; c2++) {
pileHeights[c2] = Math.max(0, Math.min(BH, th + colJitter[c2]));
}
render(performance.now(), 0.05);
} else {
requestAnimationFrame(frame);
}
})();// SandFillButton — vanilla 판(run/404/demo/07-btn-canvas-sand-fill-progress/index.html) 의 React 포트.
// 진행 값이 오를수록 목표 높이 값을 따라 쌓인 높이 값이 붙고, 동시에 캔버스 위쪽 빈 공간에서
// 모래 알갱이가 끊임없이 떨어져 내려와 진행률이 오르는 걸 실감나게 보여준다.
import { useEffect, useRef } from "react";
import styles from "./SandFillButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Grain = { x: number; y: number; vy: number; size: number; col: number };
const W = 480, H = 300;
const SEED = 70722;
const BW = 300, BH = 140;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
const COLS = 36;
const COL_W = BW / COLS;
const GRAVITY = 560;
const SPAWN_Y = 12;
const SPAWN_INTERVAL_MS = 30;
const EASE_RATE = 5.5;
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function hashNoise(x: number, y: number, seed: number): number {
const v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
const colJitter: number[] = [];
for (let ci = 0; ci < COLS; ci++) colJitter.push((hashNoise(ci, 7, SEED) - 0.5) * 10);
export default function SandFillButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const pileRef = useRef<Float32Array>(new Float32Array(COLS)) as Box<Float32Array>;
const grainsRef = useRef<Grain[]>([]) as Box<Grain[]>;
const targetFillRef = useRef<number>(0) as Box<number>;
const lastSpawnRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
function updateGrains(dt: number): void {
const grains = grainsRef.current;
if (grains.length > 90) grains.length = 90;
const pile = pileRef.current;
for (let i = grains.length - 1; i >= 0; i--) {
const g = grains[i];
g.vy += GRAVITY * dt;
g.y += g.vy * dt;
const floorY = BY + BH - pile[g.col];
if (g.y + g.size / 2 >= floorY) grains.splice(i, 1);
}
}
function updatePile(dt: number): void {
const pile = pileRef.current;
const targetHeight = targetFillRef.current * BH;
for (let c = 0; c < COLS; c++) {
const t = Math.max(0, Math.min(BH, targetHeight + colJitter[c]));
pile[c] += (t - pile[c]) * Math.min(1, dt * EASE_RATE);
}
}
function render(now: number, dt: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const rnd = rndRef.current;
const pile = pileRef.current;
const grains = grainsRef.current;
updateGrains(dt);
updatePile(dt);
if (now - lastSpawnRef.current >= SPAWN_INTERVAL_MS) {
lastSpawnRef.current = now;
const col = Math.floor(rnd() * COLS);
const size = 3 + rnd() * 3;
grains.push({ x: BX + col * COL_W + COL_W / 2, y: SPAWN_Y, vy: 40 + rnd() * 30, size, col });
}
ctx.clearRect(0, 0, W, H);
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 14);
ctx.clip();
ctx.fillStyle = "#17141a";
ctx.fillRect(BX, BY, BW, BH);
for (let c1 = 0; c1 < COLS; c1++) {
const h = pile[c1];
if (h <= 0) continue;
const x = BX + c1 * COL_W;
const y = BY + BH - h;
const grad = ctx.createLinearGradient(0, y, 0, BY + BH);
grad.addColorStop(0, "#e3c48a");
grad.addColorStop(1, "#b8925c");
ctx.fillStyle = grad;
ctx.fillRect(x, y, COL_W + 1, h + 2);
}
const speckleCell = 4;
for (let sy = 0; sy < BH; sy += speckleCell) {
for (let sx = 0; sx < BW; sx += speckleCell) {
const col = Math.min(COLS - 1, Math.floor(sx / COL_W));
const topY = BH - pile[col];
if (sy < topY) continue;
const n = hashNoise(sx, sy, SEED);
if (n > 0.74) {
ctx.fillStyle = "rgba(120,90,50," + (0.25 + n * 0.3).toFixed(3) + ")";
ctx.fillRect(BX + sx, BY + sy, speckleCell - 1, speckleCell - 1);
}
}
}
ctx.fillStyle = "#fff7e6";
ctx.font = "700 18px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("다운로드", BW / 2 + BX, BY + 20);
ctx.restore();
for (let i2 = 0; i2 < grains.length; i2++) {
const gr = grains[i2];
ctx.beginPath();
ctx.arc(gr.x, gr.y, gr.size / 2, 0, Math.PI * 2);
ctx.fillStyle = "#d9b171";
ctx.fill();
}
}
function trigger(): void {
demoRef.current = false;
targetFillRef.current = targetFillRef.current > 0.5 ? 0 : 1;
if (reduceRef.current) render(performance.now(), 0.05);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
trigger();
}
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
const pile = pileRef.current;
targetFillRef.current = 0.55;
const th = targetFillRef.current * BH;
for (let c2 = 0; c2 < COLS; c2++) {
pile[c2] = Math.max(0, Math.min(BH, th + colJitter[c2]));
}
render(performance.now(), 0.05);
return;
}
function frame(now: number): void {
const last = lastTRef.current;
const dt = last === null ? 0.016 : Math.min(0.05, (now - last) / 1000);
lastTRef.current = now;
if (demoRef.current) {
const t = now % 900;
if (t < 500) targetFillRef.current = t / 500;
else if (t < 600) targetFillRef.current = 1;
else targetFillRef.current = Math.max(0, 1 - (t - 600) / 300);
}
render(now, dt);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.sf}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={trigger} />
<button className={styles.btn} ref={btnRef} type="button" onClick={trigger} onKeyDown={onKeyDown}>
다운로드
</button>
<p className={styles.hint}>누르면 진행 값이 오르며 모래가 알갱이째 쌓입니다</p>
</div>
</main>
);
}07 · 進捗値の分だけ砂のように積もるプログレスボタン — 計測 累積面積 21.685% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function spawnGrain() {
var col = Math.floor(rnd() * COLS);
var size = 3 + rnd() * 3;
grains.push({ x: BX + col * COL_W + COL_W / 2, y: SPAWN_Y, vy: 40 + rnd() * 30, size: size, col: col });
}
function updateGrains(dt) {
if (grains.length > 90) grains.length = 90;
for (var i = grains.length - 1; i >= 0; i--) {
var g = grains[i];
g.vy += GRAVITY * dt;
g.y += g.vy * dt;
var floorY = BY + BH - pileHeights[g.col];
if (g.y + g.size / 2 >= floorY) grains.splice(i, 1);
}
}
function updatePile(dt) {
var targetHeight = targetFill * BH;
for (var c = 0; c < COLS; c++) {
var t = Math.max(0, Math.min(BH, targetHeight + colJitter[c]));
pileHeights[c] += (t - pileHeights[c]) * Math.min(1, dt * EASE_RATE);
}
}
08押した座標からピクセルを押し出す屈折リップルボタン
リップル帯の内側にある各ピクセルは、自分自身の位置ではなくずらした位置から getImageData で再サンプリングされ、そのずれ幅は shiftMag = CRACK_VALUE_ECHO * ampDecay * envelope * Math.sin(phase) で決まります — 02番が分岐数に使うのと同じ6が、ここではピクセルを押し出す量に化けています。BTN_CANVAS_RIPPLE_DECAY_MS(既定320ms)は ampDecay が0へ落ちる速さを決め、値を短くすると波は一、二フレームで収まります。

<main class="stage">
<div class="rf is-demo" id="root">
<canvas class="rf__cv" id="cv" width="480" height="300"></canvas>
<button class="rf__btn" id="btn" type="button">検索する</button>
<p class="rf__hint">押した座標から波紋が広がり、ピクセルを押し出します</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rf {
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;
}
.rf__cv {
display: block;
width: 100%;
height: 100%;
}
.rf__btn {
position: absolute;
left: 50%;
top: 50%;
width: calc(300 / 480 * 100%);
height: calc(140 / 300 * 100%);
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: $r-control;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.rf__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 3px;
}
.rf__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(23, 20, 26, .06);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.rf__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);
}
.rf {
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);
}
.rf__cv {
display: block;
width: 100%;
height: 100%;
}
.rf__btn {
position: absolute;
left: 50%;
top: 50%;
width: 62.5%;
height: 46.6666666667%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: 12px;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.rf__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 3px;
}
.rf__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.rf__btn {
transition: none;
}
}(function () {
'use strict';
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function hashNoise(x, y, seed) {
var v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function clampInt(v, lo, hi) {
v = v | 0;
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
var W = 480, H = 300;
var SEED = 80809;
var rnd = makeLcg(SEED);
// 02번(btn-canvas-crack-shatter-press)의 균열 각도 값(BTN_CANVAS_CRACK_BRANCH_COUNT = 6)을
// 이 편의 "눌린 좌표를 중심으로 화면을 밀어내는 픽셀 이동량"에 그대로 재사용한다 (훅 참고).
var CRACK_VALUE_ECHO = 6;
var BTN_CANVAS_RIPPLE_DECAY_MS = 320; // 진폭 값이 e분의 1로 줄어드는 데 걸리는 시간
var WAVE_SPEED = 850; // px/s — 물결이 밀려 나가는 속도
var FREQ = 0.16; // rad/px — 물결의 공간 주파수
var ANG_SPEED = FREQ * WAVE_SPEED; // rad/s
var BAND_WIDTH = 46; // px — 물결 띠의 두께
var MAX_RIPPLES = 3;
var BW = 300, BH = 140;
var BX = (W - BW) / 2, BY = (H - BH) / 2;
// 화면 전체에 잔무늬를 깔아 둔다 — 물결이 지나가는 자리마다 픽셀이 이 무늬 경계를
// 넘나들며 눈에 보이는 차이를 만든다 (민무늬 배경만이면 안쪽은 밀려도 색이 같아 안 보인다).
var TEXTURE_CELL = 4;
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
// 눌리지 않은 원래 화면을 한 번만 그려 픽셀을 표본할 원본으로 둔다.
var base = document.createElement('canvas');
base.width = W; base.height = H;
var bctx = base.getContext('2d');
for (var ty = 0; ty < H; ty += TEXTURE_CELL) {
for (var tx = 0; tx < W; tx += TEXTURE_CELL) {
var tn = hashNoise(tx, ty, SEED);
bctx.fillStyle = tn > 0.5 ? '#eef0f4' : '#d8dbe3';
bctx.fillRect(tx, ty, TEXTURE_CELL, TEXTURE_CELL);
}
}
roundRect(bctx, BX, BY, BW, BH, 16);
bctx.fillStyle = '#17141a';
bctx.fill();
bctx.fillStyle = '#fff7e6';
bctx.font = '700 20px system-ui, sans-serif';
bctx.textAlign = 'center';
bctx.textBaseline = 'middle';
bctx.fillText('検索する', BX + BW / 2, BY + BH / 2);
var baseData = bctx.getImageData(0, 0, W, H).data;
var outImg = ctx.createImageData(W, H);
var outData = outImg.data;
var ripples = [];
function pushRipple(x, y, now) {
ripples.push({ x: x, y: y, t0: now });
if (ripples.length > MAX_RIPPLES) ripples.shift();
}
function toCanvasXY(e) {
var rect = cv.getBoundingClientRect();
var scaleX = W / rect.width, scaleY = H / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}
function render(now) {
outData.set(baseData);
ripples = ripples.filter(function (r) {
return Math.exp(-(now - r.t0) / BTN_CANVAS_RIPPLE_DECAY_MS) > 0.02;
});
for (var i = 0; i < ripples.length; i++) {
var r = ripples[i];
var elapsed = now - r.t0;
if (elapsed < 0) continue;
var radius = (elapsed / 1000) * WAVE_SPEED;
var ampDecay = Math.exp(-elapsed / BTN_CANVAS_RIPPLE_DECAY_MS);
var minX = clampInt(Math.floor(r.x - radius - BAND_WIDTH), 0, W - 1);
var maxX = clampInt(Math.ceil(r.x + radius + BAND_WIDTH), 0, W - 1);
var minY = clampInt(Math.floor(r.y - radius - BAND_WIDTH), 0, H - 1);
var maxY = clampInt(Math.ceil(r.y + radius + BAND_WIDTH), 0, H - 1);
for (var y = minY; y <= maxY; y++) {
for (var x = minX; x <= maxX; x++) {
var dx = x - r.x, dy = y - r.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var bandDist = dist - radius;
if (bandDist > BAND_WIDTH || bandDist < -BAND_WIDTH) continue;
var envelope = 1 - Math.abs(bandDist) / BAND_WIDTH;
var phase = dist * FREQ - (elapsed / 1000) * ANG_SPEED;
var shiftMag = CRACK_VALUE_ECHO * ampDecay * envelope * Math.sin(phase);
var nx = dist > 0.0001 ? dx / dist : 0, ny = dist > 0.0001 ? dy / dist : 0;
var sx = clampInt(Math.round(x + nx * shiftMag), 0, W - 1);
var sy = clampInt(Math.round(y + ny * shiftMag), 0, H - 1);
var srcIdx = (sy * W + sx) * 4, dstIdx = (y * W + x) * 4;
outData[dstIdx] = baseData[srcIdx];
outData[dstIdx + 1] = baseData[srcIdx + 1];
outData[dstIdx + 2] = baseData[srcIdx + 2];
outData[dstIdx + 3] = baseData[srcIdx + 3];
}
}
}
ctx.putImageData(outImg, 0, 0);
}
function trigger(x, y) {
root.classList.remove('is-demo');
pushRipple(x, y, performance.now());
if (reduceMQ.matches) render(performance.now());
}
btn.addEventListener('click', function () { trigger(BX + BW / 2, BY + BH / 2); });
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
trigger(BX + BW / 2, BY + BH / 2);
});
cv.addEventListener('pointerdown', function (e) {
var p = toCanvasXY(e);
trigger(p.x, p.y);
});
window.addEventListener('resize', function () { render(performance.now()); });
function frame(now) {
if (!reduceMQ.matches) {
if (root.classList.contains('is-demo')) {
var t = now % 550;
if (t < 16) {
var cx = BX + 40 + rnd() * (BW - 80);
var cy = BY + 24 + rnd() * (BH - 48);
pushRipple(cx, cy, now);
}
}
render(now);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
pushRipple(BX + BW / 2, BY + BH / 2, performance.now() - 140);
render(performance.now());
} else {
requestAnimationFrame(frame);
}
})();// RippleRefractButton — vanilla 판(run/404/demo/08-btn-canvas-ripple-refract-press/index.html) 의 React 포트.
// 눌린 좌표를 중심으로 퍼지는 물결의 위상 값만큼 그 자리의 픽셀을 옆으로 밀어 다시 그리고,
// 진폭 값이 프레임마다 줄어들며 화면이 원래 자리로 가라앉는다.
import { useEffect, useRef } from "react";
import styles from "./RippleRefractButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PointerEvt = { clientX: number; clientY: number };
type Ripple = { x: number; y: number; t0: number };
const W = 480, H = 300;
const SEED = 80809;
// 02번(btn-canvas-crack-shatter-press)의 균열 각도 값(BTN_CANVAS_CRACK_BRANCH_COUNT = 6)을
// 이 편의 "눌린 좌표를 중심으로 화면을 밀어내는 픽셀 이동량"에 그대로 재사용한다 (훅 참고).
const CRACK_VALUE_ECHO = 6;
const BTN_CANVAS_RIPPLE_DECAY_MS = 320; // 진폭 값이 e분의 1로 줄어드는 데 걸리는 시간
const WAVE_SPEED = 850;
const FREQ = 0.16;
const ANG_SPEED = FREQ * WAVE_SPEED;
const BAND_WIDTH = 46;
const MAX_RIPPLES = 3;
const BW = 300, BH = 140;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
// 화면 전체에 잔무늬를 깔아 둔다 — 물결이 지나가는 자리마다 픽셀이 이 무늬 경계를
// 넘나들며 눈에 보이는 차이를 만든다 (민무늬 배경만이면 안쪽은 밀려도 색이 같아 안 보인다).
const TEXTURE_CELL = 4;
function hashNoise(x: number, y: number, seed: number): number {
const v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function roundRect(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function clampInt(v: number, lo: number, hi: number): number {
v = v | 0;
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
export default function RippleRefractButton() {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rndRef = useRef(makeLcg(SEED)) as Box<() => number>;
const ripplesRef = useRef<Ripple[]>([]) as Box<Ripple[]>;
const baseDataRef = useRef<Uint8ClampedArray | null>(null) as Box<Uint8ClampedArray | null>;
const outImgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
function pushRipple(x: number, y: number, now: number): void {
const ripples = ripplesRef.current;
ripples.push({ x, y, t0: now });
if (ripples.length > MAX_RIPPLES) ripples.shift();
}
function toCanvasXY(e: PointerEvt): { x: number; y: number } {
const cv = cvRef.current;
if (!cv) return { x: BX + BW / 2, y: BY + BH / 2 };
const rect = cv.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}
function render(now: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const baseData = baseDataRef.current;
const outImg = outImgRef.current;
if (!baseData || !outImg) return;
const outData = outImg.data;
outData.set(baseData);
let ripples = ripplesRef.current;
ripples = ripples.filter((r) => Math.exp(-(now - r.t0) / BTN_CANVAS_RIPPLE_DECAY_MS) > 0.02);
ripplesRef.current = ripples;
for (let i = 0; i < ripples.length; i++) {
const r = ripples[i];
const elapsed = now - r.t0;
if (elapsed < 0) continue;
const radius = (elapsed / 1000) * WAVE_SPEED;
const ampDecay = Math.exp(-elapsed / BTN_CANVAS_RIPPLE_DECAY_MS);
const minX = clampInt(Math.floor(r.x - radius - BAND_WIDTH), 0, W - 1);
const maxX = clampInt(Math.ceil(r.x + radius + BAND_WIDTH), 0, W - 1);
const minY = clampInt(Math.floor(r.y - radius - BAND_WIDTH), 0, H - 1);
const maxY = clampInt(Math.ceil(r.y + radius + BAND_WIDTH), 0, H - 1);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const dx = x - r.x, dy = y - r.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const bandDist = dist - radius;
if (bandDist > BAND_WIDTH || bandDist < -BAND_WIDTH) continue;
const envelope = 1 - Math.abs(bandDist) / BAND_WIDTH;
const phase = dist * FREQ - (elapsed / 1000) * ANG_SPEED;
const shiftMag = CRACK_VALUE_ECHO * ampDecay * envelope * Math.sin(phase);
const nx = dist > 0.0001 ? dx / dist : 0, ny = dist > 0.0001 ? dy / dist : 0;
const sx = clampInt(Math.round(x + nx * shiftMag), 0, W - 1);
const sy = clampInt(Math.round(y + ny * shiftMag), 0, H - 1);
const srcIdx = (sy * W + sx) * 4, dstIdx = (y * W + x) * 4;
outData[dstIdx] = baseData[srcIdx];
outData[dstIdx + 1] = baseData[srcIdx + 1];
outData[dstIdx + 2] = baseData[srcIdx + 2];
outData[dstIdx + 3] = baseData[srcIdx + 3];
}
}
}
ctx.putImageData(outImg, 0, 0);
}
function trigger(x: number, y: number): void {
demoRef.current = false;
pushRipple(x, y, performance.now());
if (reduceRef.current) render(performance.now());
}
function onClick(): void {
trigger(BX + BW / 2, BY + BH / 2);
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
trigger(BX + BW / 2, BY + BH / 2);
}
function onPointerDown(e: PointerEvt): void {
const p = toCanvasXY(e);
trigger(p.x, p.y);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const base = document.createElement("canvas");
base.width = W; base.height = H;
const bctx = base.getContext("2d");
if (!bctx) return;
for (let ty = 0; ty < H; ty += TEXTURE_CELL) {
for (let tx = 0; tx < W; tx += TEXTURE_CELL) {
const tn = hashNoise(tx, ty, SEED);
bctx.fillStyle = tn > 0.5 ? "#eef0f4" : "#d8dbe3";
bctx.fillRect(tx, ty, TEXTURE_CELL, TEXTURE_CELL);
}
}
roundRect(bctx, BX, BY, BW, BH, 16);
bctx.fillStyle = "#17141a";
bctx.fill();
bctx.fillStyle = "#fff7e6";
bctx.font = "700 20px system-ui, sans-serif";
bctx.textAlign = "center";
bctx.textBaseline = "middle";
bctx.fillText("검색하기", BX + BW / 2, BY + BH / 2);
baseDataRef.current = bctx.getImageData(0, 0, W, H).data;
outImgRef.current = ctx.createImageData(W, H);
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
pushRipple(BX + BW / 2, BY + BH / 2, performance.now() - 140);
render(performance.now());
return;
}
const rnd = rndRef.current;
const root = rootRef.current;
function frame(now: number): void {
if (demoRef.current) {
const t = now % 550;
if (t < 16) {
const cx = BX + 40 + rnd() * (BW - 80);
const cy = BY + 24 + rnd() * (BH - 48);
pushRipple(cx, cy, now);
}
}
render(now);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.rf} ref={rootRef}>
<canvas
className={styles.cv}
ref={cvRef}
width={W}
height={H}
onPointerDown={onPointerDown}
/>
<button className={styles.btn} ref={btnRef} type="button" onClick={onClick} onKeyDown={onKeyDown}>
검색하기
</button>
<p className={styles.hint}>누른 좌표에서 물결이 픽셀을 밀어내며 퍼집니다</p>
</div>
</main>
);
}08 · 押した座標からピクセルを押し出す屈折リップルボタン — 計測 累積面積 17.142% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (var y = minY; y <= maxY; y++) {
for (var x = minX; x <= maxX; x++) {
var dx = x - r.x, dy = y - r.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var bandDist = dist - radius;
if (bandDist > BAND_WIDTH || bandDist < -BAND_WIDTH) continue;
var envelope = 1 - Math.abs(bandDist) / BAND_WIDTH;
var phase = dist * FREQ - (elapsed / 1000) * ANG_SPEED;
var shiftMag = CRACK_VALUE_ECHO * ampDecay * envelope * Math.sin(phase);
var nx = dist > 0.0001 ? dx / dist : 0, ny = dist > 0.0001 ? dy / dist : 0;
var sx = clampInt(Math.round(x + nx * shiftMag), 0, W - 1);
var sy = clampInt(Math.round(y + ny * shiftMag), 0, H - 1);
var srcIdx = (sy * W + sx) * 4, dstIdx = (y * W + x) * 4;
outData[dstIdx] = baseData[srcIdx];
outData[dstIdx + 1] = baseData[srcIdx + 1];
outData[dstIdx + 2] = baseData[srcIdx + 2];
outData[dstIdx + 3] = baseData[srcIdx + 3];
}
}
09エラー値が反転すると砂嵐のように揺れるノイズボタン
trigger() は真偽値 errorOn を切り替えるだけで、drawNoise() は NOISE_CELL = 3 の格子の各セルを TICK_MS = 40 ごとにハッシュから再計算するので、静止画にランダムな透明度を重ねているのではなく、ティックごとに決定論的なハッシュで描き直しています。座標を必要としないぶん、他の8つが使う makeLcg() の乱数列はこの項目には存在しません。

<main class="stage">
<div class="ns is-demo" id="root">
<canvas class="ns__cv" id="cv" width="480" height="300"></canvas>
<button class="ns__btn" id="btn" type="button">再試行</button>
<p class="ns__hint">エラー値が反転する瞬間、砂嵐ノイズが一瞬覆います</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.ns {
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;
}
.ns__cv {
display: block;
width: 100%;
height: 100%;
}
.ns__btn {
position: absolute;
left: 50%;
top: 50%;
width: calc(300 / 480 * 100%);
height: calc(140 / 300 * 100%);
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: $r-control;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.ns__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.ns__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, .72);
color: $subject-ink;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.ns__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);
}
.ns {
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);
}
.ns__cv {
display: block;
width: 100%;
height: 100%;
}
.ns__btn {
position: absolute;
left: 50%;
top: 50%;
width: 62.5%;
height: 46.6666666667%;
transform: translate(-50%, -50%);
z-index: 2;
border: 0;
border-radius: 12px;
background: transparent;
color: transparent;
cursor: pointer;
font-size: 16px;
font-weight: 700;
}
.ns__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.ns__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 255, 255, 0.72);
color: #17141a;
pointer-events: none;
z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.ns__btn {
transition: none;
}
}(function () {
'use strict';
function hashNoise(x, y, seed) {
var v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
var W = 480, H = 300;
var SEED = 90909;
var BW = 300, BH = 140;
var BX = (W - BW) / 2, BY = (H - BH) / 2;
var NOISE_CELL = 3; // px — 노이즈 블록 한 칸
var BTN_CANVAS_STATIC_BURST_MS = 650; // 정적이 덮였다가 가라앉는 데 걸리는 시간
var TICK_MS = 40; // 이 간격마다 노이즈 패턴을 다시 계산한다 (깜빡임 속도)
var root = document.getElementById('root');
var cv = document.getElementById('cv');
var btn = document.getElementById('btn');
var ctx = cv.getContext('2d');
var reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
var errorOn = false;
var burstT0 = null;
function drawPanel(errored) {
roundRect(ctx, BX, BY, BW, BH, 16);
ctx.fillStyle = '#17141a';
ctx.fill();
ctx.fillStyle = errored ? '#ff8a6b' : '#fff7e6';
ctx.font = '700 18px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('再試行', BX + BW / 2, BY + BH / 2);
if (errored) {
ctx.lineWidth = 3;
ctx.strokeStyle = '#ff8a6b';
roundRect(ctx, BX + 1.5, BY + 1.5, BW - 3, BH - 3, 15);
ctx.stroke();
}
}
function drawNoise(envelope, tick) {
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 16);
ctx.clip();
ctx.globalAlpha = envelope;
for (var y = BY; y < BY + BH; y += NOISE_CELL) {
for (var x = BX; x < BX + BW; x += NOISE_CELL) {
var n = hashNoise(x / NOISE_CELL, y / NOISE_CELL, SEED + tick);
var v = Math.floor(n * 255);
ctx.fillStyle = 'rgb(' + v + ',' + v + ',' + v + ')';
ctx.fillRect(x, y, NOISE_CELL, NOISE_CELL);
}
}
ctx.restore();
ctx.globalAlpha = 1;
}
function render(now) {
ctx.clearRect(0, 0, W, H);
drawPanel(errorOn);
if (burstT0 !== null) {
var elapsed = now - burstT0;
if (elapsed >= BTN_CANVAS_STATIC_BURST_MS) {
burstT0 = null;
} else {
var envelope = 1 - elapsed / BTN_CANVAS_STATIC_BURST_MS;
var tick = Math.floor(elapsed / TICK_MS);
drawNoise(envelope, tick);
}
}
}
function trigger() {
root.classList.remove('is-demo');
errorOn = !errorOn;
if (errorOn) burstT0 = performance.now();
if (reduceMQ.matches) render(performance.now());
}
btn.addEventListener('click', trigger);
btn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
trigger();
});
cv.addEventListener('pointerdown', function () { trigger(); });
window.addEventListener('resize', function () { render(performance.now()); });
function frame(now) {
if (!reduceMQ.matches) {
if (root.classList.contains('is-demo')) {
// 데모 재생 중엔 정적이 끊기지 않도록 번짐이 끝나는 즉시 다음 번짐을 이어 붙인다.
errorOn = true;
if (burstT0 === null || now - burstT0 >= BTN_CANVAS_STATIC_BURST_MS) burstT0 = now;
}
render(now);
requestAnimationFrame(frame);
}
}
if (reduceMQ.matches) {
errorOn = true;
burstT0 = performance.now() - BTN_CANVAS_STATIC_BURST_MS * 0.4;
render(performance.now());
} else {
requestAnimationFrame(frame);
}
})();// NoiseStaticButton — vanilla 판(run/404/demo/09-btn-canvas-noise-static-glitch/index.html) 의 React 포트.
// 오류 값이 false에서 true로 뒤집히는 순간 버튼 표면 픽셀마다 밝기 값을 무작위로 다시 계산해
// 텔레비전 정적 같은 노이즈를 짧게 덮어씌운다.
import { useEffect, useRef } from "react";
import styles from "./NoiseStaticButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const W = 480, H = 300;
const SEED = 90909;
const BW = 300, BH = 140;
const BX = (W - BW) / 2, BY = (H - BH) / 2;
const NOISE_CELL = 3;
const BTN_CANVAS_STATIC_BURST_MS = 650;
const TICK_MS = 40;
function hashNoise(x: number, y: number, seed: number): number {
const v = Math.sin(x * 12.9898 + y * 78.233 + seed * 0.0013) * 43758.5453;
return v - Math.floor(v);
}
function roundRect(c: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
export default function NoiseStaticButton() {
const cvRef = useRef<HTMLCanvasElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const errorOnRef = useRef<boolean>(false) as Box<boolean>;
const burstT0Ref = useRef<number | null>(null) as Box<number | null>;
const reduceRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
function drawPanel(ctx: CanvasRenderingContext2D, errored: boolean): void {
roundRect(ctx, BX, BY, BW, BH, 16);
ctx.fillStyle = "#17141a";
ctx.fill();
ctx.fillStyle = errored ? "#ff8a6b" : "#fff7e6";
ctx.font = "700 18px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("다시 시도", BX + BW / 2, BY + BH / 2);
if (errored) {
ctx.lineWidth = 3;
ctx.strokeStyle = "#ff8a6b";
roundRect(ctx, BX + 1.5, BY + 1.5, BW - 3, BH - 3, 15);
ctx.stroke();
}
}
function drawNoise(ctx: CanvasRenderingContext2D, envelope: number, tick: number): void {
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 16);
ctx.clip();
ctx.globalAlpha = envelope;
for (let y = BY; y < BY + BH; y += NOISE_CELL) {
for (let x = BX; x < BX + BW; x += NOISE_CELL) {
const n = hashNoise(x / NOISE_CELL, y / NOISE_CELL, SEED + tick);
const v = Math.floor(n * 255);
ctx.fillStyle = "rgb(" + v + "," + v + "," + v + ")";
ctx.fillRect(x, y, NOISE_CELL, NOISE_CELL);
}
}
ctx.restore();
ctx.globalAlpha = 1;
}
function render(now: number): void {
const cv = cvRef.current;
if (!cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, W, H);
drawPanel(ctx, errorOnRef.current);
const burstT0 = burstT0Ref.current;
if (burstT0 !== null) {
const elapsed = now - burstT0;
if (elapsed >= BTN_CANVAS_STATIC_BURST_MS) {
burstT0Ref.current = null;
} else {
const envelope = 1 - elapsed / BTN_CANVAS_STATIC_BURST_MS;
const tick = Math.floor(elapsed / TICK_MS);
drawNoise(ctx, envelope, tick);
}
}
}
function trigger(): void {
demoRef.current = false;
errorOnRef.current = !errorOnRef.current;
if (errorOnRef.current) burstT0Ref.current = performance.now();
if (reduceRef.current) render(performance.now());
}
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
trigger();
}
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) {
errorOnRef.current = true;
burstT0Ref.current = performance.now() - BTN_CANVAS_STATIC_BURST_MS * 0.4;
render(performance.now());
return;
}
function frame(now: number): void {
if (demoRef.current) {
// 데모 재생 중엔 정적이 끊기지 않도록 번짐이 끝나는 즉시 다음 번짐을 이어 붙인다.
errorOnRef.current = true;
const t0 = burstT0Ref.current;
if (t0 === null || now - t0 >= BTN_CANVAS_STATIC_BURST_MS) burstT0Ref.current = now;
}
render(now);
frameRef.current = requestAnimationFrame(frame);
}
frameRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return (
<main className={styles.stage}>
<div className={styles.ns}>
<canvas className={styles.cv} ref={cvRef} width={W} height={H} onPointerDown={trigger} />
<button className={styles.btn} ref={btnRef} type="button" onClick={trigger} onKeyDown={onKeyDown}>
다시 시도
</button>
<p className={styles.hint}>오류 값이 뒤집히는 순간 정적 노이즈가 짧게 덮입니다</p>
</div>
</main>
);
}09 · エラー値が反転すると砂嵐のように揺れるノイズボタン — 計測 累積面積 22.181% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function drawNoise(envelope, tick) {
ctx.save();
roundRect(ctx, BX, BY, BW, BH, 16);
ctx.clip();
ctx.globalAlpha = envelope;
for (var y = BY; y < BY + BH; y += NOISE_CELL) {
for (var x = BX; x < BX + BW; x += NOISE_CELL) {
var n = hashNoise(x / NOISE_CELL, y / NOISE_CELL, SEED + tick);
var v = Math.floor(n * 255);
ctx.fillStyle = 'rgb(' + v + ',' + v + ',' + v + ')';
ctx.fillRect(x, y, NOISE_CELL, NOISE_CELL);
}
}
ctx.restore();
ctx.globalAlpha = 1;
}
見た目を決めているのは3種類の値だけ
9つを「定数が実際に何を決めているか」でまとめると、形は3種類しかありません。
再計算される場が形を決める — 01・04・08
01のインクグリッド、04のメタボール合計、08のピクセルずらしの場は、どれも毎フレーム配列やループから丸ごと再計算されており、2つのキーフレームをCSSプロパティでイージングしているわけではありません。01は60×38マスの配列、04は7個の粒、08はキャンバス全域のピクセルと対象の粒度は違いますが、どれも「前フレームの値から次フレームの値を計算し直す」という同じ操作を繰り返しています。
分岐や粒子の数が複雑さを決める — 02・03・07
02の分岐数、03の残り火の数、07の砂粒の数は、それぞれ定数・ドラッグ速度・進捗率という値が「今フレーム何個描くか」に変換されたもので、動き方そのものを決めているわけではありません。02は既定で6本、03は速度に応じて1〜9個、07は進捗が上がるたびに1粒ずつと、生成される個数の増え方はそれぞれ違います。
間隔の値がリズムを決める — 05・06・09
05のジッター間隔、06の組み立て速度、09のティック間隔は、それぞれ値がいつ変わってよいかを制限しており、それがジッターを滑らかなイージングではなく震えとして見せています。05は既定70ms、09は40msという時間の単位で区切られ、06は秒あたりの追いつく速さ(ASSEMBLE_SPEED = 2.6)という比率で区切られており、単位は違っても「値がいつ切り替わってよいか」を制限する点は同じです。
ひびの角度値が、もう二度使い回される場所
02番は BTN_CANVAS_CRACK_BRANCH_COUNT = 6 を宣言し、buildTree() の中でその回数だけループしてひびの枝の本数を決めています。05番と08番はそれぞれ独立した定数 CRACK_VALUE_ECHO = 6 を持ち、コメントで02番の名前を直接示しています — 05番はそれをアーク線の lineWidth に掛け、08番はリップルの shiftMag を getImageData の中で計算するのに使います。ファイル間で値を読み込んでいるわけではなく、3つの独立した定数がたまたま同じ6を持っていて、それを結びつけているのは各ファイル自身のコメントだけです。
9つのフレームごとのピクセル数を足すと
以下の数値はすべてこの回の 측정.json から直接読み取ったもので、動いたフレームは連続するフレーム間の差分23回のうち何回動いたかを数えています。
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 インク拡散 | 29.3% | 35.8 | 23/23 |
| 02 亀裂分岐 | 20.1% | 64.7 | 23/23 |
| 03 残り火トレイル | 28.6% | 45.8 | 23/23 |
| 04 リキッドメタル | 51.6% | 33.9 | 23/23 |
| 05 電気アーク | 23.6% | 54.0 | 23/23 |
| 06 煙の分解 | 22.7% | 57.2 | 23/23 |
| 07 砂の積み上げ | 21.7% | 137.8 | 23/23 |
| 08 屈折リップル | 17.1% | 37.4 | 20/23 |
| 09 ノイズ砂嵐 | 22.2% | 61.5 | 23/23 |
9つの中で最も面積を占めるのは04の融合面で、23分の23に届かなかったのは08だけです — 理由は下のFAQで説明します。
3つの定数を動かして、押し心地の違いを感じる
次の3項目にはそれぞれソースの中に既定値と入れ替えられる第二の値が用意されています。
| 項目 | 値 | 動かすとどうなるか |
|---|---|---|
| 02 BTN_CANVAS_CRACK_BRANCH_COUNT | 6 → 3 | 値を下げるとひびは一、二本の単純な分岐にとどまり、値を上げると分岐が増えてより複雑に割れたように見えるはずです。 |
| 05 BTN_CANVAS_ARC_JITTER_MS | 70 → 30 | 値を短くするとアーク線は毎フレーム速く震えてジリジリした印象が強まり、値を伸ばすと震える間隔が広がりアークはゆっくり揺れる方に近づくはずです。 |
| 08 BTN_CANVAS_RIPPLE_DECAY_MS | 320 → 220 | 値を短く縮めると波は一、二フレームで収まり軽くはじく感触だけが残り、値を伸ばすと波は長く広がり端まで何度か揺れるはずです。 |
9つがそれぞれ最初に壊れる場所
9つとも aria-pressed を含めどんなARIA状態も設定していません。すべてただの <button> に click を結びつけているだけなので、スクリーンリーダーはラベルは読み上げてもひびや積み上がりやエラー値の反転までは伝えません — 実際のフィードバックとして使うなら状態は自分で付け足す必要があります。
9つとも devicePixelRatio を読んでいないため、キャンバスの実ピクセルはCSS上のサイズのまま固定されています。高DPI画面ではインクの拡散グリッドもノイズのセルもリップルの getImageData サンプリングも、画面が本来表示できる解像度の半分で計算されていることになります。
9つすべての resize リスナーは render() を呼び直すだけで、canvas.width/canvas.height を再設定していません。CSS側でキャンバス要素を引き伸ばすレイアウトを組むと、固定解像度のバッファがそのまま引き伸ばされてぼやけます。
下のzipには9つすべての index.html が xbg2dm72 の奥に入っており、上の表と同じ状態で展開できます。
9つ以外でcss ボタン アニメーション おしゃれ系を探すなら
この9つを離れて探すと、見つかるものの多くは :active のボーダー色やスケール、影といったCSSトランジションです。中にはcss ボタン ホバー エフェクトとして単体のコードを公開しているものもありますが、他人の:activeスケールをフォークしても、getImageData によるリップルや再帰的なひびの木がどう組まれているかまでは分かりません — そこを埋めるためにこの9つを書きました。
こんな画面でこそ9つが生きる
押下状態の値がオンになったときだけひびが入る02番は、取り消せない削除ボタンにそのまま合います — 砕ける動き自体が確認そのものになるからです。09番も同じ発想を逆向きに使え、支払いが失敗してエラー値がオンになった瞬間ボタン自体が一瞬砂嵐になるので、小さな画面で見落とされがちな赤枠より気づかれやすいはずです。
07番の砂の積み上げは、ただの進捗パーセントをアップロードや書き出し中に目に見えて埋まっていくボタンへ変えます。別の行に置いた進捗バーと注意を奪い合うより、ボタンの中で完結する分だけ読みやすく、9つとも本物のボタンのテキストはそのまま保たれるので導入のためにデザインシステムを変える必要はありません。
押し込みバネ・CTAマイクロインタラクション・キャンバスシーンとの違い
ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り も9つそれぞれに一つの値を割り当てていますが、その値は押し込み量がCSSトランスフォームを通じて戻るバネで、キャンバスもピクセル単位の計算も getImageData もありません。05番のようにcss ボタン アニメーション 光る縁を計算で作るのとは別物です。
CTA ボタン デザイン 9選 — 伸びる影から跳ねるバーまで はもっと幅広いクリックのフィードバック — 伸びる影、跳ねるバー、広がる塗り — をコンバージョン重視のボタン向けにまとめたもので、9つのうち何割かはキャンバスなしでも成立します。
html5 canvas アニメーション9種 — 水滴とブラックホール は同じ系統のピクセル計算 — 水滴の屈折や歪みの場 — をボタンではなく画面全体のシーンで動かしており、クリックだけで起動するものでもありません。この9つはキャンバスをボタンのサイズに留め、クリック・キー・ドラッグでしか目を覚まさない点が違います。
prefers-reduced-motion がここで止めているもの
9つとも requestAnimationFrame ループを始める前に window.matchMedia('(prefers-reduced-motion: reduce)') を確認しており、条件に一致するとフレーム関数は二度と呼ばれず、ボタンを押したときは render() が一度だけ直接呼ばれるので状態の変化は見えたままです — ただしアニメーションではなく一枚だけ描かれた静止フレームとしてです。計算そのものは変わらず、拡散グリッドも分岐の木もピクセルのずれも一度は必ず走ります。減っているのは、本来なら毎秒20回以上描き直すはずだった requestAnimationFrame の繰り返しだけです。
9つを解凍する前に、よくある質問に答えられますか
この9つにcss ボタン ホバー エフェクトとして使えるCodePen版はありますか?
いいえ — 9つはこのページのライブデモとパスワード付きzipとしてのみ提供されており、別途フォークできる埋め込みはありません。
9つのどれかにライブラリやWebGL、CDNスクリプトは必要ですか?
いいえ。9つとも1枚の <canvas> とプレーンなJS、2Dコンテキストの呼び出しだけでできており、ライブラリもWebGLコンテキストもCDNスクリプトタグもどのファイルにも含まれていません。
なぜ08番だけ23分の23ではなく23分の20しか動いていないのですか?
BTN_CANVAS_RIPPLE_DECAY_MS は既定で320msあり、ampDecay は最後の数フレームが来る前に十分0へ近づいてしまうため、リップルのピクセルずれが計測の移動しきい値を下回ります。波自体は計算上まだ動いていますが、その時点では小さすぎて動いたピクセルとして記録されないだけです。








