メタボール(リキッド)
動き回る複数の円が近づくと滑らかに融合して見えるメタボール表現。距離場のしきい値で輪郭を描き、粘性のある液体のような有機的ビジュアルを生成します。
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW:新作ドリンクのヒーロー(とろけるメタボール背景) -->
<section class="mb-drink">
<!-- 主役:液体のように融合するメタボール -->
<canvas class="mb-drink__fx" id="mbMeta"></canvas>
<!-- 前景UI:新作ドリンク告知 -->
<div class="mb-drink__inner">
<span class="mb-drink__tag">NEW ARRIVAL</span>
<h1 class="mb-drink__title">とろける、<br>キャラメルラテ。</h1>
<p class="mb-drink__lead">ミルクとカラメルがゆっくり混ざり合う、まろやかな一杯。今週末から登場します。</p>
<div class="mb-drink__row">
<span class="mb-drink__price">¥640</span>
<a class="mb-drink__btn" href="#">メニューを見る</a>
</div>
</div>
</section>
CSS
/* MOON BREW:メタボール背景の新作ドリンクヒーロー */
:root {
--cream: #f5ede1;
--brown: #2b1d12;
--amber: #c98a3b;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
overflow: hidden;
}
.mb-drink {
position: relative;
height: 400px;
overflow: hidden;
background: linear-gradient(160deg, #3a2716, #241710);
}
/* 主役:とろける液体のキャンバス(全面) */
.mb-drink__fx {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
}
.mb-drink__inner {
position: relative;
z-index: 2;
padding: 40px 32px;
max-width: 400px;
color: var(--cream);
pointer-events: none;
}
.mb-drink__inner a { pointer-events: auto; }
.mb-drink__tag {
display: inline-block;
font-size: 10px;
letter-spacing: 0.3em;
color: #ffce97;
font-weight: 700;
}
.mb-drink__title {
margin: 13px 0 14px;
font-size: 32px;
line-height: 1.4;
font-weight: 700;
font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
text-shadow: 0 2px 12px rgba(0,0,0,0.3);
}
.mb-drink__lead {
margin: 0 0 24px;
font-size: 13px;
line-height: 1.85;
color: rgba(245,237,225,0.85);
max-width: 330px;
}
.mb-drink__row { display: flex; align-items: center; gap: 18px; }
.mb-drink__price {
font-size: 26px;
font-weight: 800;
color: #ffce97;
}
.mb-drink__btn {
display: inline-block;
padding: 11px 24px;
border-radius: 999px;
background: var(--amber);
color: #fff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
box-shadow: 0 10px 24px rgba(201,138,59,0.45);
transition: transform 0.2s ease;
}
.mb-drink__btn:hover { transform: translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
.mb-drink__btn { transition: none; }
}
JavaScript
// MOON BREW:メタボール(ミルク×カラメルが融合する液体表現)
(() => {
const canvas = document.getElementById('mbMeta');
if (!canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = 0, h = 0, raf = 0, running = true;
// 距離場計算は低解像度のオフスクリーンで行い負荷を抑える
let bw = 0, bh = 0, img = null, buf = null;
const STEP = 4; // 低解像度の倍率
const off = document.createElement('canvas');
const octx = off.getContext('2d');
const balls = [];
function resize() {
const r = canvas.getBoundingClientRect();
w = Math.max(1, Math.floor(r.width));
h = Math.max(1, Math.floor(r.height));
canvas.width = w;
canvas.height = h;
bw = Math.max(1, Math.ceil(w / STEP));
bh = Math.max(1, Math.ceil(h / STEP));
off.width = bw;
off.height = bh;
img = octx ? octx.createImageData(bw, bh) : null;
buf = img ? new Uint32Array(img.data.buffer) : null;
}
function makeBalls() {
balls.length = 0;
const count = 6;
for (let i = 0; i < count; i++) {
balls.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 1.6,
vy: (Math.random() - 0.5) * 1.6,
r: 26 + Math.random() * 22
});
}
}
resize();
makeBalls();
// 距離場のしきい値で色を決める(カラメル→ミルク)
function colorFor(sum) {
if (sum > 1.6) return 0xffe9d6f3; // 明るいミルク (ABGR)
if (sum > 1.0) return 0xff5fa0c9; // amber寄り
if (sum > 0.72) return 0xff2b4d72; // 縁取り
return 0x00000000; // 透明
}
function step() {
// ボールの移動と壁反射
for (const b of balls) {
b.x += b.vx;
b.y += b.vy;
if (b.x < b.r || b.x > w - b.r) b.vx *= -1;
if (b.y < b.r || b.y > h - b.r) b.vy *= -1;
}
// 低解像度で距離場を評価
if (buf && octx) {
for (let y = 0; y < bh; y++) {
const py = y * STEP;
for (let x = 0; x < bw; x++) {
const px = x * STEP;
let sum = 0;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
const dx = px - b.x;
const dy = py - b.y;
sum += (b.r * b.r) / (dx * dx + dy * dy + 1);
}
buf[y * bw + x] = colorFor(sum);
}
}
octx.putImageData(img, 0, 0);
}
// オフスクリーンを拡大描画(スムージングでとろみを表現)
ctx.clearRect(0, 0, w, h);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, 0, bw, bh, 0, 0, w, h);
raf = requestAnimationFrame(step);
}
function start() {
if (running) return;
running = true;
raf = requestAnimationFrame(step);
}
function stop() {
running = false;
cancelAnimationFrame(raf);
}
window.addEventListener('resize', () => { resize(); makeBalls(); });
document.addEventListener('visibilitychange', () => {
document.hidden ? stop() : start();
});
running = false;
start();
})();
コード
HTML
<!-- メタボール(リキッド)デモ -->
<div class="stage">
<canvas id="metaCanvas"></canvas>
<div class="hint">液体のように融合するメタボール</div>
</div>
CSS
/* メタボールのステージ */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
position: relative;
width: 100%;
height: 360px;
overflow: hidden;
font-family: "Segoe UI", system-ui, sans-serif;
background: radial-gradient(900px 500px at 50% 40%, #0b1d2e, #04070d 85%);
}
#metaCanvas {
display: block;
width: 100%;
height: 100%;
}
/* 下部の説明ラベル */
.hint {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
color: rgba(150, 220, 255, .5);
font-size: 12px;
letter-spacing: .08em;
pointer-events: none;
user-select: none;
}
JavaScript
// メタボール(リキッド)デモ — 距離場のしきい値で円を融合表示
(() => {
const canvas = document.getElementById('metaCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 表示解像度とは別に、低解像度バッファで距離場を計算(軽量化)
const SCALE = 4; // 1ピクセル=実画面4px相当
let w = 0, h = 0; // CSSピクセルサイズ
let gw = 0, gh = 0; // 計算グリッドサイズ
let buffer = null; // ImageData
let rafId = 0, running = false;
const THRESHOLD = 1.0; // この値を超えた領域を液体とみなす
// メタボール定義(位置・速度・半径相当の強さ)
const balls = [];
function makeBalls() {
const count = 6;
balls.length = 0;
for (let i = 0; i < count; i++) {
balls.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() * 2 - 1) * 1.1,
vy: (Math.random() * 2 - 1) * 1.1,
r: Math.random() * 28 + 34,
hue: 180 + i * 28
});
}
}
function resize() {
const rect = canvas.getBoundingClientRect();
w = Math.max(1, Math.round(rect.width));
h = Math.max(1, Math.round(rect.height));
canvas.width = w;
canvas.height = h;
gw = Math.max(1, Math.ceil(w / SCALE));
gh = Math.max(1, Math.ceil(h / SCALE));
buffer = ctx.createImageData(gw, gh);
}
// 距離場を評価して低解像度バッファに着色
function render() {
if (!buffer) return;
const data = buffer.data;
for (let gy = 0; gy < gh; gy++) {
const py = gy * SCALE;
for (let gx = 0; gx < gw; gx++) {
const px = gx * SCALE;
let sum = 0, hueAcc = 0;
// 各ボールの寄与(r^2 / 距離^2 を加算)
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
const dx = px - b.x;
const dy = py - b.y;
const d2 = dx * dx + dy * dy + 1;
const f = (b.r * b.r) / d2;
sum += f;
hueAcc += f * b.hue;
}
const idx = (gy * gw + gx) * 4;
if (sum >= THRESHOLD) {
const hue = hueAcc / sum;
// しきい値付近をやわらかく発光させる
const edge = Math.min(1, (sum - THRESHOLD) * 1.3);
const rgb = hslToRgb(hue / 360, 0.85, 0.45 + edge * 0.18);
data[idx] = rgb[0];
data[idx + 1] = rgb[1];
data[idx + 2] = rgb[2];
data[idx + 3] = Math.round(120 + edge * 135);
} else {
data[idx + 3] = 0;
}
}
}
// 低解像度バッファを全面に拡大描画(補間で滑らかに融合)
ctx.clearRect(0, 0, w, h);
putScaled();
}
// ImageData をスケール拡大して描画
let tmp = null, tmpCtx = null;
function putScaled() {
if (!tmp) {
tmp = document.createElement('canvas');
tmpCtx = tmp.getContext('2d');
}
if (tmp.width !== gw || tmp.height !== gh) {
tmp.width = gw; tmp.height = gh;
}
tmpCtx.putImageData(buffer, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(tmp, 0, 0, gw, gh, 0, 0, w, h);
}
// HSL→RGB 変換(0..1入力, 0..255出力)
function hslToRgb(hh, s, l) {
let r, g, b;
if (s === 0) { r = g = b = l; }
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, hh + 1 / 3);
g = hue2rgb(p, q, hh);
b = hue2rgb(p, q, hh - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
// ボールを動かして壁で反射
function update() {
for (const b of balls) {
b.x += b.vx;
b.y += b.vy;
if (b.x < b.r * 0.4 || b.x > w - b.r * 0.4) b.vx *= -1;
if (b.y < b.r * 0.4 || b.y > h - b.r * 0.4) b.vy *= -1;
b.x = Math.max(b.r * 0.4, Math.min(w - b.r * 0.4, b.x));
b.y = Math.max(b.r * 0.4, Math.min(h - b.r * 0.4, b.y));
}
}
function step() {
if (!running) return;
if (!reduced) update();
render();
rafId = requestAnimationFrame(step);
}
// rAFの二重起動を防いで開始
function start() {
if (running) return;
running = true;
rafId = requestAnimationFrame(step);
}
function stop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
}
resize();
makeBalls();
window.addEventListener('resize', () => { resize(); });
// タブ非表示で停止、復帰で再開
document.addEventListener('visibilitychange', () => {
if (document.hidden) stop(); else start();
});
// reduced環境では1フレームだけ描いて静止
if (reduced) { render(); } else { start(); }
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「メタボール(リキッド)」の効果を追加してください。
# 追加してほしい効果
メタボール(リキッド)(Canvas エフェクト)
動き回る複数の円が近づくと滑らかに融合して見えるメタボール表現。距離場のしきい値で輪郭を描き、粘性のある液体のような有機的ビジュアルを生成します。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- メタボール(リキッド)デモ -->
<div class="stage">
<canvas id="metaCanvas"></canvas>
<div class="hint">液体のように融合するメタボール</div>
</div>
【CSS】
/* メタボールのステージ */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
position: relative;
width: 100%;
height: 360px;
overflow: hidden;
font-family: "Segoe UI", system-ui, sans-serif;
background: radial-gradient(900px 500px at 50% 40%, #0b1d2e, #04070d 85%);
}
#metaCanvas {
display: block;
width: 100%;
height: 100%;
}
/* 下部の説明ラベル */
.hint {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
color: rgba(150, 220, 255, .5);
font-size: 12px;
letter-spacing: .08em;
pointer-events: none;
user-select: none;
}
【JavaScript】
// メタボール(リキッド)デモ — 距離場のしきい値で円を融合表示
(() => {
const canvas = document.getElementById('metaCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 表示解像度とは別に、低解像度バッファで距離場を計算(軽量化)
const SCALE = 4; // 1ピクセル=実画面4px相当
let w = 0, h = 0; // CSSピクセルサイズ
let gw = 0, gh = 0; // 計算グリッドサイズ
let buffer = null; // ImageData
let rafId = 0, running = false;
const THRESHOLD = 1.0; // この値を超えた領域を液体とみなす
// メタボール定義(位置・速度・半径相当の強さ)
const balls = [];
function makeBalls() {
const count = 6;
balls.length = 0;
for (let i = 0; i < count; i++) {
balls.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() * 2 - 1) * 1.1,
vy: (Math.random() * 2 - 1) * 1.1,
r: Math.random() * 28 + 34,
hue: 180 + i * 28
});
}
}
function resize() {
const rect = canvas.getBoundingClientRect();
w = Math.max(1, Math.round(rect.width));
h = Math.max(1, Math.round(rect.height));
canvas.width = w;
canvas.height = h;
gw = Math.max(1, Math.ceil(w / SCALE));
gh = Math.max(1, Math.ceil(h / SCALE));
buffer = ctx.createImageData(gw, gh);
}
// 距離場を評価して低解像度バッファに着色
function render() {
if (!buffer) return;
const data = buffer.data;
for (let gy = 0; gy < gh; gy++) {
const py = gy * SCALE;
for (let gx = 0; gx < gw; gx++) {
const px = gx * SCALE;
let sum = 0, hueAcc = 0;
// 各ボールの寄与(r^2 / 距離^2 を加算)
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
const dx = px - b.x;
const dy = py - b.y;
const d2 = dx * dx + dy * dy + 1;
const f = (b.r * b.r) / d2;
sum += f;
hueAcc += f * b.hue;
}
const idx = (gy * gw + gx) * 4;
if (sum >= THRESHOLD) {
const hue = hueAcc / sum;
// しきい値付近をやわらかく発光させる
const edge = Math.min(1, (sum - THRESHOLD) * 1.3);
const rgb = hslToRgb(hue / 360, 0.85, 0.45 + edge * 0.18);
data[idx] = rgb[0];
data[idx + 1] = rgb[1];
data[idx + 2] = rgb[2];
data[idx + 3] = Math.round(120 + edge * 135);
} else {
data[idx + 3] = 0;
}
}
}
// 低解像度バッファを全面に拡大描画(補間で滑らかに融合)
ctx.clearRect(0, 0, w, h);
putScaled();
}
// ImageData をスケール拡大して描画
let tmp = null, tmpCtx = null;
function putScaled() {
if (!tmp) {
tmp = document.createElement('canvas');
tmpCtx = tmp.getContext('2d');
}
if (tmp.width !== gw || tmp.height !== gh) {
tmp.width = gw; tmp.height = gh;
}
tmpCtx.putImageData(buffer, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(tmp, 0, 0, gw, gh, 0, 0, w, h);
}
// HSL→RGB 変換(0..1入力, 0..255出力)
function hslToRgb(hh, s, l) {
let r, g, b;
if (s === 0) { r = g = b = l; }
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, hh + 1 / 3);
g = hue2rgb(p, q, hh);
b = hue2rgb(p, q, hh - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
// ボールを動かして壁で反射
function update() {
for (const b of balls) {
b.x += b.vx;
b.y += b.vy;
if (b.x < b.r * 0.4 || b.x > w - b.r * 0.4) b.vx *= -1;
if (b.y < b.r * 0.4 || b.y > h - b.r * 0.4) b.vy *= -1;
b.x = Math.max(b.r * 0.4, Math.min(w - b.r * 0.4, b.x));
b.y = Math.max(b.r * 0.4, Math.min(h - b.r * 0.4, b.y));
}
}
function step() {
if (!running) return;
if (!reduced) update();
render();
rafId = requestAnimationFrame(step);
}
// rAFの二重起動を防いで開始
function start() {
if (running) return;
running = true;
rafId = requestAnimationFrame(step);
}
function stop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
}
resize();
makeBalls();
window.addEventListener('resize', () => { resize(); });
// タブ非表示で停止、復帰で再開
document.addEventListener('visibilitychange', () => {
if (document.hidden) stop(); else start();
});
// reduced環境では1フレームだけ描いて静止
if (reduced) { render(); } else { start(); }
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。