04ポインター距離の値で融合し広がるリキッドメタルボタン
ボタン表面に散らばった複数の粒がそれぞれポインターまでの距離の値を強さの値に変換し、その強さの値を足し合わせた場がしきい値を超えた場所同士を一つに融合して描く。
使いどころフローティングアクションボタン、カーソル追従CTA
fieldAtBLOB_COUNTbuildBlobs

<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 に同梱 · css ボタン クリック アニメーション 9選 — キャンバス計算
このデモが属する記事
css ボタン クリック アニメーション 9選 — キャンバス計算この記事ではcss ボタン クリック アニメーションを9種類、ピクセル計算で作ります。ボタンがガラスのようにひびが入って見える原因は、押下状態の値がオンになると伸びるひびの角度値ひとつだ。2番はその値でひびを描き、5番は同じ値を縁を走る電流 この記事のデモをすべて見る


