Skip to main content
Back to Blog
Tutorials
4 min read
January 6, 2025

How to Build Accessible Charts and Data Visualizations in React

Build charts that are accessible to screen readers and keyboard users with ARIA roles, data tables, announcements, and high-contrast patterns.

Ryel Banfield

Founder & Lead Developer

Charts are visual by nature, but they must be accessible too. Here is how to build charts that work for everyone.

Accessible Bar Chart

"use client";

import { useState, useId } from "react";

interface DataPoint {
  label: string;
  value: number;
  color?: string;
}

interface BarChartProps {
  data: DataPoint[];
  title: string;
  unit?: string;
  height?: number;
}

export function AccessibleBarChart({
  data,
  title,
  unit = "",
  height = 300,
}: BarChartProps) {
  const id = useId();
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const maxValue = Math.max(...data.map((d) => d.value));
  const barWidth = Math.min(60, (600 - data.length * 8) / data.length);

  return (
    <figure role="figure" aria-labelledby={`${id}-title`}>
      <figcaption id={`${id}-title`} className="text-lg font-semibold mb-4">
        {title}
      </figcaption>

      {/* Live region for screen readers */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {activeIndex !== null
          ? `${data[activeIndex].label}: ${data[activeIndex].value}${unit}`
          : ""}
      </div>

      {/* SVG Chart */}
      <svg
        viewBox={`0 0 ${data.length * (barWidth + 8) + 60} ${height + 60}`}
        className="w-full max-w-2xl"
        role="img"
        aria-labelledby={`${id}-title`}
      >
        {/* Y-axis labels */}
        {[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
          const y = height - fraction * height + 20;
          const value = Math.round(maxValue * fraction);
          return (
            <g key={fraction}>
              <line
                x1="50"
                y1={y}
                x2={data.length * (barWidth + 8) + 50}
                y2={y}
                stroke="#e5e5e5"
                strokeDasharray="4,4"
              />
              <text x="45" y={y + 4} textAnchor="end" fontSize="11" fill="#666">
                {value}
              </text>
            </g>
          );
        })}

        {/* Bars */}
        {data.map((point, index) => {
          const barHeight = (point.value / maxValue) * height;
          const x = index * (barWidth + 8) + 55;
          const y = height - barHeight + 20;
          const isActive = activeIndex === index;

          return (
            <g
              key={point.label}
              role="listitem"
              aria-label={`${point.label}: ${point.value}${unit}`}
              tabIndex={0}
              onFocus={() => setActiveIndex(index)}
              onBlur={() => setActiveIndex(null)}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onKeyDown={(e) => {
                if (e.key === "ArrowRight" && index < data.length - 1) {
                  e.preventDefault();
                  const next = e.currentTarget.nextElementSibling as HTMLElement;
                  next?.focus();
                }
                if (e.key === "ArrowLeft" && index > 0) {
                  e.preventDefault();
                  const prev = e.currentTarget.previousElementSibling as HTMLElement;
                  prev?.focus();
                }
              }}
              className="outline-none focus:outline-2 focus:outline-primary"
            >
              <rect
                x={x}
                y={y}
                width={barWidth}
                height={barHeight}
                rx={4}
                fill={point.color ?? "#3b82f6"}
                opacity={isActive ? 1 : 0.8}
                className="transition-opacity"
              />

              {/* Value label */}
              {isActive && (
                <text
                  x={x + barWidth / 2}
                  y={y - 8}
                  textAnchor="middle"
                  fontSize="12"
                  fontWeight="bold"
                  fill="#333"
                >
                  {point.value}{unit}
                </text>
              )}

              {/* X-axis label */}
              <text
                x={x + barWidth / 2}
                y={height + 38}
                textAnchor="middle"
                fontSize="11"
                fill="#666"
              >
                {point.label}
              </text>
            </g>
          );
        })}
      </svg>

      {/* Accessible data table fallback */}
      <details className="mt-4">
        <summary className="text-sm text-muted-foreground cursor-pointer">
          View data as table
        </summary>
        <table className="w-full text-sm mt-2 border">
          <caption className="sr-only">{title}</caption>
          <thead>
            <tr className="border-b bg-muted/50">
              <th className="px-3 py-2 text-left">Category</th>
              <th className="px-3 py-2 text-right">Value</th>
            </tr>
          </thead>
          <tbody>
            {data.map((point) => (
              <tr key={point.label} className="border-b">
                <td className="px-3 py-2">{point.label}</td>
                <td className="px-3 py-2 text-right font-mono">
                  {point.value}{unit}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </details>
    </figure>
  );
}

Accessible Pie Chart

"use client";

import { useState, useId } from "react";

interface PieSlice {
  label: string;
  value: number;
  color: string;
}

interface PieChartProps {
  data: PieSlice[];
  title: string;
  size?: number;
}

export function AccessiblePieChart({ data, title, size = 200 }: PieChartProps) {
  const id = useId();
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const total = data.reduce((sum, d) => sum + d.value, 0);
  const radius = size / 2 - 10;
  const center = size / 2;

  // Calculate slice paths
  let currentAngle = -Math.PI / 2;
  const slices = data.map((slice) => {
    const angle = (slice.value / total) * 2 * Math.PI;
    const startAngle = currentAngle;
    const endAngle = currentAngle + angle;
    currentAngle = endAngle;

    const x1 = center + radius * Math.cos(startAngle);
    const y1 = center + radius * Math.sin(startAngle);
    const x2 = center + radius * Math.cos(endAngle);
    const y2 = center + radius * Math.sin(endAngle);
    const largeArc = angle > Math.PI ? 1 : 0;

    const path = `M ${center} ${center} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
    const percentage = ((slice.value / total) * 100).toFixed(1);

    return { ...slice, path, percentage };
  });

  return (
    <figure role="figure" aria-labelledby={`${id}-title`}>
      <figcaption id={`${id}-title`} className="text-lg font-semibold mb-4">
        {title}
      </figcaption>

      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {activeIndex !== null
          ? `${slices[activeIndex].label}: ${slices[activeIndex].percentage}%`
          : ""}
      </div>

      <div className="flex items-start gap-6">
        <svg
          viewBox={`0 0 ${size} ${size}`}
          width={size}
          height={size}
          role="list"
          aria-label={`${title} pie chart`}
        >
          {slices.map((slice, index) => (
            <path
              key={slice.label}
              d={slice.path}
              fill={slice.color}
              stroke="white"
              strokeWidth="2"
              opacity={activeIndex === null || activeIndex === index ? 1 : 0.4}
              className="transition-opacity cursor-pointer outline-none"
              role="listitem"
              aria-label={`${slice.label}: ${slice.percentage}% (${slice.value})`}
              tabIndex={0}
              onFocus={() => setActiveIndex(index)}
              onBlur={() => setActiveIndex(null)}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
            />
          ))}
        </svg>

        {/* Legend */}
        <ul className="space-y-2 text-sm">
          {slices.map((slice, index) => (
            <li
              key={slice.label}
              className={`flex items-center gap-2 ${activeIndex === index ? "font-bold" : ""}`}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
            >
              <span
                className="w-3 h-3 rounded-sm shrink-0"
                style={{ backgroundColor: slice.color }}
                aria-hidden
              />
              <span>{slice.label}</span>
              <span className="text-muted-foreground ml-auto">{slice.percentage}%</span>
            </li>
          ))}
        </ul>
      </div>

      <details className="mt-4">
        <summary className="text-sm text-muted-foreground cursor-pointer">
          View data as table
        </summary>
        <table className="w-full text-sm mt-2 border">
          <caption className="sr-only">{title}</caption>
          <thead>
            <tr className="border-b bg-muted/50">
              <th className="px-3 py-2 text-left">Category</th>
              <th className="px-3 py-2 text-right">Value</th>
              <th className="px-3 py-2 text-right">Percentage</th>
            </tr>
          </thead>
          <tbody>
            {slices.map((slice) => (
              <tr key={slice.label} className="border-b">
                <td className="px-3 py-2">{slice.label}</td>
                <td className="px-3 py-2 text-right font-mono">{slice.value}</td>
                <td className="px-3 py-2 text-right font-mono">{slice.percentage}%</td>
              </tr>
            ))}
          </tbody>
        </table>
      </details>
    </figure>
  );
}

Usage

const revenueData = [
  { label: "Q1", value: 42000, color: "#3b82f6" },
  { label: "Q2", value: 58000, color: "#22c55e" },
  { label: "Q3", value: 51000, color: "#eab308" },
  { label: "Q4", value: 73000, color: "#ef4444" },
];

<AccessibleBarChart data={revenueData} title="Quarterly Revenue" unit="$" />
<AccessiblePieChart data={revenueData} title="Revenue Distribution" />

Need Accessible Data Dashboards?

We build WCAG-compliant dashboards with charts that work for all users. Contact us to discuss your needs.

accessibilitychartsdata visualizationSVGReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles