Demos

Accessible Charts

2D Intermediate

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 &middot;
      <kbd>&larr;</kbd> <kbd>&rarr;</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>