ハーフトーン網点

画像を canvas で解析し、各セルの明度をドット径に変換した印刷風ハーフトーン網点を描画。斜めに進む波でドット径が脈打ち、マウス近傍では網点が膨らんで反応します。雑誌/レコードジャケット風のグラフィックや没入系ビジュアルに。CORS失敗時は放射状グラデの網点へフォールバック。

#image#halftone#canvas#dots

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW:レコジャケ風ハーフトーンのイベント告知 -->
<section class="mb-ht">
  <!-- 網点に変換される豆/店内写真 -->
  <figure class="mb-ht__media" id="mbHt" aria-label="ハーフトーン網点ビジュアル">
    <canvas class="mb-ht__canvas" width="360" height="360"></canvas>
    <span class="mb-ht__vinyl">VOL.07</span>
  </figure>

  <!-- 告知テキスト -->
  <div class="mb-ht__info">
    <span class="mb-ht__tag">LIVE & COFFEE</span>
    <h2 class="mb-ht__title">深夜の<br>焙煎ナイト。</h2>
    <p class="mb-ht__meta">6.21 SAT / 20:00 OPEN<br>MOON BREW 中目黒店</p>
    <ul class="mb-ht__list">
      <li>◯ ライブ焙煎の実演</li>
      <li>◯ アナログ・レコードDJ</li>
      <li>◯ 限定ブレンドの試飲</li>
    </ul>
    <a class="mb-ht__btn" href="#">チケットを予約</a>
  </div>
</section>
CSS
/* MOON BREW:レコジャケ風ハーフトーン告知 */
:root {
  --cream: #f5ede1;
  --brown: #2b1d12;
  --amber: #c98a3b;
  --ht-ink: #2b1d12;   /* 網点インク色(JS が参照) */
  --ht-paper: #f5ede1; /* 紙色(JS が参照) */
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  display: flex;
  align-items: center;
  gap: 28px;
  padding: 0 30px;
  font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background: var(--brown);
  color: var(--cream);
  overflow: hidden;
}

/* レコジャケ風の正方形メディア */
.mb-ht__media {
  position: relative;
  flex: 0 0 300px;
  width: 300px;
  height: 300px;
  margin: 0;
  border-radius: 6px;
  overflow: hidden;
  background: var(--ht-paper);
  box-shadow: 0 18px 40px rgba(0,0,0,0.5);
}
.mb-ht__canvas { width: 100%; height: 100%; display: block; }
.mb-ht__vinyl {
  position: absolute;
  right: 12px;
  bottom: 12px;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0.1em;
  color: var(--amber);
  background: rgba(43,29,18,0.75);
  padding: 5px 10px;
  border-radius: 4px;
}

/* 告知テキスト */
.mb-ht__info { flex: 1; }
.mb-ht__tag { font-size: 10px; letter-spacing: 0.3em; color: var(--amber); }
.mb-ht__title {
  margin: 10px 0 14px;
  font-size: 30px;
  line-height: 1.35;
  font-weight: 800;
  font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
}
.mb-ht__meta {
  margin: 0 0 14px;
  font-size: 12.5px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  color: rgba(245,237,225,0.85);
}
.mb-ht__list {
  margin: 0 0 20px;
  padding: 0;
  list-style: none;
  font-size: 12.5px;
  line-height: 1.95;
  color: rgba(245,237,225,0.78);
}
.mb-ht__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-ht__btn:hover { transform: translateY(-2px); }

