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.