磁力グリッド
ドットの格子がカーソルの磁場で引き寄せ・反発し、近づくほど大きく波打って歪みます。待機中は仮想カーソルが巡回して常に動き、pointermoveで本物に追従。ヒーロー背景やインタラクティブな装飾に使えます。
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW: ブランドヒーロー。背景に磁力グリッドを主役で敷く -->
<div class="mb">
<canvas class="mg-canvas" aria-hidden="true"></canvas>
<div class="mb__overlay">
<header class="mb__nav">
<span class="mb__logo">☕ MOON BREW</span>
<nav class="mb__menu">
<span>MENU</span><span>STORE</span><span>ABOUT</span>
</nav>
</header>
<div class="mb__center">
<p class="mb__eyebrow">SINCE 2014 ・ SHIBUYA</p>
<h1 class="mb__title">月あかりの、<br>一杯を。</h1>
<p class="mb__lead">深夜まで灯る、自家焙煎のスペシャルティコーヒー。</p>
<span class="mb__btn">店舗を探す →</span>
</div>
<p class="mb__hint">背景にカーソルを動かすとドットが波打ちます</p>
</div>
</div>
CSS
/* MOON BREW カフェ テーマ: 濃ブラウン背景に琥珀グリッド */
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Hiragino Mincho ProN", "Segoe UI", system-ui, serif;
color: #f5ede1;
overflow: hidden;
}
.mb {
position: relative;
height: 400px;
background:
radial-gradient(circle at 30% 20%, #3a2718 0%, transparent 55%),
#211309;
}
/* 主役: 磁力グリッドのcanvas(背景全面) */
.mg-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.mb__overlay {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
padding: 20px 30px 14px;
pointer-events: none; /* グリッドへポインタを通す */
}
.mb__nav {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb__logo {
font-size: 18px;
font-weight: 700;
letter-spacing: .05em;
}
.mb__menu {
display: flex;
gap: 22px;
font-size: 12px;
letter-spacing: .12em;
color: rgba(245,237,225,.7);
font-family: "Segoe UI", system-ui, sans-serif;
}
.mb__center {
flex: 1;
display: grid;
align-content: center;
gap: 14px;
}
.mb__eyebrow {
margin: 0;
font-size: 12px;
letter-spacing: .22em;
color: #c98a3b;
font-family: "Segoe UI", system-ui, sans-serif;
}
.mb__title {
margin: 0;
font-size: 40px;
font-weight: 700;
line-height: 1.28;
text-shadow: 0 6px 24px rgba(0,0,0,.5);
}
.mb__lead {
margin: 0;
font-size: 13px;
color: rgba(245,237,225,.72);
font-family: "Segoe UI", system-ui, sans-serif;
}
.mb__btn {
justify-self: start;
margin-top: 6px;
font-family: "Segoe UI", system-ui, sans-serif;
font-size: 13px;
font-weight: 700;
color: #211309;
background: linear-gradient(135deg, #d9b073, #c98a3b);
padding: 11px 24px;
border-radius: 999px;
box-shadow: 0 10px 26px rgba(201,138,59,.4);
}
.mb__hint {
margin: 0;
font-size: 11px;
color: rgba(245,237,225,.4);
font-family: "Segoe UI", system-ui, sans-serif;
}
JavaScript
// MOON BREW背景: ドット格子をカーソルの磁場で引き寄せて波打たせる
(() => {
const canvas = document.querySelector('.mg-canvas');
if (!canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 設定値
const GAP = 32; // 格子間隔(px)
const RADIUS = 120; // 磁場の有効半径(px)
const PULL = 24; // 最大変位量(px)
const EASE = 0.14; // 目標へ寄る滑らかさ
const COLORS = ['#7a5224', '#c98a3b', '#f0c889']; // 琥珀グラデーション
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let dots = [];
let W = 0, H = 0;
let rafId = null;
// 待機中は仮想カーソル、pointermoveで本物に切替
const pointer = { x: 0, y: 0 };
let useReal = false;
let lastMove = 0;
const IDLE_MS = 1800;
// 格子を再構築(リサイズ対応)
const build = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || 1;
H = canvas.clientHeight || 1;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
dots = [];
const cols = Math.max(1, Math.floor((W - GAP) / GAP) + 1);
const rows = Math.max(1, Math.floor((H - GAP) / GAP) + 1);
const offX = (W - (cols - 1) * GAP) / 2;
const offY = (H - (rows - 1) * GAP) / 2;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const bx = offX + c * GAP;
const by = offY + r * GAP;
dots.push({ bx, by, x: bx, y: by });
}
}
if (!useReal) { pointer.x = W / 2; pointer.y = H / 2; }
};
// 仮想カーソルの巡回(リサジューでゆっくり8の字)
const virtualPointer = (t) => {
const cx = W / 2, cy = H / 2;
pointer.x = cx + Math.sin(t * 0.0007) * W * 0.34;
pointer.y = cy + Math.sin(t * 0.0011 + 0.6) * H * 0.30;
};
// 距離に応じた色を線形補間で作る
const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const C0 = hex(COLORS[0]), C1 = hex(COLORS[1]), C2 = hex(COLORS[2]);
const mixColor = (t) => {
let a, b, k;
if (t < 0.5) { a = C0; b = C1; k = t * 2; }
else { a = C1; b = C2; k = (t - 0.5) * 2; }
const r = (a[0] + (b[0] - a[0]) * k) | 0;
const g = (a[1] + (b[1] - a[1]) * k) | 0;
const bl = (a[2] + (b[2] - a[2]) * k) | 0;
return `rgb(${r},${g},${bl})`;
};
// 1フレーム更新+描画
const loop = (t) => {
if (useReal && t - lastMove > IDLE_MS) useReal = false;
if (!useReal && !reduce) virtualPointer(t);
ctx.clearRect(0, 0, W, H);
const r2 = RADIUS * RADIUS;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const dx = d.bx - pointer.x;
const dy = d.by - pointer.y;
const dist2 = dx * dx + dy * dy;
let tx = d.bx, ty = d.by;
let influence = 0;
if (dist2 < r2) {
const dist = Math.sqrt(dist2) || 0.0001;
influence = 1 - dist / RADIUS; // 近いほど強い
const force = influence * influence; // 減衰
const ux = -dx / dist, uy = -dy / dist;
tx = d.bx + ux * PULL * force;
ty = d.by + uy * PULL * force;
}
// イージングで波打ちの余韻を残す
d.x += (tx - d.x) * EASE;
d.y += (ty - d.y) * EASE;
const size = 1.4 + influence * 3;
ctx.globalAlpha = 0.3 + influence * 0.6;
ctx.fillStyle = influence > 0.001 ? mixColor(influence) : 'rgba(160,120,70,0.8)';
ctx.beginPath();
ctx.arc(d.x, d.y, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
rafId = requestAnimationFrame(loop);
};
// 本物のポインタに追従
const onMove = (e) => {
const rect = canvas.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
useReal = true;
lastMove = performance.now();
};
const stage = canvas.parentElement || canvas;
stage.addEventListener('pointermove', onMove, { passive: true });
// リサイズで格子を作り直す
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(build, 120);
});
build();
if (reduce) {
// 抑制時は静止格子を1枚だけ描く
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgba(160,120,70,0.8)';
for (const d of dots) {
ctx.beginPath();
ctx.arc(d.bx, d.by, 1.6, 0, Math.PI * 2);
ctx.fill();
}
} else {
rafId = requestAnimationFrame(loop);
}
// タブ非表示で停止・再表示で再開
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
} else if (!reduce && !rafId) {
rafId = requestAnimationFrame(loop);
}
});
})();
コード
HTML
<!-- 磁力グリッド: ドット格子がカーソルの磁場で引き寄せ・反発して波打つ -->
<div class="mg-stage">
<canvas class="mg-canvas" aria-hidden="true"></canvas>
<p class="mg-hint">カーソルを動かすと格子が波打ちます</p>
</div>
CSS
/* ステージ全体 */
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background:
radial-gradient(circle at 25% 15%, #1b2a6b 0%, transparent 50%),
radial-gradient(circle at 80% 85%, #5e1b6e 0%, transparent 50%),
#070912;
overflow: hidden;
}
/* ドット格子を敷くステージ */
.mg-stage {
position: relative;
width: 100%;
min-height: 100vh;
cursor: crosshair;
}
/* 格子描画用キャンバスは全面に敷く */
.mg-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* 操作ヒント */
.mg-hint {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
margin: 0;
padding: 0 14px;
font-size: 12px;
letter-spacing: .05em;
color: rgba(255, 255, 255, .45);
pointer-events: none;
user-select: none;
}
JavaScript
// 磁力グリッド: ドット格子をカーソルの磁場で引き寄せ・反発させて波打たせる
(() => {
const canvas = document.querySelector('.mg-canvas');
if (!canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 設定値
const GAP = 34; // 格子間隔(px)
const RADIUS = 130; // 磁場の有効半径(px)
const PULL = 26; // 最大変位量(px) ※正で引き寄せ
const EASE = 0.14; // ドットが目標位置へ寄る滑らかさ
const COLORS = ['#7ce8ff', '#9b7cff', '#ff7ce0']; // 距離で色補間
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let dots = []; // 各ドットの基準/現在位置
let W = 0, H = 0; // CSSピクセル幅・高さ
let rafId = null;
// ポインタ状態: 待機中は仮想カーソル、pointermoveで本物に切替
const pointer = { x: 0, y: 0 };
let useReal = false; // 一度でも動いたら本物追従
let lastMove = 0; // 最後の本物入力時刻
const IDLE_MS = 1800; // 無操作が続いたら仮想カーソルへ復帰
// 格子を再構築(リサイズ対応)
const build = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || 1;
H = canvas.clientHeight || 1;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSSピクセル基準で描画
dots = [];
// 端に余白が出ないよう中央寄せでマージンを計算
const cols = Math.max(1, Math.floor((W - GAP) / GAP) + 1);
const rows = Math.max(1, Math.floor((H - GAP) / GAP) + 1);
const offX = (W - (cols - 1) * GAP) / 2;
const offY = (H - (rows - 1) * GAP) / 2;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const bx = offX + c * GAP;
const by = offY + r * GAP;
dots.push({ bx, by, x: bx, y: by });
}
}
// 仮想カーソル初期位置を中央に
if (!useReal) { pointer.x = W / 2; pointer.y = H / 2; }
};
// 仮想カーソルの巡回位置(リサジュー曲線でゆっくり8の字)
const virtualPointer = (t) => {
const cx = W / 2, cy = H / 2;
const ax = W * 0.34, ay = H * 0.30;
pointer.x = cx + Math.sin(t * 0.0007) * ax;
pointer.y = cy + Math.sin(t * 0.0011 + 0.6) * ay;
};
// 16進カラーを線形補間して距離に応じた色を作る
const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const C0 = hex(COLORS[0]), C1 = hex(COLORS[1]), C2 = hex(COLORS[2]);
const mixColor = (t) => {
// t:0→1 を 3色グラデーションへ写像
let a, b, k;
if (t < 0.5) { a = C0; b = C1; k = t * 2; }
else { a = C1; b = C2; k = (t - 0.5) * 2; }
const r = (a[0] + (b[0] - a[0]) * k) | 0;
const g = (a[1] + (b[1] - a[1]) * k) | 0;
const bl = (a[2] + (b[2] - a[2]) * k) | 0;
return `rgb(${r},${g},${bl})`;
};
// 1フレーム更新+描画
const loop = (t) => {
// 本物入力が途切れたら仮想カーソルへ復帰
if (useReal && t - lastMove > IDLE_MS) useReal = false;
if (!useReal && !reduce) virtualPointer(t);
ctx.clearRect(0, 0, W, H);
const r2 = RADIUS * RADIUS;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const dx = d.bx - pointer.x;
const dy = d.by - pointer.y;
const dist2 = dx * dx + dy * dy;
let tx = d.bx, ty = d.by; // 目標位置(既定は基準位置)
let influence = 0; // 磁場の効き具合 0〜1
if (dist2 < r2) {
const dist = Math.sqrt(dist2) || 0.0001;
influence = 1 - dist / RADIUS; // 近いほど強い
const force = influence * influence; // 減衰を効かせる
// カーソル方向へ引き寄せる(基準位置→カーソルへ変位)
const ux = -dx / dist, uy = -dy / dist;
tx = d.bx + ux * PULL * force;
ty = d.by + uy * PULL * force;
}
// イージングで目標位置へ滑らかに追従(波打ちの余韻)
d.x += (tx - d.x) * EASE;
d.y += (ty - d.y) * EASE;
// 影響に応じてサイズ・明るさ・色を変化
const size = 1.4 + influence * 3.2;
ctx.globalAlpha = 0.35 + influence * 0.65;
ctx.fillStyle = influence > 0.001 ? mixColor(influence) : 'rgba(150,170,255,0.9)';
ctx.beginPath();
ctx.arc(d.x, d.y, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
rafId = requestAnimationFrame(loop);
};
// 本物のポインタに追従
const onMove = (e) => {
const rect = canvas.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
useReal = true;
lastMove = performance.now();
};
const stage = canvas.parentElement || canvas;
stage.addEventListener('pointermove', onMove, { passive: true });
// リサイズで格子を作り直す
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(build, 120);
});
build();
if (reduce) {
// モーション抑制時は静止した格子を1枚だけ描く
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgba(150,170,255,0.9)';
for (const d of dots) {
ctx.beginPath();
ctx.arc(d.bx, d.by, 1.6, 0, Math.PI * 2);
ctx.fill();
}
} else {
rafId = requestAnimationFrame(loop);
}
// タブ非表示で停止し、再表示で再開(無駄なRAFを避ける)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
} else if (!reduce && !rafId) {
rafId = requestAnimationFrame(loop);
}
});
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「磁力グリッド」の効果を追加してください。
# 追加してほしい効果
磁力グリッド(マイクロインタラクション)
ドットの格子がカーソルの磁場で引き寄せ・反発し、近づくほど大きく波打って歪みます。待機中は仮想カーソルが巡回して常に動き、pointermoveで本物に追従。ヒーロー背景やインタラクティブな装飾に使えます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 磁力グリッド: ドット格子がカーソルの磁場で引き寄せ・反発して波打つ -->
<div class="mg-stage">
<canvas class="mg-canvas" aria-hidden="true"></canvas>
<p class="mg-hint">カーソルを動かすと格子が波打ちます</p>
</div>
【CSS】
/* ステージ全体 */
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background:
radial-gradient(circle at 25% 15%, #1b2a6b 0%, transparent 50%),
radial-gradient(circle at 80% 85%, #5e1b6e 0%, transparent 50%),
#070912;
overflow: hidden;
}
/* ドット格子を敷くステージ */
.mg-stage {
position: relative;
width: 100%;
min-height: 100vh;
cursor: crosshair;
}
/* 格子描画用キャンバスは全面に敷く */
.mg-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* 操作ヒント */
.mg-hint {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
margin: 0;
padding: 0 14px;
font-size: 12px;
letter-spacing: .05em;
color: rgba(255, 255, 255, .45);
pointer-events: none;
user-select: none;
}
【JavaScript】
// 磁力グリッド: ドット格子をカーソルの磁場で引き寄せ・反発させて波打たせる
(() => {
const canvas = document.querySelector('.mg-canvas');
if (!canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 設定値
const GAP = 34; // 格子間隔(px)
const RADIUS = 130; // 磁場の有効半径(px)
const PULL = 26; // 最大変位量(px) ※正で引き寄せ
const EASE = 0.14; // ドットが目標位置へ寄る滑らかさ
const COLORS = ['#7ce8ff', '#9b7cff', '#ff7ce0']; // 距離で色補間
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let dots = []; // 各ドットの基準/現在位置
let W = 0, H = 0; // CSSピクセル幅・高さ
let rafId = null;
// ポインタ状態: 待機中は仮想カーソル、pointermoveで本物に切替
const pointer = { x: 0, y: 0 };
let useReal = false; // 一度でも動いたら本物追従
let lastMove = 0; // 最後の本物入力時刻
const IDLE_MS = 1800; // 無操作が続いたら仮想カーソルへ復帰
// 格子を再構築(リサイズ対応)
const build = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || 1;
H = canvas.clientHeight || 1;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSSピクセル基準で描画
dots = [];
// 端に余白が出ないよう中央寄せでマージンを計算
const cols = Math.max(1, Math.floor((W - GAP) / GAP) + 1);
const rows = Math.max(1, Math.floor((H - GAP) / GAP) + 1);
const offX = (W - (cols - 1) * GAP) / 2;
const offY = (H - (rows - 1) * GAP) / 2;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const bx = offX + c * GAP;
const by = offY + r * GAP;
dots.push({ bx, by, x: bx, y: by });
}
}
// 仮想カーソル初期位置を中央に
if (!useReal) { pointer.x = W / 2; pointer.y = H / 2; }
};
// 仮想カーソルの巡回位置(リサジュー曲線でゆっくり8の字)
const virtualPointer = (t) => {
const cx = W / 2, cy = H / 2;
const ax = W * 0.34, ay = H * 0.30;
pointer.x = cx + Math.sin(t * 0.0007) * ax;
pointer.y = cy + Math.sin(t * 0.0011 + 0.6) * ay;
};
// 16進カラーを線形補間して距離に応じた色を作る
const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const C0 = hex(COLORS[0]), C1 = hex(COLORS[1]), C2 = hex(COLORS[2]);
const mixColor = (t) => {
// t:0→1 を 3色グラデーションへ写像
let a, b, k;
if (t < 0.5) { a = C0; b = C1; k = t * 2; }
else { a = C1; b = C2; k = (t - 0.5) * 2; }
const r = (a[0] + (b[0] - a[0]) * k) | 0;
const g = (a[1] + (b[1] - a[1]) * k) | 0;
const bl = (a[2] + (b[2] - a[2]) * k) | 0;
return `rgb(${r},${g},${bl})`;
};
// 1フレーム更新+描画
const loop = (t) => {
// 本物入力が途切れたら仮想カーソルへ復帰
if (useReal && t - lastMove > IDLE_MS) useReal = false;
if (!useReal && !reduce) virtualPointer(t);
ctx.clearRect(0, 0, W, H);
const r2 = RADIUS * RADIUS;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const dx = d.bx - pointer.x;
const dy = d.by - pointer.y;
const dist2 = dx * dx + dy * dy;
let tx = d.bx, ty = d.by; // 目標位置(既定は基準位置)
let influence = 0; // 磁場の効き具合 0〜1
if (dist2 < r2) {
const dist = Math.sqrt(dist2) || 0.0001;
influence = 1 - dist / RADIUS; // 近いほど強い
const force = influence * influence; // 減衰を効かせる
// カーソル方向へ引き寄せる(基準位置→カーソルへ変位)
const ux = -dx / dist, uy = -dy / dist;
tx = d.bx + ux * PULL * force;
ty = d.by + uy * PULL * force;
}
// イージングで目標位置へ滑らかに追従(波打ちの余韻)
d.x += (tx - d.x) * EASE;
d.y += (ty - d.y) * EASE;
// 影響に応じてサイズ・明るさ・色を変化
const size = 1.4 + influence * 3.2;
ctx.globalAlpha = 0.35 + influence * 0.65;
ctx.fillStyle = influence > 0.001 ? mixColor(influence) : 'rgba(150,170,255,0.9)';
ctx.beginPath();
ctx.arc(d.x, d.y, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
rafId = requestAnimationFrame(loop);
};
// 本物のポインタに追従
const onMove = (e) => {
const rect = canvas.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
useReal = true;
lastMove = performance.now();
};
const stage = canvas.parentElement || canvas;
stage.addEventListener('pointermove', onMove, { passive: true });
// リサイズで格子を作り直す
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(build, 120);
});
build();
if (reduce) {
// モーション抑制時は静止した格子を1枚だけ描く
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgba(150,170,255,0.9)';
for (const d of dots) {
ctx.beginPath();
ctx.arc(d.bx, d.by, 1.6, 0, Math.PI * 2);
ctx.fill();
}
} else {
rafId = requestAnimationFrame(loop);
}
// タブ非表示で停止し、再表示で再開(無駄なRAFを避ける)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
} else if (!reduce && !rafId) {
rafId = requestAnimationFrame(loop);
}
});
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。