ホバー・ディストーション

画像にマウスを乗せると、その位置を中心に sin 波の歪み(ディストーション)が走る canvas エフェクト。ピクセル変位で水面や陽炎のような揺らぎを表現し、フォトギャラリーやヒーローに没入感を与えます。

#image#canvas#distortion#hover

ライブデモ

使用例(お題: カフェ 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&nbsp;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&nbsp;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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。