API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Z-order (back → front)
A → B → C → D → E
Drag any element to bring it to the front
Last paint
waiting…
Reports event.changedElements
Multiple draggable canvas children drawn at different positions with transforms, demonstrating z-ordering, changedElements paint data, and multi-element management.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Multi-Element Composition — HTML-in-Canvas</title>
<style>
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body,
:host {
display: block;
position: relative;
margin: 0;
width: 100%;
min-height: min(78vh, 820px);
height: 100%;
background:
radial-gradient(
ellipse at top left,
rgba(108, 65, 240, 0.16),
transparent 55%
),
radial-gradient(
ellipse at bottom right,
rgba(0, 229, 185, 0.13),
transparent 60%
),
#0a0a0f;
color: #f0f0f0;
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
#canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
#canvas:active {
cursor: grabbing;
}
/* ── Card elements drawn into the canvas ─────────── */
.card {
padding: 0.95rem 1.1rem;
border-radius: 12px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.85rem;
line-height: 1.4;
color: #fff;
user-select: none;
box-shadow:
0 18px 38px -18px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.card h3 {
font-size: 0.78rem;
font-weight: 700;
margin: 0 0 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.85;
cursor: grab;
}
.card p {
font-size: 0.75rem;
opacity: 0.85;
margin: 0;
}
/* Form controls inside the cards — real DOM, fully interactive */
.card input[type="text"] {
width: 100%;
padding: 0.45rem 0.7rem;
font-family: inherit;
font-size: 0.85rem;
color: #fff;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
outline: none;
}
.card input[type="text"]:focus {
border-color: rgba(255, 255, 255, 0.6);
background: rgba(0, 0, 0, 0.35);
}
.card input[type="range"] {
width: 100%;
accent-color: #fff;
}
.card .slider-label {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
margin-bottom: 0.35rem;
opacity: 0.85;
}
.card select {
width: 100%;
padding: 0.45rem 0.7rem;
font-family: inherit;
font-size: 0.85rem;
color: #fff;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
outline: none;
appearance: none;
cursor: pointer;
}
.card input[type="color"] {
width: 100%;
height: 36px;
padding: 2px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
cursor: pointer;
}
.card-image {
padding: 0.75rem;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
user-select: none;
box-shadow:
0 18px 38px -18px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.card-button {
padding: 0.6rem 1.25rem;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: background 0.15s;
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.55);
}
.card-button:hover {
background: rgba(255, 255, 255, 0.2);
}
.card-button:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* Individual card themes */
#card-a {
background: linear-gradient(135deg, #6c41f0, #9b6dff);
width: 230px;
}
#card-b {
background: linear-gradient(135deg, #00b894, #00e5b9);
width: 220px;
}
#card-c {
background: linear-gradient(135deg, #e84393, #fd79a8);
width: 110px;
height: 110px;
}
#card-d {
background: linear-gradient(135deg, #0984e3, #74b9ff);
width: 200px;
}
#card-e {
background: linear-gradient(135deg, #fdcb6e, #e17055);
width: auto;
}
/* Floating info badge — top-left of stage */
.info-badge {
position: absolute;
top: 1.25rem;
left: 1.25rem;
z-index: 2;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.7rem 0.9rem;
background: rgba(15, 15, 22, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
backdrop-filter: blur(10px);
font-family: system-ui, -apple-system, sans-serif;
max-width: 18rem;
}
.info-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.info-row .label {
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6a6a80;
}
.info-row .value {
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
font-size: 0.75rem;
color: #00e5b9;
line-height: 1.4;
word-break: break-all;
}
.info-row .hint {
font-size: 0.62rem;
color: #555570;
}
</style>
</head>
<body>
<canvas id="canvas" layoutsubtree>
<div id="card-a" class="card">
<h3>✏️ Text input</h3>
<input type="text" placeholder="Type something..." value="Real DOM!" />
</div>
<div id="card-b" class="card">
<h3>🎚️ Range</h3>
<div class="slider-label">
<span>Drag the slider</span>
<span id="slider-val">50</span>
</div>
<input id="slider" type="range" min="0" max="100" value="50" />
</div>
<div id="card-c" class="card-image">🎨</div>
<div id="card-d" class="card">
<h3>📋 Select</h3>
<select>
<option>Crossfade</option>
<option>Dissolve</option>
<option>Wipe</option>
<option>Pixel sort</option>
</select>
</div>
<button id="card-e" class="card-button">Shuffle z-order</button>
</canvas>
<div class="info-badge">
<div class="info-row">
<span class="label">Z-order (back → front)</span>
<span class="value" id="z-order-display">A → B → C → D → E</span>
<span class="hint">Drag any element to bring it to the front</span>
</div>
<div class="info-row">
<span class="label">Last paint</span>
<span class="value" id="paint-info">waiting…</span>
<span class="hint">Reports event.changedElements</span>
</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);
const canvas = $("canvas");
const ctx = canvas.getContext("2d");
const zOrderDisplay = $("z-order-display");
const paintInfo = $("paint-info");
const cardA = $("card-a");
const cardB = $("card-b");
const cardC = $("card-c");
const cardD = $("card-d");
const cardE = $("card-e");
// Each element has its own position, rotation, and scale.
// Array order = z-order (first = back).
const elements = [
{ el: cardA, label: "A", x: 60, y: 60, rotate: 0, scale: 1 },
{ el: cardB, label: "B", x: 320, y: 90, rotate: 0, scale: 0.9 },
{ el: cardC, label: "C", x: 200, y: 220, rotate: 0, scale: 1 },
{ el: cardD, label: "D", x: 80, y: 290, rotate: -5, scale: 1 },
{ el: cardE, label: "E", x: 360, y: 360, rotate: 0, scale: 1 },
];
let cssW = 0;
let cssH = 0;
let paintCount = 0;
// Subtle dot-grid background
function drawGrid(context, w, h) {
context.fillStyle = "rgba(108, 65, 240, 0.08)";
const step = 32;
for (let gx = step; gx < w; gx += step) {
for (let gy = step; gy < h; gy += step) {
context.beginPath();
context.arc(gx, gy, 1.2, 0, Math.PI * 2);
context.fill();
}
}
}
function updateZDisplay() {
zOrderDisplay.textContent = elements
.map((item) => item.label)
.join(" → ");
}
// Paint callback — fires when child rendering changes.
// Each element is drawn at its own position with its own
// transform. The array order determines z-stacking.
canvas.onpaint = (event) => {
// Bitmap is 1:1 with CSS pixels — see memory
// project_layoutsubtree_bitmap_layout. No ctx.scale(dpr).
ctx.reset();
drawGrid(ctx, cssW, cssH);
for (const item of elements) {
ctx.save();
ctx.translate(item.x, item.y);
ctx.rotate((item.rotate * Math.PI) / 180);
ctx.scale(item.scale, item.scale);
const t = ctx.drawElementImage(item.el, 0, 0);
if (t) item.el.style.transform = t.toString();
ctx.restore();
}
paintCount++;
const changed = event && event.changedElements;
if (changed && changed.length > 0) {
const names = changed
.map((el) => {
const match = elements.find((item) => item.el === el);
return match ? match.label : el.id;
})
.join(", ");
paintInfo.textContent =
"#" + paintCount + " changed: [" + names + "]";
} else {
paintInfo.textContent = "#" + paintCount + " (full repaint)";
}
updateZDisplay();
};
// Hit testing — walk the elements array in reverse (topmost first)
// and check if the point falls within the element's transformed
// bounding box.
function hitTest(px, py) {
for (let i = elements.length - 1; i >= 0; i--) {
const item = elements[i];
const el = item.el;
const w = el.offsetWidth * item.scale;
const h = el.offsetHeight * item.scale;
const rad = (-item.rotate * Math.PI) / 180;
const dx = px - item.x;
const dy = py - item.y;
const lx = dx * Math.cos(rad) - dy * Math.sin(rad);
const ly = dx * Math.sin(rad) + dy * Math.cos(rad);
if (lx >= 0 && lx <= w && ly >= 0 && ly <= h) return item;
}
return null;
}
// Drag handling. We compute coordinates from clientX/Y minus the
// canvas bounding rect because e.offsetX/Y is relative to the
// event TARGET (which can be one of the canvas children if the
// user clicks on a card), not the canvas itself.
let dragItem = null;
let dragOffsetX = 0;
let dragOffsetY = 0;
function canvasCoords(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
// Tags whose clicks should NOT initiate a drag — they're real
// form controls and the user expects clicks/focus/typing to
// work normally.
const interactiveTags = new Set([
"INPUT",
"SELECT",
"TEXTAREA",
"OPTION",
]);
canvas.addEventListener("pointerdown", (e) => {
// Skip drag if the click landed on a form control or button
const targetTag = (e.target).tagName;
if (interactiveTags.has(targetTag)) return;
const { x, y } = canvasCoords(e);
const hit = hitTest(x, y);
if (!hit) return;
dragItem = hit;
dragOffsetX = x - hit.x;
dragOffsetY = y - hit.y;
// Move dragged element to front (end of array)
const idx = elements.indexOf(hit);
if (idx !== -1 && idx < elements.length - 1) {
elements.splice(idx, 1);
elements.push(hit);
}
canvas.setPointerCapture(e.pointerId);
canvas.requestPaint?.();
});
canvas.addEventListener("pointermove", (e) => {
if (!dragItem) return;
const { x, y } = canvasCoords(e);
dragItem.x = x - dragOffsetX;
dragItem.y = y - dragOffsetY;
canvas.requestPaint?.();
});
canvas.addEventListener("pointerup", () => {
dragItem = null;
});
canvas.addEventListener("pointercancel", () => {
dragItem = null;
});
// Wire up the slider value display so the demo proves the
// form controls inside the canvas are real DOM and respond to
// input events normally.
const sliderEl = $("slider");
const sliderVal = $("slider-val");
if (sliderEl && sliderVal) {
sliderEl.addEventListener("input", () => {
sliderVal.textContent = sliderEl.value;
canvas.requestPaint?.();
});
}
// Shuffle button — Fisher-Yates on the z-order array. This shows
// that z-ordering is entirely controlled by draw order, not by
// DOM order.
cardE.addEventListener("click", () => {
for (let i = elements.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = elements[i];
elements[i] = elements[j];
elements[j] = tmp;
}
canvas.requestPaint?.();
});
// ResizeObserver: keep the bitmap 1:1 with CSS pixels. Chrome's
// layoutsubtree uses canvas.width as the layout viewport for
// child elements — DPR scaling would break card layout.
// requestPaint() so the browser's paint pipeline generates the
// cached paint records that drawElementImage depends on.
new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
cssW = width;
cssH = height;
canvas.width = Math.round(width);
canvas.height = Math.round(height);
canvas.requestPaint?.();
}).observe(canvas);
</script>
</body>
</html>