API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
12 paintings · 1 DOM element
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 · <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>