01Ink-diffusion Button That Spreads From the Click Point
Centered on the press point, a radius value grows frame by frame while its alpha value fades together, drawing a circular bloom that spreads out like ink soaking into paper.
Best forConfirm-button click feedback, form-submit buttons
DIFFUSE_RATEinjectAtdiffuseStep

<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">Submit</button>
<p class="ik__hint">Press the button — ink spreads from where you press</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 Ink-diffusion Button That Spreads From the Click Point — Measured area 29.288% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Button Click Effect CSS: 9 Canvas Pixel Patterns
The post this demo lives in
Button Click Effect CSS: 9 Canvas Pixel PatternsNine button click effect css demos. The button cracks like glass for one reason: a crack-angle value tied to a press-sta See all demos in this post
If you like this effect - similar demos
Buttons03 Burst scatter — pieces scatter from the click point at differing angle values before fadingCTA Button Design: 9 Interaction Patterns
Buttons09 A Chat Bubble That Arcs From the Send Button and Lands in the ThreadSend Button Animation CSS: 9 Fold-and-Fly Buttons
Buttons02 Button that unlocks when the form is valid9 button state css patterns, loading to done