Demos

Hello World

2D Beginner

The simplest HTML-in-Canvas demo — a styled div drawn into canvas with drawElementImage, showing the minimal boilerplate with annotated code.

Source Code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Hello World — HTML-in-Canvas</title>
    <style>
      /*
       * This file is authored to work in two contexts:
       *  1. As a standalone HTML page (served at /demos/hello-world/demo.html)
       *  2. Mounted inside a shadow root on the wrapped /demos/hello-world/ page
       *
       * The `body, :host` selector list targets whichever root element
       * exists in the current context. :host only matches inside a shadow
       * root; body only matches in a regular document.
       */
      html {
        height: 100%;
      }

      body,
      :host {
        display: block;
        position: relative;
        width: 100%;
        min-height: min(78vh, 820px);
        height: 100%;
        margin: 0;
        overflow: hidden;
        background:
          radial-gradient(
            ellipse at top left,
            rgba(108, 65, 240, 0.18),
            transparent 55%
          ),
          radial-gradient(
            ellipse at bottom right,
            rgba(0, 229, 185, 0.15),
            transparent 55%
          ),
          #07070d;
        color: #f0f0f0;
        font-family: system-ui, -apple-system, sans-serif;
      }

      * {
        box-sizing: border-box;
      }

      /* The canvas fills the entire stage */
      #canvas {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        display: block;
      }

      /*
       * The single DOM element drawn into the canvas. It is a real,
       * accessible heading the user can edit and select. Its CSS
       * transform is rewritten every frame to match the painted hero
       * position, so character-level text selection lines up with the
       * pixels on screen.
       */
      #content {
        position: absolute;
        left: 0;
        top: 0;
        padding: 1rem 2.25rem;
        font-size: clamp(2.25rem, 5.5vw, 4.25rem);
        font-weight: 800;
        letter-spacing: -0.025em;
        background: linear-gradient(
          135deg,
          #6c41f0 0%,
          #d52e66 45%,
          #ff5926 70%,
          #00e5b9 100%
        );
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
        background-clip: text;
        line-height: 1.05;
        white-space: nowrap;
        /* Disable transitions so the per-frame transform updates feel
           instantaneous instead of lagging behind the canvas paint. */
        transition: none;
        transform-origin: 0 0;
      }

      /* Floating control bar */
      .controls {
        position: absolute;
        left: 50%;
        bottom: 1.5rem;
        transform: translateX(-50%);
        display: flex;
        align-items: center;
        gap: 0.75rem;
        padding: 0.65rem 0.85rem;
        background: rgba(15, 15, 22, 0.72);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 999px;
        backdrop-filter: blur(12px);
        box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.6);
        z-index: 2;
      }

      .controls input[type="text"] {
        width: clamp(12rem, 30vw, 22rem);
        padding: 0.5rem 0.85rem;
        font-family: inherit;
        font-size: 0.9rem;
        color: #f0f0f0;
        background: rgba(0, 0, 0, 0.4);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 999px;
      }

      .controls input[type="text"]:focus {
        outline: none;
        border-color: rgba(108, 65, 240, 0.6);
        box-shadow: 0 0 0 3px rgba(108, 65, 240, 0.2);
      }

      .controls .divider {
        width: 1px;
        height: 1.5rem;
        background: rgba(255, 255, 255, 0.08);
      }

      .controls label {
        display: flex;
        align-items: center;
        gap: 0.5rem;
        font-size: 0.75rem;
        color: #8a8aa0;
        white-space: nowrap;
      }

      .controls input[type="range"] {
        width: 6rem;
        accent-color: #6c41f0;
      }

      .badge {
        position: absolute;
        top: 1.25rem;
        left: 1.25rem;
        padding: 0.4rem 0.7rem;
        font-size: 0.7rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.08em;
        color: #a0a0b8;
        background: rgba(15, 15, 22, 0.72);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 6px;
        backdrop-filter: blur(8px);
        z-index: 2;
      }

      .badge strong {
        color: #00e5b9;
        font-weight: 700;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" layoutsubtree>
      <div id="content">Hello, Canvas!</div>
    </canvas>

    <div class="badge">
      <span id="copy-count">12</span> paintings &middot; <strong>1</strong> DOM element
    </div>

    <div class="controls">
      <input
        id="text-input"
        type="text"
        value="Hello, Canvas!"
        autocomplete="off"
        spellcheck="false"
        aria-label="Text drawn into canvas"
      />
      <span class="divider"></span>
      <label>
        Copies
        <input
          id="copies-input"
          type="range"
          min="1"
          max="32"
          value="12"
        />
      </label>
      <label>
        Speed
        <input
          id="speed-input"
          type="range"
          min="0"
          max="200"
          value="100"
        />
      </label>
    </div>

    <script>
      // Resolve the script's root: a ShadowRoot when mounted via shadow DOM,
      // or `document` when this file is served standalone. Both expose the
      // same getElementById/querySelector surface. The wrapped page hands
      // the script its shadow root via a transient `window.__demoRoot`.
      const root = window.__demoRoot ?? document;
      const stage = root.host ?? document.body;

      const canvas = root.getElementById("canvas");
      const ctx = canvas.getContext("2d");
      const content = root.getElementById("content");

      const textInput = root.getElementById("text-input");
      const copiesInput = root.getElementById("copies-input");
      const speedInput = root.getElementById("speed-input");
      const copyCountEl = root.getElementById("copy-count");

      // ── Animation state ─────────────────────────────────────────
      let copies = 12;
      let speed = 1; // multiplier
      let mouseX = 0.5;
      let mouseY = 0.5;
      let targetX = 0.5;
      let targetY = 0.5;
      let phase = 0;
      let lastFrame = performance.now();

      // ── Cross-version API compatibility ─────────────────────────
      // Chrome Canary currently exposes both names; the spec uses
      // drawElementImage. Use whichever exists.
      const drawElementInto =
        ctx.drawElementImage?.bind(ctx) ?? ctx.drawElement?.bind(ctx);

      // ── Inputs ──────────────────────────────────────────────────
      textInput.addEventListener("input", () => {
        content.textContent = textInput.value || " ";
        canvas.requestPaint?.();
      });

      copiesInput.addEventListener("input", () => {
        copies = Number(copiesInput.value);
        copyCountEl.textContent = String(copies);
        canvas.requestPaint?.();
      });

      speedInput.addEventListener("input", () => {
        speed = Number(speedInput.value) / 100;
      });

      // Pointer parallax — the ring tilts toward the cursor.
      // Listening on window means the parallax tracks even when the
      // pointer is over chrome outside the demo stage.
      window.addEventListener("pointermove", (e) => {
        const rect = stage.getBoundingClientRect();
        targetX = (e.clientX - rect.left) / rect.width;
        targetY = (e.clientY - rect.top) / rect.height;
      });

      // ── Paint callback ──────────────────────────────────────────
      // Draws the SAME DOM element many times in a rotating ring.
      // The hero copy at the center is the canonical painting whose
      // returned DOMMatrix is applied back to the source element so
      // that DOM hit testing and text selection line up with pixels.
      canvas.onpaint = () => {
        // Chrome's <canvas layoutsubtree> uses the canvas's bitmap
        // dimensions as the layout viewport for children, so we size
        // the bitmap 1:1 with CSS pixels — see
        // project_layoutsubtree_bitmap_layout memory. The trade-off
        // is slightly softer rendering on Retina displays.
        const cssW = canvas.width;
        const cssH = canvas.height;

        // Smoothly chase the cursor target
        mouseX += (targetX - mouseX) * 0.08;
        mouseY += (targetY - mouseY) * 0.08;

        ctx.reset();

        // Soft motion-trail wash. Filling with translucent black each
        // frame produces ghostly trails behind moving copies.
        ctx.fillStyle = "rgba(7, 7, 13, 0.22)";
        ctx.fillRect(0, 0, cssW, cssH);

        // Measure the source element so paintings center on its origin
        const rect = content.getBoundingClientRect();
        const halfW = rect.width / 2;
        const halfH = rect.height / 2;

        const cx = cssW / 2;
        const cy = cssH / 2;
        // Tilt the ring center toward the pointer for parallax
        const offsetX = (mouseX - 0.5) * cssW * 0.18;
        const offsetY = (mouseY - 0.5) * cssH * 0.18;

        // Ring radius scales with stage size
        const radius = Math.min(cssW, cssH) * 0.32;

        // Decorative ring copies — no DOM sync, pure painting
        for (let i = 0; i < copies; i++) {
          const t = i / copies;
          const angle = phase + t * Math.PI * 2;
          const px = cx + offsetX + Math.cos(angle) * radius;
          const py = cy + offsetY + Math.sin(angle) * radius * 0.55;

          const depth = (Math.sin(angle) + 1) / 2; // 0..1, 1 = front
          const scale = 0.35 + depth * 0.65;

          ctx.save();
          ctx.translate(px, py);
          ctx.rotate(angle + Math.PI / 2);
          ctx.scale(scale, scale);
          ctx.globalAlpha = 0.25 + depth * 0.75;
          drawElementInto(content, -halfW, -halfH);
          ctx.restore();
        }

        // Hero copy at the center — drawn last so it's on top, and the
        // returned transform is synced back to the DOM element so that
        // selection / focus / hit testing line up with the pixels.
        ctx.save();
        ctx.translate(cx + offsetX * 0.4, cy + offsetY * 0.4);
        ctx.scale(1.2, 1.2);
        ctx.globalAlpha = 1;
        const heroTransform = drawElementInto(content, -halfW, -halfH);
        ctx.restore();

        if (heroTransform) {
          content.style.transform = heroTransform.toString();
        }
      };

      // ── Drive a continuous animation ────────────────────────────
      // Use requestPaint() so the browser's paint pipeline runs each
      // frame and generates the cached paint records drawElementImage
      // depends on.
      function loop(now) {
        const dt = (now - lastFrame) / 1000;
        lastFrame = now;
        phase += dt * speed * 0.6;
        canvas.requestPaint?.();
        requestAnimationFrame(loop);
      }
      requestAnimationFrame((t) => {
        lastFrame = t;
        loop(t);
      });

      // ── ResizeObserver: keep the bitmap sized 1:1 with CSS pixels.
      // We do NOT scale by devicePixelRatio because Chrome's
      // layoutsubtree uses canvas.width as the layout viewport for
      // children — a DPR-scaled bitmap doubles the layout width and
      // breaks text wrapping / percentage padding on Retina displays.
      new ResizeObserver(([entry]) => {
        const { width, height } = entry.contentRect;
        canvas.width = Math.round(width);
        canvas.height = Math.round(height);
        canvas.requestPaint?.();
      }).observe(canvas);
    </script>
  </body>
</html>