ホバー・ディストーション
画像にマウスを乗せると、その位置を中心に sin 波の歪み(ディストーション)が走る canvas エフェクト。ピクセル変位で水面や陽炎のような揺らぎを表現し、フォトギャラリーやヒーローに没入感を与えます。
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW:淹れたての湯気ゆらぎを canvas ディストーションで -->
<section class="mb-disp">
<!-- 歪む写真(canvas) -->
<figure class="mb-disp__media" id="mbDisp" tabindex="0" aria-label="ホバーで湯気の揺らぎ">
<canvas class="mb-disp__canvas" width="480" height="320"></canvas>
<span class="mb-disp__steam">~ 湯気のゆらぎ ~</span>
</figure>
<!-- メニュー説明 -->
<div class="mb-disp__info">
<span class="mb-disp__tag">SIGNATURE</span>
<h2 class="mb-disp__name">月夜のカフェラテ</h2>
<p class="mb-disp__price">¥ 680 <small>(HOT)</small></p>
<p class="mb-disp__desc">深煎り豆のコクとミルクのまろやかさ。立ちのぼる湯気まで、ごちそうです。</p>
<a class="mb-disp__btn" href="#">この一杯を注文</a>
</div>
</section>
CSS
/* MOON BREW:湯気ゆらぎディストーション付きメニュー */
:root {
--cream: #f5ede1;
--brown: #2b1d12;
--amber: #c98a3b;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
display: flex;
align-items: center;
gap: 30px;
padding: 0 30px;
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background:
radial-gradient(120% 100% at 0% 0%, #3a2818 0%, var(--brown) 65%);
color: var(--cream);
overflow: hidden;
}
/* 写真メディア */
.mb-disp__media {
position: relative;
flex: 0 0 360px;
height: 300px;
margin: 0;
border-radius: 18px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 18px 42px rgba(0,0,0,0.45);
outline: none;
}
.mb-disp__canvas {
width: 100%;
height: 100%;
display: block;
}
.mb-disp__steam {
position: absolute;
left: 14px;
bottom: 14px;
padding: 6px 13px;
border-radius: 999px;
font-size: 11px;
letter-spacing: 0.08em;
background: rgba(43,29,18,0.5);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
/* 右側のメニュー情報 */
.mb-disp__info { flex: 1; }
.mb-disp__tag { font-size: 10px; letter-spacing: 0.3em; color: var(--amber); }
.mb-disp__name {
margin: 10px 0 8px;
font-size: 26px;
font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
}
.mb-disp__price { margin: 0 0 14px; font-size: 22px; font-weight: 700; color: var(--amber); }
.mb-disp__price small { font-size: 12px; color: rgba(245,237,225,0.7); }
.mb-disp__desc {
margin: 0 0 22px;
font-size: 13px;
line-height: 1.9;
max-width: 280px;
color: rgba(245,237,225,0.82);
}
.mb-disp__btn {
display: inline-block;
padding: 11px 24px;
border-radius: 999px;
background: var(--amber);
color: #2b1d12;
font-size: 13px;
font-weight: 700;
text-decoration: none;
box-shadow: 0 8px 20px rgba(201,138,59,0.4);
transition: transform 0.2s ease;
}
.mb-disp__btn:hover { transform: translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
.mb-disp__btn { transition: none; }
}
JavaScript
// 湯気のゆらぎ:ホバー位置を中心に sin 波で行ごとに横変位する canvas
(() => {
const figure = document.getElementById("mbDisp");
const canvas = figure && figure.querySelector(".mb-disp__canvas");
if (!figure || !canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const W = canvas.width; // 内部解像度(固定)
const H = canvas.height;
// 元画像を保持するオフスクリーン
const src = document.createElement("canvas");
src.width = W;
src.height = H;
const sctx = src.getContext("2d");
// フォールバック:コーヒー色のグラデ+泡(CORS失敗時の保険)
const drawFallback = () => {
if (!sctx) return;
const g = sctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, "#5a3a1e");
g.addColorStop(0.55, "#cda06a");
g.addColorStop(1, "#3a2412");
sctx.fillStyle = g;
sctx.fillRect(0, 0, W, H);
sctx.globalAlpha = 0.18;
sctx.fillStyle = "#f5ede1";
for (let y = 40; y < H; y += 60) {
for (let x = 40; x < W; x += 60) {
sctx.beginPath();
sctx.arc(x, y, 8, 0, Math.PI * 2);
sctx.fill();
}
}
sctx.globalAlpha = 1;
};
drawFallback(); // 画像ロード前から動かす
// picsum 画像を CORS 安全にロード
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
// cover 風にトリミング描画
const ar = img.width / img.height;
const car = W / H;
let dw = W, dh = H, dx = 0, dy = 0;
if (ar > car) { dh = H; dw = H * ar; dx = (W - dw) / 2; }
else { dw = W; dh = W / ar; dy = (H - dh) / 2; }
sctx.drawImage(img, dx, dy, dw, dh);
sctx.getImageData(0, 0, 1, 1); // tainted 検証
} catch {
drawFallback();
}
};
img.onerror = () => drawFallback();
img.src = "https://picsum.photos/seed/moonbrew-latte/480/320";
// マウス状態
let mx = W / 2, my = H / 2;
let strength = 0; // 0..1
let active = false;
const toLocal = (cx, cy) => {
const r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return;
mx = ((cx - r.left) / r.width) * W;
my = ((cy - r.top) / r.height) * H;
};
figure.addEventListener("pointermove", (e) => { active = true; toLocal(e.clientX, e.clientY); });
figure.addEventListener("pointerenter", () => { active = true; });
figure.addEventListener("pointerleave", () => { active = false; });
figure.addEventListener("focus", () => { active = true; mx = W / 2; my = H / 2; });
figure.addEventListener("blur", () => { active = false; });
// 行ごとに横オフセット転写する軽量ディストーション
let t = 0;
let raf = 0;
const render = () => {
t += 0.05;
strength += ((active ? 1 : 0) - strength) * 0.08;
ctx.clearRect(0, 0, W, H);
if (strength < 0.01) {
ctx.drawImage(src, 0, 0);
raf = 0;
return;
}
const amp = 14 * strength; // 最大変位(px)
for (let y = 0; y < H; y++) {
// マウス中心ほど揺らぎを強調
const dyN = (y - my) / H;
const focus = Math.exp(-(dyN * dyN) * 6) * 0.7 + 0.3;
const phase = (y / H) * Math.PI * 6 + t + (mx / W) * Math.PI * 2;
const dx = Math.sin(phase) * amp * focus;
const wob = Math.sin(t * 1.3 + y * 0.03) * amp * 0.3 * focus;
ctx.drawImage(src, 0, y, W, 1, dx, y + wob, W, 1);
}
raf = requestAnimationFrame(render);
};
const kick = () => { if (!raf) raf = requestAnimationFrame(render); };
figure.addEventListener("pointerenter", kick);
figure.addEventListener("pointermove", kick);
figure.addEventListener("focus", kick);
// 初期は静止画。src 反映後にもう一度描く
const initial = () => { ctx.clearRect(0, 0, W, H); ctx.drawImage(src, 0, 0); };
initial();
setTimeout(initial, 400);
})();
コード
HTML
<!-- ホバーで sin 波の歪みが走る canvas ディストーション -->
<div class="stage">
<figure class="disp" tabindex="0" aria-label="ディストーション画像">
<!-- 描画先 canvas。元画像はJS内で crossOrigin 付きロード or フォールバック描画 -->
<canvas class="disp__canvas" width="480" height="320"></canvas>
<figcaption class="disp__cap">HOVER ME</figcaption>
</figure>
</div>
CSS
:root {
--bg: #0a0c12;
--radius: 12px;
--cap: rgba(255, 255, 255, .9);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(120% 120% at 50% 0%, #141826 0%, var(--bg) 70%);
font-family: "Courier New", ui-monospace, monospace;
}
.stage { padding: 24px; }
.disp {
position: relative;
width: min(72vw, 420px);
aspect-ratio: 3 / 2;
margin: 0;
border-radius: var(--radius);
overflow: hidden;
cursor: crosshair;
outline: none;
box-shadow: 0 20px 50px -18px rgba(0, 0, 0, .85);
}
.disp:focus-visible { box-shadow: 0 0 0 3px #4ad7ff, 0 20px 50px -18px rgba(0, 0, 0, .85); }
/* canvas は枠いっぱいに表示(内部解像度は固定) */
.disp__canvas {
display: block;
width: 100%;
height: 100%;
}
/* キャプション。ホバーで少し沈ませる */
.disp__cap {
position: absolute;
left: 14px;
bottom: 12px;
z-index: 2;
font-size: 14px;
font-weight: 700;
letter-spacing: .35em;
color: var(--cap);
text-shadow: 0 2px 8px rgba(0, 0, 0, .6);
transition: opacity .3s ease;
pointer-events: none;
}
.disp:hover .disp__cap,
.disp:focus-visible .disp__cap { opacity: 0; }
JavaScript
// ホバー位置を中心に sin 波でピクセル変位させる canvas ディストーション
(() => {
const canvas = document.querySelector(".disp__canvas");
const figure = document.querySelector(".disp");
if (!canvas || !figure) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const W = canvas.width; // 内部解像度(固定)
const H = canvas.height;
// 元画像を保持するオフスクリーン canvas
const src = document.createElement("canvas");
src.width = W;
src.height = H;
const sctx = src.getContext("2d");
// --- フォールバック描画:グラデ+幾何パターン(CORS事故/取得失敗時の保険)---
const drawFallback = () => {
if (!sctx) return;
const g = sctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, "#1b2a4a");
g.addColorStop(0.5, "#5a3a8a");
g.addColorStop(1, "#c2466b");
sctx.fillStyle = g;
sctx.fillRect(0, 0, W, H);
// 斜めストライプ
sctx.globalAlpha = 0.12;
sctx.fillStyle = "#ffffff";
for (let x = -H; x < W; x += 34) {
sctx.beginPath();
sctx.moveTo(x, 0);
sctx.lineTo(x + H, H);
sctx.lineTo(x + H + 14, H);
sctx.lineTo(x + 14, 0);
sctx.closePath();
sctx.fill();
}
// 円ドット
sctx.globalAlpha = 0.16;
sctx.fillStyle = "#ffe27a";
for (let y = 40; y < H; y += 70) {
for (let x = 40; x < W; x += 70) {
sctx.beginPath();
sctx.arc(x, y, 10, 0, Math.PI * 2);
sctx.fill();
}
}
sctx.globalAlpha = 1;
};
// まずフォールバックを敷いておく(画像ロード前から動かす)
drawFallback();
// --- picsum 画像を CORS 安全にロード。失敗してもフォールバック維持 ---
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
// cover 風にトリミング描画
const ar = img.width / img.height;
const car = W / H;
let dw = W, dh = H, dx = 0, dy = 0;
if (ar > car) { dh = H; dw = H * ar; dx = (W - dw) / 2; }
else { dw = W; dh = W / ar; dy = (H - dh) / 2; }
sctx.drawImage(img, dx, dy, dw, dh);
// 画像がCORSで汚染されていないか getImageData で検証
sctx.getImageData(0, 0, 1, 1);
} catch {
drawFallback(); // tainted なら描き直して安全に
}
};
img.onerror = () => drawFallback();
// ランダムなシード画像(読めなくても問題なし)
img.src = "https://picsum.photos/seed/disp42/480/320";
// --- マウス状態 ---
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let mx = W / 2, my = H / 2; // 内部座標での歪み中心
let strength = 0; // 0..1 の歪み強度(ホバーで上昇)
let active = false;
const toLocal = (clientX, clientY) => {
const r = canvas.getBoundingClientRect();
mx = ((clientX - r.left) / r.width) * W;
my = ((clientY - r.top) / r.height) * H;
};
figure.addEventListener("pointermove", (e) => { active = true; toLocal(e.clientX, e.clientY); });
figure.addEventListener("pointerenter", () => { active = true; });
figure.addEventListener("pointerleave", () => { active = false; });
figure.addEventListener("focus", () => { active = true; mx = W / 2; my = H / 2; });
figure.addEventListener("blur", () => { active = false; });
// --- 行ごとに横オフセットして転写する軽量ディストーション ---
let t = 0;
let raf = 0;
const render = () => {
t += 0.05;
// 強度をなめらかに追従
strength += ((active ? 1 : 0) - strength) * 0.08;
ctx.clearRect(0, 0, W, H);
if (strength < 0.01) {
// 歪みほぼ無し:そのまま描画して停止
ctx.drawImage(src, 0, 0);
raf = 0;
return;
}
const amp = 16 * strength; // 最大変位(px)
for (let y = 0; y < H; y++) {
// マウス中心ほど歪みを強調(ガウシアン風の減衰)
const dy = (y - my) / H;
const focus = Math.exp(-(dy * dy) * 6) * 0.7 + 0.3;
// 横方向の sin 波 + マウスX で位相をずらす
const phase = (y / H) * Math.PI * 6 + t + (mx / W) * Math.PI * 2;
const dx = Math.sin(phase) * amp * focus;
const wob = Math.sin(t * 1.3 + y * 0.03) * amp * 0.35 * focus; // 縦揺れ
// 1行ぶんを横にずらして転写
ctx.drawImage(src, 0, y, W, 1, dx, y + wob, W, 1);
}
raf = requestAnimationFrame(render);
};
// 描画ループの起動(多重起動を防ぐ)
const kick = () => { if (!raf && !reduce) raf = requestAnimationFrame(render); };
figure.addEventListener("pointerenter", kick);
figure.addEventListener("pointermove", kick);
figure.addEventListener("focus", kick);
// 初期描画(静止画)。reduce 指定時は歪みアニメ無しで静止表示
const initial = () => {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(src, 0, 0);
};
// src に画像が乗るのを少し待ってから初期描画(フォールバックは即時反映済み)
initial();
setTimeout(initial, 400);
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ホバー・ディストーション」の効果を追加してください。
# 追加してほしい効果
ホバー・ディストーション(画像エフェクト)
画像にマウスを乗せると、その位置を中心に sin 波の歪み(ディストーション)が走る canvas エフェクト。ピクセル変位で水面や陽炎のような揺らぎを表現し、フォトギャラリーやヒーローに没入感を与えます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ホバーで sin 波の歪みが走る canvas ディストーション -->
<div class="stage">
<figure class="disp" tabindex="0" aria-label="ディストーション画像">
<!-- 描画先 canvas。元画像はJS内で crossOrigin 付きロード or フォールバック描画 -->
<canvas class="disp__canvas" width="480" height="320"></canvas>
<figcaption class="disp__cap">HOVER ME</figcaption>
</figure>
</div>
【CSS】
:root {
--bg: #0a0c12;
--radius: 12px;
--cap: rgba(255, 255, 255, .9);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(120% 120% at 50% 0%, #141826 0%, var(--bg) 70%);
font-family: "Courier New", ui-monospace, monospace;
}
.stage { padding: 24px; }
.disp {
position: relative;
width: min(72vw, 420px);
aspect-ratio: 3 / 2;
margin: 0;
border-radius: var(--radius);
overflow: hidden;
cursor: crosshair;
outline: none;
box-shadow: 0 20px 50px -18px rgba(0, 0, 0, .85);
}
.disp:focus-visible { box-shadow: 0 0 0 3px #4ad7ff, 0 20px 50px -18px rgba(0, 0, 0, .85); }
/* canvas は枠いっぱいに表示(内部解像度は固定) */
.disp__canvas {
display: block;
width: 100%;
height: 100%;
}
/* キャプション。ホバーで少し沈ませる */
.disp__cap {
position: absolute;
left: 14px;
bottom: 12px;
z-index: 2;
font-size: 14px;
font-weight: 700;
letter-spacing: .35em;
color: var(--cap);
text-shadow: 0 2px 8px rgba(0, 0, 0, .6);
transition: opacity .3s ease;
pointer-events: none;
}
.disp:hover .disp__cap,
.disp:focus-visible .disp__cap { opacity: 0; }
【JavaScript】
// ホバー位置を中心に sin 波でピクセル変位させる canvas ディストーション
(() => {
const canvas = document.querySelector(".disp__canvas");
const figure = document.querySelector(".disp");
if (!canvas || !figure) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const W = canvas.width; // 内部解像度(固定)
const H = canvas.height;
// 元画像を保持するオフスクリーン canvas
const src = document.createElement("canvas");
src.width = W;
src.height = H;
const sctx = src.getContext("2d");
// --- フォールバック描画:グラデ+幾何パターン(CORS事故/取得失敗時の保険)---
const drawFallback = () => {
if (!sctx) return;
const g = sctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, "#1b2a4a");
g.addColorStop(0.5, "#5a3a8a");
g.addColorStop(1, "#c2466b");
sctx.fillStyle = g;
sctx.fillRect(0, 0, W, H);
// 斜めストライプ
sctx.globalAlpha = 0.12;
sctx.fillStyle = "#ffffff";
for (let x = -H; x < W; x += 34) {
sctx.beginPath();
sctx.moveTo(x, 0);
sctx.lineTo(x + H, H);
sctx.lineTo(x + H + 14, H);
sctx.lineTo(x + 14, 0);
sctx.closePath();
sctx.fill();
}
// 円ドット
sctx.globalAlpha = 0.16;
sctx.fillStyle = "#ffe27a";
for (let y = 40; y < H; y += 70) {
for (let x = 40; x < W; x += 70) {
sctx.beginPath();
sctx.arc(x, y, 10, 0, Math.PI * 2);
sctx.fill();
}
}
sctx.globalAlpha = 1;
};
// まずフォールバックを敷いておく(画像ロード前から動かす)
drawFallback();
// --- picsum 画像を CORS 安全にロード。失敗してもフォールバック維持 ---
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
// cover 風にトリミング描画
const ar = img.width / img.height;
const car = W / H;
let dw = W, dh = H, dx = 0, dy = 0;
if (ar > car) { dh = H; dw = H * ar; dx = (W - dw) / 2; }
else { dw = W; dh = W / ar; dy = (H - dh) / 2; }
sctx.drawImage(img, dx, dy, dw, dh);
// 画像がCORSで汚染されていないか getImageData で検証
sctx.getImageData(0, 0, 1, 1);
} catch {
drawFallback(); // tainted なら描き直して安全に
}
};
img.onerror = () => drawFallback();
// ランダムなシード画像(読めなくても問題なし)
img.src = "https://picsum.photos/seed/disp42/480/320";
// --- マウス状態 ---
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let mx = W / 2, my = H / 2; // 内部座標での歪み中心
let strength = 0; // 0..1 の歪み強度(ホバーで上昇)
let active = false;
const toLocal = (clientX, clientY) => {
const r = canvas.getBoundingClientRect();
mx = ((clientX - r.left) / r.width) * W;
my = ((clientY - r.top) / r.height) * H;
};
figure.addEventListener("pointermove", (e) => { active = true; toLocal(e.clientX, e.clientY); });
figure.addEventListener("pointerenter", () => { active = true; });
figure.addEventListener("pointerleave", () => { active = false; });
figure.addEventListener("focus", () => { active = true; mx = W / 2; my = H / 2; });
figure.addEventListener("blur", () => { active = false; });
// --- 行ごとに横オフセットして転写する軽量ディストーション ---
let t = 0;
let raf = 0;
const render = () => {
t += 0.05;
// 強度をなめらかに追従
strength += ((active ? 1 : 0) - strength) * 0.08;
ctx.clearRect(0, 0, W, H);
if (strength < 0.01) {
// 歪みほぼ無し:そのまま描画して停止
ctx.drawImage(src, 0, 0);
raf = 0;
return;
}
const amp = 16 * strength; // 最大変位(px)
for (let y = 0; y < H; y++) {
// マウス中心ほど歪みを強調(ガウシアン風の減衰)
const dy = (y - my) / H;
const focus = Math.exp(-(dy * dy) * 6) * 0.7 + 0.3;
// 横方向の sin 波 + マウスX で位相をずらす
const phase = (y / H) * Math.PI * 6 + t + (mx / W) * Math.PI * 2;
const dx = Math.sin(phase) * amp * focus;
const wob = Math.sin(t * 1.3 + y * 0.03) * amp * 0.35 * focus; // 縦揺れ
// 1行ぶんを横にずらして転写
ctx.drawImage(src, 0, y, W, 1, dx, y + wob, W, 1);
}
raf = requestAnimationFrame(render);
};
// 描画ループの起動(多重起動を防ぐ)
const kick = () => { if (!raf && !reduce) raf = requestAnimationFrame(render); };
figure.addEventListener("pointerenter", kick);
figure.addEventListener("pointermove", kick);
figure.addEventListener("focus", kick);
// 初期描画(静止画)。reduce 指定時は歪みアニメ無しで静止表示
const initial = () => {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(src, 0, 0);
};
// src に画像が乗るのを少し待ってから初期描画(フォールバックは即時反映済み)
initial();
setTimeout(initial, 400);
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。