Tips & Tricks (Updated: 6/2/2026)

Canvas Development with Claude Code: HiDPI, RAF, Input, and Tests

Build production-ready Canvas UI with Claude Code: HiDPI, RAF, pointer input, state, and Playwright checks.

Canvas Development with Claude Code: HiDPI, RAF, Input, and Tests

Start with the Failure Modes

Canvas is the browser’s low-level drawing surface. It is excellent for custom charts, image annotation, particles, educational simulations, games, and product configurators. It is also easy to ship a demo that looks fine on a laptop and fails on a phone: blurry lines on HiDPI screens, mouse-only input, a hidden infinite animation loop, a canvas that creates horizontal scroll, or a black rectangle that still passes a screenshot test.

Claude Code helps most when you ask it to build the whole drawing workflow, not just a pretty effect. Give it the existing component, CSS constraints, target devices, test command, and review checklist. That lets Claude Code connect rendering code with responsive layout, cleanup, and verification.

Read this together with Claude Code animation implementation, Claude Code Three.js 3D, and Claude Code data visualization. Keep the official references close: Claude Code docs, MDN Canvas API, requestAnimationFrame, Pointer Events, and Playwright screenshots.

Prompt Claude Code with Boundaries

A vague prompt such as “make a cool canvas animation” tends to produce a fixed-size desktop demo. A production prompt should describe the rendering contract. HiDPI means high pixel density screens where one CSS pixel may map to multiple physical pixels; ignoring it makes Canvas output look soft on modern laptops and phones.

Implement a Canvas 2D demo with Claude Code.
Requirements:
- Separate CSS pixels from backing-store pixels and support devicePixelRatio
- Use requestAnimationFrame and clamp dt to avoid large jumps after tab pauses
- Use Pointer Events so mouse, touch, and pen input share one path
- Keep drawing state in one state object and keep render(ctx) side-effect-light
- Use ResizeObserver so the canvas follows its container
- Avoid horizontal scroll at 375px mobile width
- Add Playwright checks for visible canvas, non-blank pixels, screenshots, and mobile width
- Return changed files, risks, failure cases, and manual checks

The important part is not the wording. The important part is that Claude Code knows what it must preserve. It should not randomly redesign the page, move CTAs, or add a heavy library when a small drawing loop is enough.

Architecture Diagram

Give Claude Code a small mental model before asking for code. Canvas quality improves when input, state, update, render, and tests are separate.

Pointer Events
      |
      v
  input handler  --->  state update  --->  update(dt)
                                         |
ResizeObserver ---> resize(dpr)          v
                                     render(ctx)
                                         |
                                         v
                                Playwright checks

render(ctx) should draw from state. It should not register event listeners, mutate unrelated DOM, or start another animation loop. That separation makes later requests easier: undo/redo, eraser mode, screenshot tests, WebGL fallback, or mobile fixes.

Practical Use Cases

The first use case is editorial or dashboard visualization. If a standard chart library cannot express trails, live particles, dense time-series movement, or map overlays, Canvas is a good fit. Claude Code can generate the loop, but you still need loading, empty, and mobile states because the chart usually sits near article text, ads, or CTA links.

The second use case is image annotation. Screenshot markup, review tools, and learning platforms often need pen strokes, rectangles, arrows, labels, and undo. Pointer Events matter here because the same UI may be used with a mouse, finger, or stylus. On stylus-capable devices, pressure can drive line width.

The third use case is education and lightweight games. Physics demos, vocabulary games, typing trainers, and interactive diagrams benefit from frame-based state. The risk is lifecycle cleanup: if requestAnimationFrame keeps running after a route change, the user does not see a bug, but the device pays for it.

The fourth use case is product interaction. A small Canvas preview can let readers drag, compare, color, or inspect something before clicking a purchase or consultation CTA. In that context, the canvas should support the conversion path instead of pushing the CTA below the fold or creating a distracting layout shift.

Copy-Paste Canvas Demo

This standalone HTML file can be opened directly in a browser. It handles HiDPI scaling, requestAnimationFrame, Pointer Events, ResizeObserver, and mobile touch behavior. The key detail is ctx.setTransform(dpr, 0, 0, dpr, 0, 0): it replaces the transform after resize instead of stacking scale() calls.

<!doctype html>
<html lang="en">
  <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>

State Management for Drawing

Canvas does not keep a DOM tree of strokes. If you want undo, redo, erasing, labels, or replay, keep that data in JavaScript and render from it. Ask Claude Code to update state in small pure functions so the behavior can be tested without a browser.

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;
}

Playwright Verification

Canvas output is pixels, so DOM assertions are not enough. Combine visibility, mobile width, a pointer interaction, a non-blank pixel check, and a screenshot. The pixel check catches the case where the canvas exists but nothing meaningful was painted.

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,
    });
  });
}

Create the first baseline with npx playwright test tests/canvas.spec.ts --update-snapshots, then run normally in review. When a check fails, give Claude Code the failing screenshot, viewport size, DPR, and recent CSS diff.

Common Pitfalls

The first pitfall is resizing only with CSS. A canvas displayed at 100% can still have a 300x150 backing store, so the browser scales a low-resolution image. Set canvas.width and canvas.height from the CSS size multiplied by DPR.

The second pitfall is cumulative scaling. Calling ctx.scale(dpr, dpr) after every resize stacks transforms. Use setTransform to replace the transform.

The third pitfall is mouse-only input. mousemove ignores touch and pen users. Pointer Events are the practical default, and touch-action: none prevents scroll gestures from interrupting drawing.

The fourth pitfall is leaking animation loops. React unmount, route changes, modals, and hot reloads can leave requestAnimationFrame running unless cleanup cancels it.

The fifth pitfall is mobile layout. Fixed 800px canvases create horizontal scroll inside articles. Test the canvas alongside code blocks, ad slots, related article cards, and CTAs.

Monetization and Results

Canvas can support monetization when it explains something a static image cannot: an interactive tutorial, a product preview, a data tool, or an annotation workflow that leads to a paid template or consultation. It hurts monetization when it delays article text, hides the next step, or makes mobile readers fight the layout. For a team rollout, connect this with the Claude Code training and consultation page so implementation, review prompts, and Playwright checks become a repeatable workflow.

After trying this workflow, the biggest practical win was asking Claude Code for a separate review pass focused only on failure cases. That pass caught fixed-width mobile overflow, repeated ctx.scale calls, and pointer handling that worked on desktop but not touch devices. The demo became less flashy and more publishable, which is usually the better tradeoff for content sites and product surfaces.

#Claude Code #Canvas #WebGL #graphics #TypeScript
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.