Claude CodeでCanvas開発を実装する実践ガイド
Claude CodeでCanvas開発を進める実践手順を、HiDPI・RAF・Pointer Events・検証まで解説。
最初に押さえること
Canvasは、ブラウザ上で線、画像、粒子、ゲーム画面、グラフを直接描くための「描画面」です。HTMLやCSSのレイアウトとは別に、JavaScriptで毎フレーム絵を描き直すため、見た目は自由に作れます。一方で、ピクセル密度、リサイズ、入力イベント、アニメーション停止、スマホ表示を少し外すだけで、ぼやける、重い、触れない、横スクロールする、スクリーンショットでは気づけない空白が出る、という失敗が起きます。
Claude Codeを使う価値は、Canvas APIの断片を生成することだけではありません。既存のReactコンポーネント、CSS、テスト、記事のCTA、スマホ幅の制約まで読ませて、「動くデモ」から「公開できるUI」へ進めるところにあります。Canvasは低レベルなAPIなので、Claude Codeへの依頼も低レベルな条件を明確にするほど品質が安定します。
この記事では、初心者でも読めるように、HiDPI対応、requestAnimationFrame、Pointer Events、描画状態管理、Playwrightのスクリーンショット検証、ありがちなスマホ崩れまで一気通貫で扱います。関連して、動きの調整はClaude Codeでアニメーション実装を効率化する方法、3D表現はClaude CodeでThree.js 3D表現を作るガイド、データ表現はClaude Codeでデータ可視化を実装する方法も参考になります。公式情報はClaude Code Docs、MDN Canvas API、requestAnimationFrame、Pointer Events、Playwright screenshotsを確認してください。
Claude Codeへの依頼文
Canvas開発で一番危ない依頼は「かっこいいCanvasを作って」です。これだと、固定サイズ、マウス専用、HiDPI未対応、テストなしのデモになりがちです。Masaがこのサイト用の検証デモを作ったときも、最初の雑な依頼ではデスクトップでは動くのに、iPhone幅でCanvasの高さが潰れ、CTAの上に余白だけが残りました。
Claude Codeには、次のように境界条件を先に渡します。
Claude CodeでCanvas 2Dのデモを実装してください。
条件:
- CSSピクセルと内部ピクセルを分け、devicePixelRatioに対応する
- requestAnimationFrameで描画し、dtは最大値を丸める
- mouse/touch/penをPointer Eventsで統一する
- 描画状態は1つのstateにまとめ、render関数に副作用を持たせない
- ResizeObserverで親要素の幅変更に追従する
- スマホ幅375pxで横スクロールしない
- Playwrightで非ブランク描画、スクリーンショット、モバイル幅を検証する
- 変更ファイル、落とし穴、手動確認項目を最後に列挙する
この依頼の狙いは、Claude Codeに「絵」ではなく「描画システム」を作らせることです。特にdevicePixelRatioはHiDPI、つまり高密度ディスプレイで1 CSSピクセルを複数の実ピクセルとして描くための値です。ここを無視すると、MacBookやスマホで線がぼやけます。
全体像を図で持つ
コードを書く前に、Canvasの責務を分けておくとレビューが楽になります。Claude Codeにもこの構造を共有してから依頼すると、入力処理と描画処理が混ざりにくくなります。
Pointer Events
|
v
input handler ---> state更新 ---> update(dt)
|
ResizeObserver ---> resize(dpr) v
render(ctx)
|
v
Playwright検証
ポイントは、render(ctx)の中でイベント登録やDOM更新をしないことです。イベントは入力、updateは状態の時間変化、renderは描画だけ、という分担にすると、Claude Codeが後から機能を足しても破綻しにくくなります。
3つ以上のユースケース
1つ目は、ダッシュボードや記事内のデータ可視化です。標準のグラフライブラリでは表現しにくい粒子、軌跡、地図上の動き、リアルタイム波形はCanvasが向いています。広告やCTAがある記事では、Canvasが重すぎて本文やボタンの表示を遅らせないことが重要です。
2つ目は、画像注釈ツールです。スクリーンショットに矢印、矩形、ハイライト、手書きメモを重ねるUIでは、Pointer Eventsと状態管理が中心になります。ペン入力のpressureを扱える端末では、筆圧を線の太さに反映できます。
3つ目は、学習教材やミニゲームです。物理シミュレーション、タイピング練習、英単語カードのアニメーションなどは、毎フレームの状態更新が自然です。ただし、requestAnimationFrameを複数起動したまま画面遷移するとCPUを使い続けるため、停止処理まで実装する必要があります。
4つ目は、商品ページのインタラクティブな演出です。マウスやタッチで商品色を切り替える、粒子で背景を反応させる、3Dに進む前の軽いプレビューを作る、といった用途です。収益導線に近い場所で使うほど、見た目より「CTAを邪魔しない」「スマホで崩れない」「広告枠と重ならない」ことを優先します。
コピペで動くCanvasデモ
次のHTMLは、単体でブラウザに開けるCanvas 2Dデモです。HiDPI対応、requestAnimationFrame、Pointer Events、ResizeObserver、スマホのtouch-action: noneをまとめています。ctx.scale(dpr, dpr)を繰り返すのではなく、リサイズのたびにctx.setTransform(dpr, 0, 0, dpr, 0, 0)で変換を上書きするのが落とし穴回避の要点です。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Claude Code Canvas Demo</title>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #111827;
color: #f9fafb;
}
main {
min-height: 100vh;
box-sizing: border-box;
display: grid;
place-items: center;
padding: 24px;
}
.canvas-shell {
width: min(100%, 760px);
}
canvas {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid #374151;
border-radius: 8px;
background: #020617;
touch-action: none;
}
</style>
</head>
<body>
<main>
<div class="canvas-shell">
<canvas id="demo" aria-label="Pointer controlled particle demo"></canvas>
</div>
</main>
<script type="module">
const canvas = document.querySelector("#demo");
const ctx = canvas.getContext("2d");
const state = {
dpr: 1,
width: 1,
height: 1,
last: 0,
pointer: { x: 0, y: 0, down: false },
particles: [],
};
function resize() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
state.width = Math.max(1, rect.width);
state.height = Math.max(1, rect.height);
state.dpr = dpr;
canvas.width = Math.round(state.width * dpr);
canvas.height = Math.round(state.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function readPoint(event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
pressure: event.pressure || 0.5,
};
}
function emit(x, y, pressure = 0.5, count = 9) {
for (let i = 0; i < count; i += 1) {
const angle = Math.random() * Math.PI * 2;
const speed = 80 + Math.random() * 220;
state.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 3 + pressure * 10 * Math.random(),
life: 0.7 + Math.random() * 0.5,
maxLife: 1.2,
});
}
state.particles = state.particles.slice(-420);
}
function handlePointerMove(event) {
const events = event.getCoalescedEvents ? event.getCoalescedEvents() : [event];
for (const item of events) {
const point = readPoint(item);
state.pointer.x = point.x;
state.pointer.y = point.y;
if (state.pointer.down) emit(point.x, point.y, point.pressure, 3);
}
}
canvas.addEventListener("pointerdown", (event) => {
canvas.setPointerCapture(event.pointerId);
const point = readPoint(event);
state.pointer = { x: point.x, y: point.y, down: true };
emit(point.x, point.y, point.pressure, 24);
});
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", () => {
state.pointer.down = false;
});
canvas.addEventListener("pointercancel", () => {
state.pointer.down = false;
});
function update(dt) {
for (const particle of state.particles) {
particle.vy += 240 * dt;
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
particle.life -= dt;
}
state.particles = state.particles.filter((particle) => particle.life > 0);
}
function drawGrid() {
ctx.strokeStyle = "rgba(148, 163, 184, 0.16)";
ctx.lineWidth = 1;
for (let x = 0; x < state.width; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, state.height);
ctx.stroke();
}
for (let y = 0; y < state.height; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(state.width, y);
ctx.stroke();
}
}
function render() {
ctx.clearRect(0, 0, state.width, state.height);
ctx.fillStyle = "#020617";
ctx.fillRect(0, 0, state.width, state.height);
drawGrid();
for (const particle of state.particles) {
const alpha = Math.max(0, particle.life / particle.maxLife);
ctx.fillStyle = `rgba(56, 189, 248, ${alpha})`;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size * alpha, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "#f97316";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(state.pointer.x, state.pointer.y, 14, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "#e5e7eb";
ctx.font = "14px system-ui, sans-serif";
ctx.fillText(`dpr ${state.dpr.toFixed(2)} / particles ${state.particles.length}`, 16, 24);
}
function frame(now) {
const dt = state.last ? Math.min((now - state.last) / 1000, 0.033) : 0;
state.last = now;
update(dt);
render();
requestAnimationFrame(frame);
}
new ResizeObserver(resize).observe(canvas);
resize();
requestAnimationFrame(frame);
</script>
</body>
</html>
Claude Codeにこのコードを渡してレビューさせるなら、「ctx.scaleの累積がないか」「スマホ幅でcanvasが親要素を超えないか」「Pointer Eventsがマウスだけに閉じていないか」を見てもらいます。ここを確認しないと、PCのスクリーンショットだけきれいなデモになります。
描画状態管理を分ける
CanvasはDOMのように要素が残りません。毎フレーム描き直すため、過去の線、現在のツール、Undo/Redo、選択状態をJavaScript側に持つ必要があります。harnessという言葉を使うなら、「エージェントや処理を動かす足場」と言い換えると初心者にも伝わります。Canvasでの足場は、状態、入力、更新、描画、検証の分離です。
type Tool = "pen" | "eraser";
type StrokePoint = {
x: number;
y: number;
pressure: number;
t: number;
};
type CanvasState = {
tool: Tool;
color: string;
lineWidth: number;
strokes: StrokePoint[][];
redo: StrokePoint[][];
};
type Action =
| { type: "start"; point: StrokePoint }
| { type: "append"; point: StrokePoint }
| { type: "finish" }
| { type: "undo" }
| { type: "redo" }
| { type: "tool"; tool: Tool };
export function canvasReducer(state: CanvasState, action: Action): CanvasState {
if (action.type === "start") {
return { ...state, strokes: [...state.strokes, [action.point]], redo: [] };
}
if (action.type === "append") {
const strokes = state.strokes.slice();
const last = strokes.at(-1) ?? [];
strokes[strokes.length - 1] = [...last, action.point];
return { ...state, strokes };
}
if (action.type === "undo") {
const strokes = state.strokes.slice(0, -1);
const removed = state.strokes.at(-1);
return removed ? { ...state, strokes, redo: [removed, ...state.redo] } : state;
}
if (action.type === "redo") {
const [next, ...redo] = state.redo;
return next ? { ...state, strokes: [...state.strokes, next], redo } : state;
}
if (action.type === "tool") {
return { ...state, tool: action.tool };
}
return state;
}
このように状態更新を純粋関数に寄せると、Claude Codeに「UndoでRedoが消えるか」「空の状態でUndoしても壊れないか」「筆圧がない端末で既定値になるか」をテストさせやすくなります。
Reactで組み込む時の注意
ReactでCanvasを扱う場合、useEffectの中でイベント登録、ResizeObserver、アニメーションループを開始し、クリーンアップで必ず解除します。ここを忘れると、ページ遷移やホットリロードのたびにrequestAnimationFrameが増え、見た目は同じでもCPU使用率だけ上がります。
import { useEffect, useRef } from "react";
export function CanvasPanel() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
let frameId = 0;
let last = 0;
const state = { width: 1, height: 1, dpr: 1, x: 40 };
const resize = () => {
const rect = canvas.getBoundingClientRect();
state.width = Math.max(1, rect.width);
state.height = Math.max(1, rect.height);
state.dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(state.width * state.dpr);
canvas.height = Math.round(state.height * state.dpr);
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
};
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
const tick = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.033) : 0;
last = now;
state.x = (state.x + dt * 80) % state.width;
ctx.clearRect(0, 0, state.width, state.height);
ctx.fillStyle = "#020617";
ctx.fillRect(0, 0, state.width, state.height);
ctx.fillStyle = "#38bdf8";
ctx.beginPath();
ctx.arc(state.x, state.height / 2, 18, 0, Math.PI * 2);
ctx.fill();
frameId = requestAnimationFrame(tick);
};
frameId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, []);
return <canvas ref={canvasRef} className="w-full aspect-video touch-none rounded border" />;
}
Claude CodeにReact実装を頼む時は、「useEffectの戻り値で解除する」「依存配列を変えた時に二重起動しない」「CSSで高さが決まっている」ことをレビュー条件に入れてください。
Playwrightでスクショと非ブランクを検証する
CanvasはDOMの中身を見ても描画結果が分かりません。だからスクリーンショットとピクセル検査を入れます。PlaywrightのtoHaveScreenshotだけに頼ると、環境差で揺れる場合があります。まずCanvasが表示され、スマホ幅で親要素を超えず、実際にピクセルが塗られていることを確認します。
import { expect, test } from "@playwright/test";
const viewports = [
{ name: "desktop", width: 1280, height: 800 },
{ name: "mobile", width: 390, height: 844 },
];
for (const viewport of viewports) {
test(`canvas renders on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto("/canvas-demo");
const canvas = page.locator("canvas").first();
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
expect(box?.width ?? 0).toBeLessThanOrEqual(viewport.width);
if (box) {
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.5);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.6);
await page.mouse.up();
}
const paintedPixels = await canvas.evaluate((node) => {
const context = node.getContext("2d");
if (!context) return 0;
const image = context.getImageData(0, 0, node.width, node.height).data;
let painted = 0;
for (let i = 3; i < image.length; i += 4) {
if (image[i] > 0) painted += 1;
}
return painted;
});
expect(paintedPixels).toBeGreaterThan(1000);
await expect(canvas).toHaveScreenshot(`canvas-${viewport.name}.png`, {
maxDiffPixelRatio: 0.03,
});
});
}
初回はnpx playwright test tests/canvas.spec.ts --update-snapshotsで基準画像を作り、以後は通常実行で差分を見ます。Claude Codeには、失敗したスクリーンショットだけでなく、boundingBox、viewport、DPR、直近のCSS変更も読ませると原因特定が早くなります。
具体的な失敗例と落とし穴
よくある失敗の1つ目は、CanvasのCSSサイズだけを変えて内部サイズを変えないことです。canvas.style.width = "100%"だけでは、内部の描画バッファが既定の300x150のままになり、拡大表示されてぼやけます。
2つ目は、リサイズのたびにctx.scale(dpr, dpr)を呼ぶことです。scaleは累積するので、何度かリサイズすると線や座標が大きくずれます。setTransformで上書きするほうが安全です。
3つ目は、mousemoveだけで入力を実装することです。スマホ、タブレット、ペン入力では反応しません。Pointer Eventsなら、マウス、タッチ、ペンを同じコードで扱えます。さらにtouch-action: noneをCanvasに指定しないと、描画中にページスクロールが割り込むことがあります。
4つ目は、requestAnimationFrameを止めないことです。Reactのアンマウント、タブ切り替え、モーダルの閉じる操作でループが残ると、見えないCanvasが動き続けます。
5つ目は、スマホ幅でCanvasを固定ピクセルにすることです。width: 800pxのまま記事本文に置くと、375px幅で横スクロールが出ます。コードブロック、広告、CTA、関連記事の列と一緒に確認してください。
6つ目は、スクリーンショットが通るだけで安心することです。黒背景だけでもスクリーンショットは生成されます。非ブランク検査、イベント操作後の差分、モバイル幅のboundingBoxを合わせて見る必要があります。
WebGLへ進む前の判断
Canvas 2Dで線、画像、数百個程度の粒子を扱うなら、まず2Dコンテキストで十分です。何万個の点、3Dカメラ、ライティング、GPUシェーダーが必要になったら、WebGLやThree.jsを検討します。Claude CodeにWebGLシェーダーを書かせる場合も、「Canvas 2Dのフォールバック」「WebGLが使えない時の静止画像」「スマホGPUでの負荷」「スクリーンショット検証」を条件に含めます。
WebGLの基礎はWebGL Fundamentalsが分かりやすく、実務で3Dを使うならClaude CodeでThree.js 3D表現を作るガイドにつなげると判断しやすくなります。
マネタイズCTAへのつなげ方
Canvasは見た目の派手さだけでなく、収益導線にも使えます。たとえば、記事内のインタラクティブな図解は滞在時間を伸ばし、画像注釈ツールはテンプレート販売や教材販売に接続しやすく、商品シミュレーターは問い合わせや購入CTAに直結します。ただし、Canvasが本文やCTAを押し下げすぎると逆効果です。
ClaudeCodeLabでは、Claude Codeを使った実装ルール、レビュー観点、Playwright検証、記事品質改善をまとめて設計できます。チームでCanvasデモ、教育コンテンツ、データ可視化、プロダクトUIを作る場合は、Claude Code研修・導入相談で、既存リポジトリに合わせた進め方を相談できます。
まとめ
Claude CodeでCanvas開発を進めるなら、最初に「何を描くか」より「どう壊れないように描くか」を決めます。HiDPI対応はcanvas.width/heightとCSSサイズを分けること、アニメーションはrequestAnimationFrameでdtを丸めること、入力はPointer Eventsで統一すること、状態は描画から分離すること、検証はPlaywrightで非ブランクとスマホ幅まで見ることが基本です。
この記事で紹介した内容を実際に試した結果、最も効果があったのは、Claude Codeに実装と同時に「失敗例のレビュー」を頼む流れでした。Masaの検証では、固定800pxのCanvas、ctx.scaleの累積、スマホでのスクロール干渉が早い段階で見つかり、公開前の手戻りを減らせました。Canvasは派手なデモほど粗が隠れやすいので、描画コード、状態管理、スクリーンショット、CTA周辺のレイアウトを同じチェックリストで見るのが実務では一番安定します。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。