Demos

OffscreenCanvas Worker

2D Advanced

Captures HTML as a transferable ElementImage and sends it to a Web Worker for rendering on an OffscreenCanvas, demonstrating the worker communication pattern with postMessage.

Source Code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>OffscreenCanvas Worker — HTML-in-Canvas</title>
    <style>
      * {
        box-sizing: border-box;
      }

      html {
        height: 100%;
      }

      body,
      :host {
        display: block;
        margin: 0;
        min-height: 100%;
        background: #0a0a0f;
        color: #f0f0f0;
        font-family: system-ui, -apple-system, sans-serif;
        padding: 1.5rem;
        overflow-x: hidden;
      }

      .layout {
        max-width: 760px;
        margin: 0 auto;
        display: flex;
        flex-direction: column;
        gap: 1rem;
      }

      .demo-panel {
        background: #14141f;
        border-radius: 12px;
        padding: 1.5rem;
        border: 1px solid #282840;
      }

      /* ── Section labels ──────────────────────────────── */
      .section-label {
        font-size: 0.7rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        color: #6a6a80;
        margin-bottom: 0.5rem;
        display: flex;
        align-items: center;
        gap: 0.5rem;
      }

      .edit-hint {
        font-weight: 400;
        text-transform: none;
        letter-spacing: 0;
        color: #4a4a60;
        font-size: 0.65rem;
      }

      .worker-badge {
        font-weight: 400;
        text-transform: none;
        letter-spacing: 0;
        font-size: 0.6rem;
        background: #2a1a4e;
        color: #9b6dff;
        padding: 0.15em 0.5em;
        border-radius: 4px;
      }

      /* ── Source element area ──────────────────────────── */
      .source-area {
        margin-bottom: 1rem;
      }

      /* The source lives inside a <canvas layoutsubtree> so we can
         capture it as an ImageBitmap. The canvas itself is visually
         transparent — the source div is laid out as a child and is
         the thing the user sees and edits. */
      #source-canvas {
        display: block;
        width: 100%;
      }

      #source {
        background: #0d0d18;
        border-radius: 8px;
        padding: 1rem;
        border: 1px dashed #282840;
        outline: none;
        transition: border-color 0.15s;
      }

      #source:focus {
        border-color: #6c41f0;
      }

      .source-card {
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
      }

      .source-card-icon {
        font-size: 1.75rem;
        line-height: 1;
      }

      .source-card h3 {
        font-size: 0.95rem;
        font-weight: 700;
        color: #e0e0f0;
      }

      .source-card p {
        font-size: 0.78rem;
        line-height: 1.5;
        color: #8080a0;
      }

      .source-card code {
        background: #282840;
        padding: 0.1em 0.35em;
        border-radius: 3px;
        font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
        font-size: 0.72rem;
        color: #c3e88d;
      }

      .source-card-badge {
        display: inline-block;
        width: fit-content;
        font-size: 0.65rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        background: linear-gradient(135deg, #6c41f0, #9b6dff);
        color: #fff;
        padding: 0.25em 0.75em;
        border-radius: 4px;
      }

      /* ── Controls row ────────────────────────────────── */
      .controls {
        display: flex;
        align-items: center;
        gap: 1rem;
        margin-bottom: 1rem;
      }

      .btn-primary {
        display: inline-flex;
        align-items: center;
        gap: 0.4rem;
        padding: 0.55rem 1rem;
        border-radius: 8px;
        border: none;
        background: linear-gradient(135deg, #6c41f0, #9b6dff);
        color: #fff;
        font-family: inherit;
        font-size: 0.78rem;
        font-weight: 600;
        cursor: pointer;
        transition: opacity 0.15s;
      }

      .btn-primary:hover {
        opacity: 0.9;
      }

      .btn-primary:active {
        opacity: 0.8;
      }

      .btn-primary:focus-visible {
        outline: 2px solid #9b6dff;
        outline-offset: 2px;
      }

      .btn-icon {
        font-size: 0.9rem;
      }

      .toggle {
        display: flex;
        align-items: center;
        gap: 0.4rem;
        font-size: 0.75rem;
        color: #8080a0;
        cursor: pointer;
        user-select: none;
      }

      .toggle input {
        accent-color: #6c41f0;
      }

      /* ── Worker canvas ───────────────────────────────── */
      canvas {
        width: 100%;
        aspect-ratio: 4 / 3;
        display: block;
        border-radius: 8px;
        border: 1px solid #282840;
        background: #0a0a0f;
      }

      .canvas-area {
        margin-bottom: 1rem;
      }

      /* ── Info row ────────────────────────────────────── */
      .info-row {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 0.75rem;
        margin-top: 1rem;
      }

      .info-box {
        background: #1a1a2e;
        border-radius: 8px;
        padding: 0.75rem 1rem;
        border: 1px solid #282840;
      }

      .info-box .label {
        font-size: 0.7rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        color: #6a6a80;
        margin-bottom: 0.35rem;
      }

      .info-box .value {
        font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
        font-size: 0.72rem;
        color: #00e5b9;
        line-height: 1.5;
        white-space: pre-wrap;
        word-break: break-all;
      }

      .info-box .hint {
        font-size: 0.65rem;
        color: #555570;
        margin-top: 0.25rem;
      }

      /* ── Message log ─────────────────────────────────── */
      .log-area {
        margin-top: 1rem;
      }

      .log-panel {
        background: #0d0d18;
        border-radius: 8px;
        padding: 0.75rem 1rem;
        border: 1px solid #1e1e30;
        max-height: 140px;
        overflow-y: auto;
        font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
        font-size: 0.7rem;
        line-height: 1.7;
      }

      .log-entry {
        display: flex;
        gap: 0.5rem;
        color: #6a6a80;
      }

      .log-entry .log-dir {
        flex-shrink: 0;
        font-weight: 700;
      }

      .log-dir.to-worker {
        color: #6c41f0;
      }

      .log-dir.from-worker {
        color: #00e5b9;
      }

      .log-entry .log-text {
        color: #8080a0;
      }

    </style>
  </head>
  <body>
    <div class="layout">
      <div class="demo-panel">
        <h2>Live Result</h2>

        <!-- Source HTML element to capture. Wrapping it in a
             <canvas layoutsubtree> means the card is a real, editable
             DOM element AND lives in a canvas whose bitmap we can
             transfer to the worker via createImageBitmap(). -->
        <div class="source-area">
          <div class="section-label">
            Source Element
            <span class="edit-hint">(click to edit, then re-capture)</span>
          </div>
          <canvas id="source-canvas" layoutsubtree>
            <div id="source" contenteditable="true">
              <div class="source-card">
                <div class="source-card-icon">&#x1F3A8;</div>
                <h3>HTML Content</h3>
                <p>
                  This DOM element is captured as a transferable
                  <code>ElementImage</code> and sent to a Web Worker
                  for off-thread rendering.
                </p>
                <div class="source-card-badge">Transferable</div>
              </div>
            </div>
          </canvas>
        </div>

        <!-- Controls -->
        <div class="controls">
          <button id="capture-btn" class="btn-primary">
            <span class="btn-icon">&#x1F4F8;</span>
            Capture &amp; Send to Worker
          </button>
          <label class="toggle">
            <input type="checkbox" id="animate-toggle" checked />
            <span>Animate</span>
          </label>
        </div>

        <!-- Worker-rendered canvas -->
        <div class="canvas-area">
          <div class="section-label">
            Worker Output
            <span class="worker-badge">OffscreenCanvas</span>
          </div>
          <canvas id="output"></canvas>
        </div>

        <div class="info-row">
          <div class="info-box">
            <div class="label">Sync Transform</div>
            <div class="value" id="transform-display">Waiting...</div>
            <div class="hint">DOMMatrix sent back from worker</div>
          </div>
          <div class="info-box">
            <div class="label">Worker FPS</div>
            <div class="value" id="fps-display">&mdash;</div>
            <div class="hint">Animation in worker thread</div>
          </div>
        </div>

        <!-- Message log -->
        <div class="log-area">
          <div class="section-label">Message Log</div>
          <div id="log" class="log-panel"></div>
        </div>
      </div>

    </div>

    <script>
      // Resolve the script's root: a ShadowRoot when mounted via shadow
      // DOM, or `document` when this file is served standalone.
      const root = window.__demoRoot ?? document;
      const $ = (id) => root.getElementById(id);

      /* ── DOM references ────────────────────────────── */
      const canvas = $("output");
      const sourceCanvas = $("source-canvas");
      const source = $("source");
      const captureBtn = $("capture-btn");
      const animateToggle = $("animate-toggle");
      const transformDisplay = $("transform-display");
      const fpsDisplay = $("fps-display");
      const logPanel = $("log");

      /* ──────────────────────────────────────────────────
       * captureElementImage polyfill
       * ──────────────────────────────────────────────────
       * The WICG spec proposes `canvas.captureElementImage(element)`
       * which returns a transferable `ElementImage` that can be
       * posted to a worker. Chrome Canary doesn't implement it yet.
       *
       * Polyfill: the source element already lives inside a
       * `<canvas layoutsubtree>` in the DOM (see #source-canvas in
       * the HTML). On capture:
       *   1. Size the source canvas's bitmap to match the source
       *      element's natural layout dimensions.
       *   2. Paint the source element into the canvas via
       *      drawElementImage.
       *   3. Call `createImageBitmap(sourceCanvas)` to pull a
       *      transferable ImageBitmap out of the canvas bitmap.
       *   4. Post that ImageBitmap to the worker.
       *
       * The worker draws the ImageBitmap the same way it would have
       * drawn an ElementImage — the shape of the API is different
       * but the semantics are equivalent.
       */
      const sourceCtx = sourceCanvas.getContext("2d");

      async function captureElementImage(element) {
        // Resize the source canvas bitmap to match the source div's
        // current natural dimensions. We read getBoundingClientRect()
        // BEFORE mutating canvas.width (which would reset the
        // layoutsubtree children's layout mid-measurement).
        const rect = element.getBoundingClientRect();
        const w = Math.max(1, Math.round(rect.width));
        const h = Math.max(1, Math.round(rect.height));
        if (sourceCanvas.width !== w || sourceCanvas.height !== h) {
          sourceCanvas.width = w;
          sourceCanvas.height = h;
        }

        // Paint the element into the canvas bitmap and wait for the
        // paint cycle to complete before reading pixels.
        await new Promise((resolve) => {
          sourceCanvas.onpaint = () => {
            sourceCtx.clearRect(0, 0, w, h);
            sourceCtx.drawElementImage(element, 0, 0);
            resolve();
          };
          if (sourceCanvas.requestPaint) sourceCanvas.requestPaint();
          else resolve();
        });

        // Pull a transferable ImageBitmap out of the canvas. We go
        // through toBlob() + createImageBitmap(blob) instead of
        // createImageBitmap(canvas) because Chrome Canary crashes the
        // renderer when you pass a <canvas layoutsubtree> element
        // directly to createImageBitmap. Routing through a Blob adds
        // a small amount of overhead but produces a valid, safely
        // transferable ImageBitmap.
        const blob = await new Promise((resolve, reject) => {
          sourceCanvas.toBlob((b) => {
            if (b) resolve(b);
            else reject(new Error('canvas.toBlob returned null'));
          }, 'image/png');
        });
        return await createImageBitmap(blob);
      }

      /* ── Message log utility ───────────────────────── */
      function log(direction, text) {
        const entry = document.createElement("div");
        entry.className = "log-entry";

        const dir = document.createElement("span");
        dir.className =
          "log-dir " +
          (direction === "to" ? "to-worker" : "from-worker");
        dir.textContent = direction === "to" ? "\u2192" : "\u2190";

        const msg = document.createElement("span");
        msg.className = "log-text";
        msg.textContent = text;

        entry.appendChild(dir);
        entry.appendChild(msg);
        logPanel.appendChild(entry);
        logPanel.scrollTop = logPanel.scrollHeight;

        /* Keep log at a reasonable size */
        while (logPanel.children.length > 50) {
          logPanel.removeChild(logPanel.firstChild);
        }
      }

      /* ── Worker code (inline Blob) ─────────────────── */
      const workerSource = [
        '"use strict";',
        "",
        "let canvas = null;",
        "let ctx = null;",
        "let currentImage = null;",
        "let dpr = 1;",
        "let angle = 0;",
        "let animating = true;",
        "let frameCount = 0;",
        "let lastFpsTime = 0;",
        "let currentFps = 0;",
        "",
        "self.onmessage = (e) => {",
        "  const msg = e.data;",
        "",
        '  if (msg.type === "init") {',
        "    canvas = msg.canvas;",
        "    dpr = msg.dpr || 1;",
        '    ctx = canvas.getContext("2d");',
        "    self.postMessage({",
        '      type: "log",',
        '      text: "Worker ready, OffscreenCanvas " +',
        '        canvas.width + "\\u00d7" + canvas.height',
        "    });",
        "    tick();",
        "  }",
        "",
        '  if (msg.type === "image") {',
        "    currentImage = msg.image;",
        "    self.postMessage({",
        '      type: "log",',
        '      text: "ElementImage received (" +',
        '        currentImage.width + "\\u00d7" +',
        '        currentImage.height + ")"',
        "    });",
        "  }",
        "",
        '  if (msg.type === "resize") {',
        "    canvas.width = msg.width;",
        "    canvas.height = msg.height;",
        "    dpr = msg.dpr || dpr;",
        "  }",
        "",
        '  if (msg.type === "animate") {',
        "    animating = msg.value;",
        "  }",
        "};",
        "",
        "/* Draw a subtle dot grid */",
        "function drawGrid(w, h) {",
        '  ctx.fillStyle = "#1a1a2e";',
        "  const step = 24 * dpr;",
        "  for (let gx = step; gx < w; gx += step) {",
        "    for (let gy = step; gy < h; gy += step) {",
        "      ctx.beginPath();",
        "      ctx.arc(gx, gy, dpr, 0, Math.PI * 2);",
        "      ctx.fill();",
        "    }",
        "  }",
        "}",
        "",
        "function tick() {",
        "  if (!ctx) { requestAnimationFrame(tick); return; }",
        "",
        "  const w = canvas.width;",
        "  const h = canvas.height;",
        "",
        "  /* Clear and draw background */",
        '  ctx.fillStyle = "#0d0d18";',
        "  ctx.fillRect(0, 0, w, h);",
        "  drawGrid(w, h);",
        "",
        "  if (currentImage) {",
        "    const iw = currentImage.width;",
        "    const ih = currentImage.height;",
        "",
        "    /* Scale to fit nicely in the canvas */",
        "    const fitScale = Math.min(",
        "      (w * 0.45) / iw,",
        "      (h * 0.45) / ih",
        "    );",
        "",
        "    if (animating) angle += 0.006;",
        "",
        "    /* Draw main centered copy with rotation */",
        "    ctx.save();",
        "    ctx.translate(w / 2, h / 2);",
        "    ctx.rotate(angle);",
        "    ctx.scale(fitScale, fitScale);",
        "",
        "    /* Drop shadow */",
        '    ctx.shadowColor = "rgba(108, 65, 240, 0.3)";',
        "    ctx.shadowBlur = 24 * dpr;",
        "    ctx.shadowOffsetX = 0;",
        "    ctx.shadowOffsetY = 4 * dpr;",
        "    ctx.drawImage(currentImage, -iw / 2, -ih / 2);",
        "",
        "    const transform = ctx.getTransform();",
        "    ctx.restore();",
        "",
        "    /* Draw 4 smaller orbiting ghost copies */",
        "    const orbitRadius = Math.min(w, h) * 0.33;",
        "    for (let i = 0; i < 4; i++) {",
        "      const orbitAngle = angle * 1.5 + (i * Math.PI / 2);",
        "      const ox = w / 2 + Math.cos(orbitAngle) * orbitRadius;",
        "      const oy = h / 2 + Math.sin(orbitAngle) * orbitRadius;",
        "      const ghostScale = fitScale * 0.35;",
        "",
        "      ctx.save();",
        "      ctx.globalAlpha = 0.25;",
        "      ctx.translate(ox, oy);",
        "      ctx.rotate(orbitAngle + Math.PI / 6);",
        "      ctx.scale(ghostScale, ghostScale);",
        "      ctx.drawImage(currentImage, -iw / 2, -ih / 2);",
        "      ctx.restore();",
        "    }",
        "",
        "    /* Send transform back for accessibility sync */",
        "    self.postMessage({",
        '      type: "transform",',
        "      matrix: {",
        "        a: transform.a, b: transform.b,",
        "        c: transform.c, d: transform.d,",
        "        e: transform.e, f: transform.f",
        "      }",
        "    });",
        "  } else {",
        "    /* Waiting state */",
        '    ctx.fillStyle = "#4a4a60";',
        "    ctx.font = (14 * dpr) + " +
          '"px system-ui, -apple-system, sans-serif";',
        '    ctx.textAlign = "center";',
        '    ctx.fillText("Waiting for ElementImage\\u2026",',
        "      w / 2, h / 2 - 8 * dpr);",
        '    ctx.fillStyle = "#3a3a50";',
        "    ctx.font = (12 * dpr) + " +
          '"px system-ui, -apple-system, sans-serif";',
        '    ctx.fillText("Click \\u201cCapture & Send\\u201d above",',
        "      w / 2, h / 2 + 16 * dpr);",
        "  }",
        "",
        "  /* FPS counter */",
        "  frameCount++;",
        "  const now = performance.now();",
        "  if (now - lastFpsTime >= 1000) {",
        "    currentFps = frameCount;",
        "    frameCount = 0;",
        "    lastFpsTime = now;",
        "    self.postMessage({",
        '      type: "fps",',
        "      value: currentFps",
        "    });",
        "  }",
        "",
        "  /* Draw FPS in corner */",
        '  ctx.fillStyle = "#3a3a50";',
        '  ctx.font = (11 * dpr) + "px monospace";',
        '  ctx.textAlign = "left";',
        "  ctx.fillText(currentFps + " +
          '" fps", 8 * dpr, 16 * dpr);',
        "",
        "  requestAnimationFrame(tick);",
        "}",
      ].join("\n");

      const workerBlob = new Blob([workerSource], {
        type: "application/javascript",
      });
      const workerURL = URL.createObjectURL(workerBlob);

      /* ── Create worker + transfer OffscreenCanvas ──── */
      const offscreen = canvas.transferControlToOffscreen();
      const worker = new Worker(workerURL);

      /* Set initial canvas size before transfer */
      const dp = devicePixelRatio;
      const rect = canvas.getBoundingClientRect();
      offscreen.width = Math.round(rect.width * dp);
      offscreen.height = Math.round(rect.height * dp);

      worker.postMessage(
        { type: "init", canvas: offscreen, dpr: dp },
        [offscreen],
      );

      log("to", "init: OffscreenCanvas " +
        offscreen.width + "\u00d7" + offscreen.height +
        " (transferred)");

      /* ── Resize handling ───────────────────────────── */
      new ResizeObserver(([entry]) => {
        const { width, height } = entry.contentRect;
        const pw = Math.round(width * dp);
        const ph = Math.round(height * dp);
        worker.postMessage({
          type: "resize",
          width: pw,
          height: ph,
          dpr: dp,
        });
      }).observe(canvas);

      /* ── Capture & send ────────────────────────────── */
      captureBtn.addEventListener("click", async () => {
        captureBtn.disabled = true;
        captureBtn.textContent = "Capturing\u2026";

        try {
          const t0 = performance.now();
          const image = await captureElementImage(source);
          const captureMs = (performance.now() - t0).toFixed(1);

          log(
            "to",
            "image: ElementImage " +
              image.width + "\u00d7" + image.height +
              " (captured in " + captureMs + "ms, transferring\u2026)",
          );

          worker.postMessage(
            { type: "image", image: image },
            [image],
          );

          log("to", "image transferred (zero-copy)");
        } catch (err) {
          log("to", "ERROR: " + err.message);
        }

        captureBtn.disabled = false;
        captureBtn.innerHTML =
          '<span class="btn-icon">&#x1F4F8;</span> ' +
          "Capture &amp; Send to Worker";
      });

      /* ── Animate toggle ────────────────────────────── */
      animateToggle.addEventListener("change", () => {
        const val = animateToggle.checked;
        worker.postMessage({ type: "animate", value: val });
        log("to", "animate: " + val);
      });

      /* ── Auto-run the first capture after the worker boots ──
       * The demo is self-demoing: the visitor arrives and sees the
       * worker thread already rendering the captured element image
       * with the rotating animation running. They can then click
       * Capture again to re-send a snapshot after editing the
       * source element. A 700ms delay lets the demo fonts load and
       * the captureElementImage first paint cycle settle before we
       * transfer the image into the worker.
       */
      setTimeout(() => {
        // Honor the default `checked` state of the animate toggle
        // by posting the initial animate message before the first
        // capture, so the worker's render loop is already running
        // when the image arrives.
        worker.postMessage({
          type: "animate",
          value: animateToggle.checked,
        });
        captureBtn.click();
      }, 700);

      /* ── Receive messages from worker ──────────────── */
      let transformThrottle = 0;

      worker.onmessage = (e) => {
        const msg = e.data;

        if (msg.type === "transform") {
          const m = msg.matrix;
          const formatted =
            "matrix(" +
            m.a.toFixed(3) + ", " + m.b.toFixed(3) + ",\n" +
            "       " +
            m.c.toFixed(3) + ", " + m.d.toFixed(3) + ",\n" +
            "       " +
            m.e.toFixed(1) + ", " + m.f.toFixed(1) + ")";
          transformDisplay.textContent = formatted;

          /*
           * Log transform updates at most once per second
           * to avoid flooding the log.
           */
          const now = Date.now();
          if (now - transformThrottle > 2000) {
            transformThrottle = now;
            log(
              "from",
              "transform: a=" + m.a.toFixed(3) +
                " b=" + m.b.toFixed(3) +
                " e=" + m.e.toFixed(1) +
                " f=" + m.f.toFixed(1),
            );
          }
        }

        if (msg.type === "fps") {
          fpsDisplay.textContent = msg.value + " fps";
        }

        if (msg.type === "log") {
          log("from", msg.text);
        }
      };

      /* ── Cleanup ───────────────────────────────────── */
      window.addEventListener("unload", () => {
        worker.terminate();
        URL.revokeObjectURL(workerURL);
      });
    </script>
  </body>
</html>