ハーフトーン網点
画像を canvas で解析し、各セルの明度をドット径に変換した印刷風ハーフトーン網点を描画。斜めに進む波でドット径が脈打ち、マウス近傍では網点が膨らんで反応します。雑誌/レコードジャケット風のグラフィックや没入系ビジュアルに。CORS失敗時は放射状グラデの網点へフォールバック。
ライブデモ
使用例(お題: カフェ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。