@media (prefers-reduced-motion: reduce) {
  .mb-ht__btn { transition: none; }
}
JavaScript
// ハーフトーン網点:画像の明度→ドット径に変換し、波&マウスで径を揺らす
(() => {
  const figure = document.getElementById("mbHt");
  const canvas = figure && figure.querySelector(".mb-ht__canvas");
  if (!figure || !canvas) return;

  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  const W = canvas.width;   // 内部解像度(固定)
  const H = canvas.height;
  const GRID = 9;           // 網点セルのピッチ(px)
  const cols = Math.ceil(W / GRID);
  const rows = Math.ceil(H / GRID);

  // インク色/紙色を CSS変数から取得(null安全フォールバック)
  const css = getComputedStyle(document.documentElement);
  const ink = (css.getPropertyValue("--ht-ink") || "#2b1d12").trim() || "#2b1d12";
  const paper = (css.getPropertyValue("--ht-paper") || "#f5ede1").trim() || "#f5ede1";

  // 明度サンプリング用オフスクリーン
  const off = document.createElement("canvas");
  off.width = cols;
  off.height = rows;
  const octx = off.getContext("2d", { willReadFrequently: true });

  // セルごとの暗さ(0..1)
  const dark = new Float32Array(cols * rows);

  // フォールバック:放射状グラデを明度マップに
  const fillFallback = () => {
    for (let y = 0; y < rows; y++) {
      for (let x = 0; x < cols; x++) {
        const nx = x / cols - 0.5;
        const ny = y / rows - 0.5;
        const d = Math.sqrt(nx * nx + ny * ny) * 1.6;
        dark[y * cols + x] = Math.max(0, Math.min(1, 1 - d));
      }
    }
  };

  // 画像から明度マップ構築
  const buildFromImage = (image) => {
    try {
      const ar = image.width / image.height;
      const car = cols / rows;
      let dw = cols, dh = rows, dx = 0, dy = 0;
      if (ar > car) { dh = rows; dw = rows * ar; dx = (cols - dw) / 2; }
      else { dw = cols; dh = cols / ar; dy = (rows - dh) / 2; }
      octx.drawImage(image, dx, dy, dw, dh);
      const data = octx.getImageData(0, 0, cols, rows).data; // tainted なら例外
      for (let i = 0, p = 0; i < data.length; i += 4, p++) {
        const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
        dark[p] = 1 - lum;
      }
    } catch {
      fillFallback();
    }
  };

  fillFallback();

  const img = new Image();
  img.crossOrigin = "anonymous";
  img.onload = () => buildFromImage(img);
  img.onerror = () => fillFallback();
  img.src = "https://picsum.photos/seed/moonbrew-jacket/360/360";

  // マウス状態(内部座標)
  let mx = -999, my = -999;
  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) => toLocal(e.clientX, e.clientY));
  figure.addEventListener("pointerleave", () => { mx = my = -999; });

  const maxR = GRID * 0.62;
  const MOUSE_R = 80;
  let t = 0;
  let raf = 0;
  let running = false;

  const render = () => {
    if (!running) return;
    t += 0.05;

    ctx.fillStyle = paper;
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = ink;

    for (let gy = 0; gy < rows; gy++) {
      for (let gx = 0; gx < cols; gx++) {
        const cx = gx * GRID + GRID / 2;
        const cy = gy * GRID + GRID / 2;
        let v = dark[gy * cols + gx];

        // 斜めに進む波で径を揺らす
        const wave = 0.5 + 0.5 * Math.sin((gx + gy) * 0.5 - t * 2);
        v *= 0.65 + 0.5 * wave;

        // マウス近傍は点を膨らませる
        if (mx > -900) {
          const dxm = cx - mx, dym = cy - my;
          const dist = Math.sqrt(dxm * dxm + dym * dym);
          if (dist < MOUSE_R) {
            const f = 1 - dist / MOUSE_R;
            v += f * f * 0.9;
          }
        }

        const r = Math.max(0, Math.min(1, v)) * maxR;
        if (r > 0.35) {
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    }
    raf = requestAnimationFrame(render);
  };

  const start = () => { if (!running) { running = true; raf = requestAnimationFrame(render); } };
  const stop = () => { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; };

  document.addEventListener("visibilitychange", () => {
    document.hidden ? stop() : start();
  });
  start();
})();

コード

HTML
<!-- 画像をcanvasでドット網点に変換。ドット径が波打ち、マウスに反応する -->
<div class="ht-stage">
  <figure class="ht" aria-label="ハーフトーン網点">
    <!-- 表示用canvas(内部解像度は固定、CSSで枠いっぱいに伸縮) -->
    <canvas class="ht__canvas" width="520" height="360"></canvas>
    <figcaption class="ht__cap">HALFTONE</figcaption>
  </figure>
