API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Live Result
Source Element
(click to edit, then re-capture)
Worker Output
OffscreenCanvas
Sync Transform
Waiting...
DOMMatrix sent back from worker
Worker FPS
—
Animation in worker thread
Message Log
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">🎨</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">📸</span>
Capture & 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">—</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">📸</span> ' +
"Capture & 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>