API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
Accessible Charts with HTML-in-Canvas
Quarterly Revenue
Market Share
Use Tab to focus chart items ·
← → to navigate within a chart.
Screen readers announce each item's label and value.
Focus rings are drawn via drawFocusIfNeeded.
Bar and pie charts with real HTML labels, ARIA roles, keyboard navigation, and focus rings via drawFocusIfNeeded.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Accessible Charts — 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;
}
h1 {
text-align: center;
font-size: 1.25rem;
margin-bottom: 1.5rem;
color: #a0a0b8;
font-weight: 400;
}
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
max-width: 960px;
margin: 0 auto;
}
@media (max-width: 700px) {
.charts {
grid-template-columns: 1fr;
}
}
.chart-section {
background: #14141f;
border-radius: 12px;
padding: 1.25rem;
border: 1px solid #282840;
}
.chart-section h2 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 1rem;
color: #f0f0f0;
}
canvas {
width: 100%;
aspect-ratio: 4 / 3;
display: block;
border-radius: 8px;
}
/*
* Label styles — these are real DOM elements drawn into the canvas.
* Screen readers see them directly; they ARE the accessible content.
*/
.bar-label {
font-size: 12px;
line-height: 1.3;
text-align: center;
color: #f0f0f0;
padding: 4px 8px;
white-space: nowrap;
width: 70px;
}
.bar-label .name {
display: block;
font-weight: 600;
}
.bar-label .value {
display: block;
font-size: 11px;
color: #a0a0b8;
}
.pie-label {
font-size: 11px;
line-height: 1.3;
text-align: center;
color: #f0f0f0;
padding: 2px 6px;
white-space: nowrap;
}
.pie-label .name {
display: block;
font-weight: 600;
}
.pie-label .pct {
display: block;
font-size: 10px;
color: rgba(255, 255, 255, 0.8);
}
.instructions {
text-align: center;
margin-top: 1.25rem;
font-size: 0.8rem;
color: #6a6a80;
line-height: 1.6;
}
.instructions kbd {
background: #282840;
padding: 2px 6px;
border-radius: 4px;
font-family: inherit;
font-size: 0.75rem;
border: 1px solid #3a3a50;
}
</style>
</head>
<body>
<h1>Accessible Charts with HTML-in-Canvas</h1>
<div class="charts">
<!-- ============================================
BAR CHART
Each <div> is a real HTML element with ARIA
semantics — drawn into the canvas, but still
focusable, tabbable, and announced by screen
readers. The drawn content IS the fallback.
============================================ -->
<section class="chart-section">
<h2>Quarterly Revenue</h2>
<canvas
id="bar-chart"
width="400"
height="300"
layoutsubtree
role="list"
aria-label="Quarterly revenue bar chart"
>
<div
class="bar-label"
role="listitem"
tabindex="0"
aria-label="Q1 2025: $42,000"
data-value="42"
>
<span class="name">Q1</span>
<span class="value">$42K</span>
</div>
<div
class="bar-label"
role="listitem"
tabindex="0"
aria-label="Q2 2025: $58,000"
data-value="58"
>
<span class="name">Q2</span>
<span class="value">$58K</span>
</div>
<div
class="bar-label"
role="listitem"
tabindex="0"
aria-label="Q3 2025: $35,000"
data-value="35"
>
<span class="name">Q3</span>
<span class="value">$35K</span>
</div>
<div
class="bar-label"
role="listitem"
tabindex="0"
aria-label="Q4 2025: $71,000"
data-value="71"
>
<span class="name">Q4</span>
<span class="value">$71K</span>
</div>
</canvas>
</section>
<!-- ============================================
PIE CHART
Same pattern: real DOM elements with ARIA
roles, positioned radially and drawn into
the canvas. drawFocusIfNeeded renders the
browser's native focus ring on the wedge
path when a label is focused.
============================================ -->
<section class="chart-section">
<h2>Market Share</h2>
<canvas
id="pie-chart"
width="400"
height="300"
layoutsubtree
role="list"
aria-label="Market share pie chart"
>
<div
class="pie-label"
role="listitem"
tabindex="0"
aria-label="Enterprise: 38%"
data-value="38"
data-color="#6C41F0"
>
<span class="name">Enterprise</span>
<span class="pct">38%</span>
</div>
<div
class="pie-label"
role="listitem"
tabindex="0"
aria-label="SMB: 27%"
data-value="27"
data-color="#00e5b9"
>
<span class="name">SMB</span>
<span class="pct">27%</span>
</div>
<div
class="pie-label"
role="listitem"
tabindex="0"
aria-label="Consumer: 22%"
data-value="22"
data-color="#ff5926"
>
<span class="name">Consumer</span>
<span class="pct">22%</span>
</div>
<div
class="pie-label"
role="listitem"
tabindex="0"
aria-label="Government: 13%"
data-value="13"
data-color="#d52e66"
>
<span class="name">Gov't</span>
<span class="pct">13%</span>
</div>
</canvas>
</section>
</div>
<p class="instructions">
Use <kbd>Tab</kbd> to focus chart items ·
<kbd>←</kbd> <kbd>→</kbd> to navigate within a chart.<br />
Screen readers announce each item's label and value.
Focus rings are drawn via <code>drawFocusIfNeeded</code>.
</p>
<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;
// =============================================================
// BAR CHART
// =============================================================
const barCanvas = root.getElementById("bar-chart");
const barCtx = barCanvas.getContext("2d");
const barItems = Array.from(barCanvas.children);
const barColors = ["#6C41F0", "#00e5b9", "#ff5926", "#d52e66"];
const barMax = Math.max(
...barItems.map((el) => Number(el.dataset.value))
);
function paintBarChart() {
// Bitmap is 1:1 with CSS pixels for layoutsubtree layout.
const cssW = barCanvas.width;
const cssH = barCanvas.height;
barCtx.clearRect(0, 0, cssW, cssH);
// Layout constants (in CSS pixels)
const pad = { top: 20, right: 20, bottom: 30, left: 45 };
const chartW = cssW - pad.left - pad.right;
const chartH = cssH - pad.top - pad.bottom;
const count = barItems.length;
const gap = chartW * 0.06;
const barW = (chartW - gap * (count + 1)) / count;
// Scale factor: leave 15% headroom above tallest bar
const scale = barMax * 1.15;
// ----- Grid lines and Y-axis labels -----
barCtx.strokeStyle = "#282840";
barCtx.lineWidth = 1;
barCtx.fillStyle = "#6a6a80";
barCtx.font = "11px system-ui, sans-serif";
barCtx.textAlign = "right";
barCtx.textBaseline = "middle";
for (let i = 0; i <= 4; i++) {
const y = pad.top + chartH * (1 - i / 4);
barCtx.beginPath();
barCtx.moveTo(pad.left, y);
barCtx.lineTo(cssW - pad.right, y);
barCtx.stroke();
barCtx.fillText(
"$" + Math.round((barMax * i) / 4) + "K",
pad.left - 8,
y
);
}
// ----- X-axis baseline -----
barCtx.strokeStyle = "#3a3a50";
barCtx.beginPath();
barCtx.moveTo(pad.left, pad.top + chartH);
barCtx.lineTo(cssW - pad.right, pad.top + chartH);
barCtx.stroke();
// ----- Bars and labels -----
for (let i = 0; i < count; i++) {
const el = barItems[i];
const value = Number(el.dataset.value);
const barH = (value / scale) * chartH;
const x = pad.left + gap + i * (barW + gap);
const y = pad.top + chartH - barH;
// Rounded-top bar path
const r = Math.min(4, barW / 4);
const path = new Path2D();
path.moveTo(x + r, y);
path.lineTo(x + barW - r, y);
path.arcTo(x + barW, y, x + barW, y + r, r);
path.lineTo(x + barW, pad.top + chartH);
path.lineTo(x, pad.top + chartH);
path.lineTo(x, y + r);
path.arcTo(x, y, x + r, y, r);
path.closePath();
// Gradient fill
const grad = barCtx.createLinearGradient(
x,
y,
x,
pad.top + chartH
);
grad.addColorStop(0, barColors[i]);
grad.addColorStop(1, barColors[i] + "44");
barCtx.fillStyle = grad;
barCtx.fill(path);
// Draw the HTML label element above the bar
// 35 = half of the 70px CSS width of .bar-label
const labelX = x + barW / 2 - 35;
const labelY = y - 38;
const transform = barCtx.drawElementImage(el, labelX, labelY);
if (transform) el.style.transform = transform.toString();
// Draw the browser's native focus ring around the bar
// shape when this label element is focused
barCtx.drawFocusIfNeeded(path, el);
}
}
barCanvas.onpaint = paintBarChart;
// =============================================================
// PIE CHART
// =============================================================
const pieCanvas = root.getElementById("pie-chart");
const pieCtx = pieCanvas.getContext("2d");
const pieItems = Array.from(pieCanvas.children);
const pieTotal = pieItems.reduce(
(sum, el) => sum + Number(el.dataset.value),
0
);
function paintPieChart() {
// Bitmap is 1:1 with CSS pixels for layoutsubtree layout.
const cssW = pieCanvas.width;
const cssH = pieCanvas.height;
pieCtx.clearRect(0, 0, cssW, cssH);
const cx = cssW / 2;
const cy = cssH / 2;
const radius = Math.min(cssW, cssH) * 0.38;
let angle = -Math.PI / 2; // start from 12 o'clock
for (let i = 0; i < pieItems.length; i++) {
const el = pieItems[i];
const value = Number(el.dataset.value);
const color = el.dataset.color;
const slice = (value / pieTotal) * Math.PI * 2;
// ---- Wedge path ----
const path = new Path2D();
path.moveTo(cx, cy);
path.arc(cx, cy, radius, angle, angle + slice);
path.closePath();
// Radial gradient fill
const grad = pieCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
grad.addColorStop(0, color + "88");
grad.addColorStop(1, color);
pieCtx.fillStyle = grad;
pieCtx.fill(path);
// Dark border between slices
pieCtx.strokeStyle = "#0a0a0f";
pieCtx.lineWidth = 2;
pieCtx.stroke(path);
// ---- Draw HTML label at 65% of radius, centred on midpoint ----
const mid = angle + slice / 2;
// Approximate label dimensions for centering
const lw = 35; // ~half of rendered label width
const lh = 15; // ~half of rendered label height
const lx = cx + Math.cos(mid) * radius * 0.6 - lw;
const ly = cy + Math.sin(mid) * radius * 0.6 - lh;
const transform = pieCtx.drawElementImage(el, lx, ly);
if (transform) el.style.transform = transform.toString();
// Focus ring around the wedge path
pieCtx.drawFocusIfNeeded(path, el);
angle += slice;
}
}
pieCanvas.onpaint = paintPieChart;
// =============================================================
// KEYBOARD NAVIGATION
// Arrow keys cycle through items within each chart.
// =============================================================
function setupKeyNav(canvas, items) {
canvas.addEventListener("keydown", (e) => {
// In shadow DOM, the active element lives on the shadow root;
// in standalone, it lives on document. `root` resolves to the
// right thing in both contexts.
const focused = root.activeElement ?? document.activeElement;
const idx = items.indexOf(focused);
if (idx === -1) return;
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (idx + 1) % items.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (idx - 1 + items.length) % items.length;
}
if (next !== -1) {
e.preventDefault();
items[next].focus();
}
});
// Repaint on focus changes so drawFocusIfNeeded updates
items.forEach((el) => {
el.addEventListener("focus", () => {
if (canvas.requestPaint) {
canvas.requestPaint();
} else if (canvas.onpaint) {
canvas.onpaint();
}
});
el.addEventListener("blur", () => {
if (canvas.requestPaint) {
canvas.requestPaint();
} else if (canvas.onpaint) {
canvas.onpaint();
}
});
});
}
setupKeyNav(barCanvas, barItems);
setupKeyNav(pieCanvas, pieItems);
// =============================================================
// RESIZE OBSERVER
// Keep the bitmap 1:1 with CSS pixels. Chrome's layoutsubtree
// uses canvas.width as the layout viewport for children, so a
// DPR-scaled bitmap breaks child layout on Retina displays
// (see project_layoutsubtree_bitmap_layout memory).
// =============================================================
function observeResize(canvas, paintFn) {
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
canvas.width = Math.round(width);
canvas.height = Math.round(height);
// requestPaint() instead of onpaint() so the browser's paint
// pipeline runs and generates the cached paint records that
// drawElementImage() depends on.
canvas.requestPaint?.();
});
ro.observe(canvas);
}
observeResize(barCanvas, paintBarChart);
observeResize(pieCanvas, paintPieChart);
</script>
</body>
</html>