</div>
CSS
/* ハーフトーン網点 */
:root {
  --ht-ink: #0c1230;   /* ドットの色(インク) */
  --ht-paper: #f4eede; /* 紙の色(背景) */
}
body {
  background:
    radial-gradient(120% 120% at 50% 0%, #20242f 0%, #0a0c12 70%);
}
.ht-stage { padding: 22px; }

.ht {
  position: relative;
  width: min(82vw, 480px);
  aspect-ratio: 13 / 9;
  margin: 0;
  border-radius: 12px;
  overflow: hidden;
  background: var(--ht-paper);
  cursor: crosshair;
  box-shadow: 0 22px 55px -20px rgba(0, 0, 0, .85);
}

/* canvas は枠いっぱい。内部解像度は固定なので拡大表示される */
.ht__canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.ht__cap {
  position: absolute;
  left: 14px;
  bottom: 11px;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .4em;
  color: var(--ht-ink);
  mix-blend-mode: multiply;
  opacity: .55;
  pointer-events: none;
}
JavaScript
// ハーフトーン網点:画像の明度→ドット径に変換し、波&マウスで径を揺らす
(() => {
  const canvas = document.querySelector(".ht__canvas");
  const figure = document.querySelector(".ht");
  if (!canvas || !figure) return;

  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  const W = canvas.width;   // 内部解像度(固定)
  const H = canvas.height;
  const GRID = 9;           // 網点セルのピッチ(px)
  const cols = Math.ceil(W / GRID);
  const rows = Math.ceil(H / GRID);

  // インク色/紙色を CSS変数から取得(null安全にフォールバック)
  const css = getComputedStyle(document.documentElement);
  const ink = (css.getPropertyValue("--ht-ink") || "#0c1230").trim() || "#0c1230";
  const paper = (css.getPropertyValue("--ht-paper") || "#f4eede").trim() || "#f4eede";

  // 明度サンプリング用のオフスクリーン(セル解像度ぶんだけ持つ)
  const off = document.createElement("canvas");
  off.width = cols;
  off.height = rows;
  const octx = off.getContext("2d", { willReadFrequently: true });

  // セルごとの「暗さ」(0..1)。0=明るい→小さい点、1=暗い→大きい点
  let dark = new Float32Array(cols * rows);

  // --- フォールバック:放射状グラデを明度マップとして使う(CORS事故/失敗時)---
  const fillFallback = () => {
    for (let y = 0; y < rows; y++) {
      for (let x = 0; x < cols; x++) {
        const nx = x / cols - 0.5;
        const ny = y / rows - 0.5;
        const d = Math.sqrt(nx * nx + ny * ny) * 1.6; // 中心ほど暗い
        dark[y * cols + x] = Math.max(0, Math.min(1, 1 - d));
      }
    }
  };

  // --- 画像から明度マップを構築 ---
  const buildFromImage = (image) => {
    try {
      // cover 風にトリミングしてセル解像度へ縮小描画
      const ar = image.width / image.height;
      const car = cols / rows;
      let dw = cols, dh = rows, dx = 0, dy = 0;
      if (ar > car) { dh = rows; dw = rows * ar; dx = (cols - dw) / 2; }
      else { dw = cols; dh = cols / ar; dy = (rows - dh) / 2; }
      octx.drawImage(image, dx, dy, dw, dh);
      const data = octx.getImageData(0, 0, cols, rows).data; // tainted ならここで例外
      for (let i = 0, p = 0; i < data.length; i += 4, p++) {
        // 知覚輝度 → 暗さ(1-輝度)
        const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
        dark[p] = 1 - lum;
      }
    } catch {
      fillFallback(); // 汚染時は安全にフォールバック
    }
  };

  fillFallback(); // 画像が来る前から動かす

  const img = new Image();
  img.crossOrigin = "anonymous";
  img.onload = () => buildFromImage(img);
  img.onerror = () => fillFallback();
  img.src = "https://picsum.photos/seed/halftone7/520/360";

  // --- マウス状態(内部座標) ---
  let mx = -999, my = -999;
  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) => toLocal(e.clientX, e.clientY));
  figure.addEventListener("pointerleave", () => { mx = my = -999; });

  // --- 描画ループ ---
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const maxR = GRID * 0.62;     // ドット最大半径
  const MOUSE_R = 90;           // マウス影響半径
  let t = 0;
  let raf = 0;
  let running = false;

  const render = () => {
    if (!running) return;
    t += 0.05;

    ctx.fillStyle = paper;
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = ink;

    for (let gy = 0; gy < rows; gy++) {
      for (let gx = 0; gx < cols; gx++) {
        const cx = gx * GRID + GRID / 2;
        const cy = gy * GRID + GRID / 2;
        let v = dark[gy * cols + gx]; // 0..1 の暗さ

        // 斜めに進む波で径を揺らす(網点が波打つ)
        const wave = 0.5 + 0.5 * Math.sin((gx + gy) * 0.5 - t * 2);
        v *= 0.65 + 0.5 * wave;

        // マウス近傍は点を膨らませて反応させる
        if (mx > -900) {
          const dxm = cx - mx, dym = cy - my;
          const dist = Math.sqrt(dxm * dxm + dym * dym);
          if (dist < MOUSE_R) {
            const f = 1 - dist / MOUSE_R; // 近いほど1
            v += f * f * 0.9;
          }
        }

        const r = Math.max(0, Math.min(1, v)) * maxR;
        if (r > 0.35) {
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    }

    raf = requestAnimationFrame(render);
  };

  const start = () => { if (!running) { running = true; raf = requestAnimationFrame(render); } };
  const stop = () => { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; };

  // タブ非表示で停止/復帰で再開
  document.addEventListener("visibilitychange", () => {
    document.hidden ? stop() : start();
  });

  // reduce 指定でも当ギャラリーは動きを見せる方針なので通常起動
  void reduce;
  start();
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ハーフトーン網点」の効果を追加してください。

# 追加してほしい効果
ハーフトーン網点(画像エフェクト)
画像を canvas で解析し、各セルの明度をドット径に変換した印刷風ハーフトーン網点を描画。斜めに進む波でドット径が脈打ち、マウス近傍では網点が膨らんで反応します。雑誌/レコードジャケット風のグラフィックや没入系ビジュアルに。CORS失敗時は放射状グラデの網点へフォールバック。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 画像をcanvasでドット網点に変換。ドット径が波打ち、マウスに反応する -->
<div class="ht-stage">
  <figure class="ht" aria-label="ハーフトーン網点">
    <!-- 表示用canvas(内部解像度は固定、CSSで枠いっぱいに伸縮) -->
    <canvas class="ht__canvas" width="520" height="360"></canvas>
    <figcaption class="ht__cap">HALFTONE</figcaption>
  </figure>
</div>

【CSS】
/* ハーフトーン網点 */
:root {
  --ht-ink: #0c1230;   /* ドットの色(インク) */
  --ht-paper: #f4eede; /* 紙の色(背景) */
}
body {
  background:
    radial-gradient(120% 120% at 50% 0%, #20242f 0%, #0a0c12 70%);
}
.ht-stage { padding: 22px; }

.ht {
  position: relative;
  width: min(82vw, 480px);
  aspect-ratio: 13 / 9;
  margin: 0;
  border-radius: 12px;
  overflow: hidden;
  background: var(--ht-paper);
  cursor: crosshair;
  box-shadow: 0 22px 55px -20px rgba(0, 0, 0, .85);
}

/* canvas は枠いっぱい。内部解像度は固定なので拡大表示される */
.ht__canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.ht__cap {
  position: absolute;
  left: 14px;
  bottom: 11px;
  font-family: "Courier New", ui-monospace, monospace;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: .4em;
  color: var(--ht-ink);
  mix-blend-mode: multiply;
  opacity: .55;
  pointer-events: none;
}

【JavaScript】
// ハーフトーン網点:画像の明度→ドット径に変換し、波&マウスで径を揺らす
(() => {
  const canvas = document.querySelector(".ht__canvas");
  const figure = document.querySelector(".ht");
  if (!canvas || !figure) return;

  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  const W = canvas.width;   // 内部解像度(固定)
  const H = canvas.height;
  const GRID = 9;           // 網点セルのピッチ(px)
  const cols = Math.ceil(W / GRID);
  const rows = Math.ceil(H / GRID);

  // インク色/紙色を CSS変数から取得(null安全にフォールバック)
  const css = getComputedStyle(document.documentElement);
  const ink = (css.getPropertyValue("--ht-ink") || "#0c1230").trim() || "#0c1230";
  const paper = (css.getPropertyValue("--ht-paper") || "#f4eede").trim() || "#f4eede";

  // 明度サンプリング用のオフスクリーン(セル解像度ぶんだけ持つ)
  const off = document.createElement("canvas");
  off.width = cols;
  off.height = rows;
  const octx = off.getContext("2d", { willReadFrequently: true });

  // セルごとの「暗さ」(0..1)。0=明るい→小さい点、1=暗い→大きい点
  let dark = new Float32Array(cols * rows);

  // --- フォールバック:放射状グラデを明度マップとして使う(CORS事故/失敗時)---
  const fillFallback = () => {
    for (let y = 0; y < rows; y++) {
      for (let x = 0; x < cols; x++) {
        const nx = x / cols - 0.5;
        const ny = y / rows - 0.5;
        const d = Math.sqrt(nx * nx + ny * ny) * 1.6; // 中心ほど暗い
        dark[y * cols + x] = Math.max(0, Math.min(1, 1 - d));
      }
    }
  };

  // --- 画像から明度マップを構築 ---
  const buildFromImage = (image) => {
    try {
      // cover 風にトリミングしてセル解像度へ縮小描画
      const ar = image.width / image.height;
      const car = cols / rows;
      let dw = cols, dh = rows, dx = 0, dy = 0;
      if (ar > car) { dh = rows; dw = rows * ar; dx = (cols - dw) / 2; }
      else { dw = cols; dh = cols / ar; dy = (rows - dh) / 2; }
      octx.drawImage(image, dx, dy, dw, dh);
      const data = octx.getImageData(0, 0, cols, rows).data; // tainted ならここで例外
      for (let i = 0, p = 0; i < data.length; i += 4, p++) {
        // 知覚輝度 → 暗さ(1-輝度)
        const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
        dark[p] = 1 - lum;
      }
    } catch {
      fillFallback(); // 汚染時は安全にフォールバック
    }
  };

  fillFallback(); // 画像が来る前から動かす

  const img = new Image();
  img.crossOrigin = "anonymous";
  img.onload = () => buildFromImage(img);
  img.onerror = () => fillFallback();
  img.src = "https://picsum.photos/seed/halftone7/520/360";

  // --- マウス状態(内部座標) ---
  let mx = -999, my = -999;
  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) => toLocal(e.clientX, e.clientY));
  figure.addEventListener("pointerleave", () => { mx = my = -999; });

  // --- 描画ループ ---
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const maxR = GRID * 0.62;     // ドット最大半径
  const MOUSE_R = 90;           // マウス影響半径
  let t = 0;
  let raf = 0;
  let running = false;

  const render = () => {
    if (!running) return;
    t += 0.05;

    ctx.fillStyle = paper;
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = ink;

    for (let gy = 0; gy < rows; gy++) {
      for (let gx = 0; gx < cols; gx++) {
        const cx = gx * GRID + GRID / 2;
        const cy = gy * GRID + GRID / 2;
        let v = dark[gy * cols + gx]; // 0..1 の暗さ

        // 斜めに進む波で径を揺らす(網点が波打つ)
        const wave = 0.5 + 0.5 * Math.sin((gx + gy) * 0.5 - t * 2);
        v *= 0.65 + 0.5 * wave;

        // マウス近傍は点を膨らませて反応させる
        if (mx > -900) {
          const dxm = cx - mx, dym = cy - my;
          const dist = Math.sqrt(dxm * dxm + dym * dym);
          if (dist < MOUSE_R) {
            const f = 1 - dist / MOUSE_R; // 近いほど1
            v += f * f * 0.9;
          }
        }

        const r = Math.max(0, Math.min(1, v)) * maxR;
        if (r > 0.35) {
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    }

    raf = requestAnimationFrame(render);
  };

  const start = () => { if (!running) { running = true; raf = requestAnimationFrame(render); } };
  const stop = () => { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; };

  // タブ非表示で停止/復帰で再開
  document.addEventListener("visibilitychange", () => {
    document.hidden ? stop() : start();
  });

  // reduce 指定でも当ギャラリーは動きを見せる方針なので通常起動
  void reduce;
  start();
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。