API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Paint events
0
Focus a text field — cursor blink fires onpaint
A full HTML form rendered inside canvas — inputs, checkboxes, selects, sliders, and buttons all remain fully interactive, proving that typing, clicking, and tabbing work natively.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Interactive Form — 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 right,
rgba(108, 65, 240, 0.15),
transparent 55%
),
radial-gradient(
ellipse at bottom left,
rgba(0, 229, 185, 0.12),
transparent 60%
),
#0a0a0f;
color: #f0f0f0;
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
/* The canvas fills the stage; the form is laid out by the
layoutsubtree contract and drawn into the canvas pixels. */
#canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/*
* The form is a real DOM element. Every input, select, slider,
* checkbox, radio, and button is interactive — clicks, focus,
* keyboard, autofill, and screen readers all work natively
* because these are real elements, not painted approximations.
*
* Position is set explicitly here. Each frame, drawElementImage
* returns a DOMMatrix that we apply back to #form-root so the
* DOM elements stay aligned with the painted pixels.
*/
#form-root {
position: absolute;
left: 0;
top: 0;
width: min(560px, calc(100vw - 4rem));
padding: 1.75rem 2rem;
border-radius: 18px;
background: rgba(20, 20, 31, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
box-shadow:
0 20px 60px -20px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(108, 65, 240, 0.08);
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.875rem;
color: #e0e0f0;
transition: none;
transform-origin: 0 0;
}
.form-title {
font-size: 1rem;
font-weight: 700;
margin: 0 0 1.25rem;
color: #f0f0f0;
letter-spacing: -0.01em;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field.full-width {
grid-column: 1 / -1;
}
.field label {
font-size: 0.7rem;
font-weight: 600;
color: #8080a0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field input[type="text"],
.field input[type="email"],
.field textarea,
.field select {
background: #0d0d18;
border: 1px solid #282840;
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: #e0e0f0;
font-family: inherit;
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field input[type="text"]:focus,
.field input[type="email"]:focus,
.field textarea:focus,
.field select:focus {
border-color: #6c41f0;
box-shadow: 0 0 0 3px rgba(108, 65, 240, 0.18);
}
.field textarea {
resize: vertical;
min-height: 3.25rem;
line-height: 1.45;
}
.field select {
cursor: pointer;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #6a6a80 50%),
linear-gradient(135deg, #6a6a80 50%, transparent 50%);
background-position:
calc(100% - 14px) 50%,
calc(100% - 9px) 50%;
background-size: 5px 5px;
background-repeat: no-repeat;
padding-right: 2rem;
}
.inline-options {
display: flex;
gap: 0.85rem;
flex-wrap: wrap;
}
.inline-options label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 400;
color: #c0c0d8;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.inline-options input[type="checkbox"],
.inline-options input[type="radio"] {
accent-color: #6c41f0;
width: 0.95rem;
height: 0.95rem;
cursor: pointer;
}
.slider-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.slider-row input[type="range"] {
flex: 1;
accent-color: #6c41f0;
height: 6px;
cursor: pointer;
}
.slider-value {
font-size: 0.78rem;
color: #6c41f0;
font-weight: 700;
min-width: 2rem;
text-align: right;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
}
.form-actions {
grid-column: 1 / -1;
display: flex;
gap: 0.6rem;
justify-content: flex-end;
padding-top: 0.25rem;
}
.btn {
padding: 0.5rem 1.1rem;
border-radius: 6px;
border: 1px solid #282840;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-secondary {
background: transparent;
color: #a0a0b8;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.04);
border-color: #3a3a58;
}
.btn-primary {
background: #6c41f0;
color: #fff;
border-color: #6c41f0;
}
.btn-primary:hover {
background: #7a52ff;
border-color: #7a52ff;
}
.btn:focus-visible {
outline: 2px solid #00e5b9;
outline-offset: 2px;
}
/* Floating paint counter — top-right of stage */
.paint-badge {
position: absolute;
top: 1.25rem;
right: 1.25rem;
z-index: 2;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
padding: 0.55rem 0.85rem;
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;
}
.paint-badge .label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6a6a80;
}
.paint-badge .count {
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
font-size: 1.1rem;
font-weight: 700;
color: #00e5b9;
line-height: 1;
}
.paint-badge .hint {
font-size: 0.62rem;
color: #555570;
margin-top: 0.1rem;
}
</style>
</head>
<body>
<canvas id="canvas" layoutsubtree>
<div id="form-root">
<h1 class="form-title">Sign up</h1>
<div class="form-grid">
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" placeholder="Jane Doe" autocomplete="off" />
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" placeholder="jane@example.com" autocomplete="off" />
</div>
<div class="field full-width">
<label for="message">Message</label>
<textarea id="message" rows="2" placeholder="Type something..."></textarea>
</div>
<div class="field">
<label for="role">Role</label>
<select id="role">
<option value="">Choose one</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="pm">Product Manager</option>
<option value="other">Other</option>
</select>
</div>
<div class="field">
<label>Experience</label>
<div class="slider-row">
<input type="range" id="experience" min="0" max="10" value="5" />
<span class="slider-value" id="exp-value">5</span>
</div>
</div>
<div class="field full-width">
<label>Interests</label>
<div class="inline-options">
<label><input type="checkbox" name="interest" value="graphics" /> Graphics</label>
<label><input type="checkbox" name="interest" value="a11y" /> Accessibility</label>
<label><input type="checkbox" name="interest" value="perf" /> Performance</label>
</div>
</div>
<div class="field full-width">
<label>Preferred API</label>
<div class="inline-options">
<label><input type="radio" name="api" value="2d" checked /> Canvas 2D</label>
<label><input type="radio" name="api" value="webgl" /> WebGL</label>
<label><input type="radio" name="api" value="webgpu" /> WebGPU</label>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="btn-reset">Reset</button>
<button type="button" class="btn btn-primary" id="btn-submit">Submit</button>
</div>
</div>
</div>
</canvas>
<div class="paint-badge">
<div class="label">Paint events</div>
<div class="count" id="paint-count">0</div>
<div class="hint">Focus a text field — cursor blink fires onpaint</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 formRoot = $("form-root");
const paintCountEl = $("paint-count");
let paintCount = 0;
// ── Hand-drawn mindmap annotation around the form ────────
// The form sits at the centre. The canvas's 2D context draws
// sketched arrows, labels, and decorative scribbles around it
// to make the demo feel like an annotated drawing on graph
// paper. This is the "very canvassy" wrapper the user asked
// for: a real DOM form positioned as the centerpiece of a
// canvas illustration.
const ANNOTATIONS = [
{
label: 'real <input> elements',
targetField: 'name',
side: 'left',
},
{
label: 'native <textarea>',
targetField: 'message',
side: 'right',
},
{
label: 'real <select>',
targetField: 'role',
side: 'left',
},
{
label: 'live cursor blink → onpaint',
targetField: 'email',
side: 'right',
},
{
label: 'native focus rings',
targetField: 'experience',
side: 'left',
},
];
// Random-but-stable jitter so the sketch lines look hand-drawn
// without redrawing differently every frame.
function jitterFor(seed) {
const r = Math.sin(seed * 12.9898) * 43758.5453;
return (r - Math.floor(r)) * 2 - 1;
}
function sketchLine(x1, y1, x2, y2, seed) {
// Draw a slightly wobbly line by perturbing the midpoint and
// rendering as a quadratic curve. Stable across frames so it
// doesn't shimmer.
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy);
const nx = -dy / len;
const ny = dx / len;
const wob = jitterFor(seed) * Math.min(8, len * 0.04);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx + nx * wob, my + ny * wob, x2, y2);
ctx.stroke();
}
function sketchArrowhead(x, y, angle) {
const len = 10;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(
x - Math.cos(angle - 0.4) * len,
y - Math.sin(angle - 0.4) * len,
);
ctx.moveTo(x, y);
ctx.lineTo(
x - Math.cos(angle + 0.4) * len,
y - Math.sin(angle + 0.4) * len,
);
ctx.stroke();
}
function drawDotGrid(w, h) {
ctx.fillStyle = 'rgba(108, 65, 240, 0.07)';
const step = 28;
for (let gx = step; gx < w; gx += step) {
for (let gy = step; gy < h; gy += step) {
ctx.beginPath();
ctx.arc(gx, gy, 1.1, 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawDecorations(w, h, formRect) {
// Soft glow circles in the corners
const grad1 = ctx.createRadialGradient(
w * 0.85,
h * 0.15,
0,
w * 0.85,
h * 0.15,
Math.min(w, h) * 0.45,
);
grad1.addColorStop(0, 'rgba(108, 65, 240, 0.18)');
grad1.addColorStop(1, 'transparent');
ctx.fillStyle = grad1;
ctx.fillRect(0, 0, w, h);
const grad2 = ctx.createRadialGradient(
w * 0.15,
h * 0.85,
0,
w * 0.15,
h * 0.85,
Math.min(w, h) * 0.45,
);
grad2.addColorStop(0, 'rgba(0, 229, 185, 0.14)');
grad2.addColorStop(1, 'transparent');
ctx.fillStyle = grad2;
ctx.fillRect(0, 0, w, h);
// Sketchy spiral in the top-left
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1.2;
ctx.beginPath();
const sx = 90;
const sy = 90;
for (let t = 0; t < Math.PI * 4; t += 0.1) {
const r = t * 4;
const x = sx + Math.cos(t) * r;
const y = sy + Math.sin(t) * r;
if (t === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Constellation of dots in the bottom-right
ctx.fillStyle = 'rgba(0, 229, 185, 0.35)';
const dots = [
[w - 80, h - 60],
[w - 130, h - 90],
[w - 60, h - 110],
[w - 100, h - 140],
[w - 160, h - 130],
];
for (const [dx, dy] of dots) {
ctx.beginPath();
ctx.arc(dx, dy, 2.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = 'rgba(0, 229, 185, 0.18)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < dots.length; i++) {
const [x, y] = dots[i];
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function drawAnnotations(formX, formY, formW, formH) {
ctx.font =
'600 12px ui-monospace, "JetBrains Mono", "Cascadia Code", monospace';
ctx.fillStyle = '#a78bfa';
ctx.strokeStyle = 'rgba(167, 139, 250, 0.55)';
ctx.lineWidth = 1.4;
ctx.lineCap = 'round';
ANNOTATIONS.forEach((ann, i) => {
const target = root.getElementById(ann.targetField);
if (!target) return;
const fieldRect = target.getBoundingClientRect();
const formRect = formRoot.getBoundingClientRect();
// The form has a CSS transform applied; compute the
// field's position in canvas coordinates by offsetting
// from the rendered form's screen position.
const fy = formY + (fieldRect.top - formRect.top) + fieldRect.height / 2;
const onLeft = ann.side === 'left';
const fx = onLeft ? formX + 12 : formX + formW - 12;
// Arrow start point — out in the margin
const labelX = onLeft ? formX - 110 : formX + formW + 110;
const labelY = fy + jitterFor(i + 7) * 6;
ctx.fillStyle = '#a78bfa';
ctx.textAlign = onLeft ? 'right' : 'left';
ctx.textBaseline = 'middle';
ctx.fillText(ann.label, labelX, labelY);
// Sketchy line from label edge → field
const startX = onLeft
? labelX + 6
: labelX - 6 - ctx.measureText(ann.label).width;
// hmm: when textAlign is 'left', text grows right from labelX
// when 'right', text grows left from labelX
// so the right edge of label = labelX (for right-aligned) or labelX + width (for left-aligned)
const labelEdge = onLeft
? labelX + 8
: labelX - 8;
ctx.strokeStyle = 'rgba(167, 139, 250, 0.55)';
sketchLine(labelEdge, labelY, fx, fy, i + 1);
// Arrowhead at field end
const angle = Math.atan2(fy - labelY, fx - labelEdge);
sketchArrowhead(fx, fy, angle);
});
}
// Center the form once we know its measured size, then sync its
// CSS transform to the painted matrix on every frame.
canvas.onpaint = () => {
// Bitmap is 1:1 with CSS pixels; see project memory
// `project_layoutsubtree_bitmap_layout`. No DPR scaling.
const cssW = canvas.width;
const cssH = canvas.height;
ctx.reset();
// Background decorations and dot grid (drawn first, behind form)
drawDotGrid(cssW, cssH);
const rect = formRoot.getBoundingClientRect();
const x = Math.max(160, (cssW - rect.width) / 2);
const y = Math.max(40, (cssH - rect.height) / 2);
drawDecorations(cssW, cssH, { x, y, w: rect.width, h: rect.height });
// Annotations (sketched arrows + labels around the form)
drawAnnotations(x, y, rect.width, rect.height);
// Now paint the actual form on top
const transform = ctx.drawElementImage(formRoot, x, y);
if (transform) formRoot.style.transform = transform.toString();
paintCount++;
paintCountEl.textContent = paintCount;
};
// ResizeObserver: keep the bitmap 1:1 with CSS pixels. We do NOT
// scale by devicePixelRatio because Chrome's layoutsubtree uses
// canvas.width as the layout viewport for child elements — a
// DPR-scaled bitmap breaks child layout on Retina.
new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
canvas.width = Math.round(width);
canvas.height = Math.round(height);
canvas.requestPaint?.();
}).observe(canvas);
// Slider value mirror.
const slider = $("experience");
const expValue = $("exp-value");
slider.addEventListener("input", () => {
expValue.textContent = slider.value;
});
// Reset button.
$("btn-reset").addEventListener("click", () => {
$("name").value = "";
$("email").value = "";
$("message").value = "";
$("role").selectedIndex = 0;
slider.value = 5;
expValue.textContent = "5";
formRoot
.querySelectorAll('input[type="checkbox"]')
.forEach((cb) => {
cb.checked = false;
});
const defaultRadio = formRoot.querySelector(
'input[type="radio"][value="2d"]',
);
if (defaultRadio) defaultRadio.checked = true;
canvas.requestPaint?.();
});
// Submit button — log the data, no actual submission.
$("btn-submit").addEventListener("click", () => {
const data = {
name: $("name").value,
email: $("email").value,
message: $("message").value,
role: $("role").value,
experience: slider.value,
interests: Array.from(
formRoot.querySelectorAll('input[name="interest"]:checked'),
).map((cb) => cb.value),
api: formRoot.querySelector('input[name="api"]:checked')?.value,
};
console.log("Form submitted:", data);
});
</script>
</body>
</html>