What You'll Build
In this tutorial, you will build a fully accessible health metrics dashboard that displays patient data such as heart rate trends, blood pressure readings, blood glucose levels, and activity summaries. The dashboard will meet WCAG 2.1 AA standards, support keyboard navigation, and provide meaningful data representations for screen readers. Every chart will include ARIA labels, focus management, and alternative text descriptions.
This is the kind of dashboard pattern we use at WellAlly for rendering patient health summaries that clinicians and patients can both interpret regardless of ability.
Prerequisites
Before starting, ensure you have the following:
- Node.js 20+ and npm 10+ installed
- A React 19 project with TypeScript 5.x
- Familiarity with React hooks and functional components
- Basic understanding of D3.js selection and data joins
- Knowledge of HTML ARIA attributes
- A screen reader for testing (VoiceOver on macOS, NVDA on Windows)
Tech Stack
| Technology | Version | Purpose |
|---|---|---|
| React | 19.x | Component framework |
| D3.js | 7.x | Data visualization |
| TypeScript | 5.x | Type safety |
| Tailwind CSS | 4.x | Styling |
| Vitest | 3.x | Unit testing |
| @testing-library/react | 16.x | Component testing |
Architecture Overview
The dashboard follows a layered architecture where data flows from a mock API layer through custom hooks into chart components. Each chart component encapsulates D3 rendering logic inside a React effect while maintaining accessibility through ARIA attributes and keyboard event handlers.
DashboardPage
├── useHealthMetrics() -- data fetching hook
├── AccessibleLineChart -- heart rate / glucose trends
│ ├── SVG with aria-label
│ ├── Data table (screen reader)
│ └── Keyboard focus ring
├── AccessibleBarChart -- activity summary
│ ├── SVG with role="img"
│ ├── Desc element
│ └── Tab navigation
├── MetricCard[] -- summary cards
└── LiveRegion -- aria-live announcements
Step-by-Step Implementation
Step 1: Project Setup
Initialize the project with the required dependencies.
mkdir accessible-health-dashboard && cd accessible-health-dashboard
npm init -y
npm install react react-dom d3
npm install -D typescript @types/react @types/d3 vitest @testing-library/react
Create the TypeScript configuration.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
Step 2: Define Types and Data Models
Start by defining the TypeScript types for health metrics. Strong typing prevents runtime errors and makes the data contracts explicit.
// src/types/health-metrics.ts
export interface TimeSeriesPoint {
timestamp: string;
value: number;
}
export interface HealthMetric {
id: string;
label: string;
unit: string;
data: TimeSeriesPoint[];
normalRange: { min: number; max: number };
description: string; // Used for screen reader announcements
}
export interface ActivitySummary {
date: string;
steps: number;
caloriesBurned: number;
activeMinutes: number;
category: 'low' | 'moderate' | 'high';
}
export interface DashboardState {
heartRate: HealthMetric;
bloodGlucose: HealthMetric;
bloodPressure: {
systolic: HealthMetric;
diastolic: HealthMetric;
};
activity: ActivitySummary[];
lastUpdated: string;
}
Step 3: Create the Data Fetching Hook
Build a custom hook that fetches health metrics and provides loading and error states. This hook also generates accessible descriptions for each metric.
// src/hooks/useHealthMetrics.ts
import { useState, useEffect } from 'react';
import type { DashboardState, HealthMetric } from '../types/health-metrics';
function generateMetricDescription(metric: HealthMetric): string {
const latest = metric.data[metric.data.length - 1];
const prev = metric.data[metric.data.length - 2];
const trend = latest.value > prev.value ? 'increased' : latest.value < prev.value ? 'decreased' : 'remained stable';
const inRange = latest.value >= metric.normalRange.min && latest.value <= metric.normalRange.max;
return `${metric.label} is currently ${latest.value} ${metric.unit}, which has ${trend} from ${prev.value} ${metric.unit}. ` +
`This is ${inRange ? 'within' : 'outside of'} the normal range of ${metric.normalRange.min} to ${metric.normalRange.max} ${metric.unit}.`;
}
async function fetchDashboardData(): Promise<DashboardState> {
const response = await fetch('/api/health-dashboard');
if (!response.ok) {
throw new Error(`Failed to fetch dashboard data: ${response.statusText}`);
}
return response.json();
}
export function useHealthMetrics() {
const [data, setData] = useState<DashboardState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
const dashboardData = await fetchDashboardData();
if (!cancelled) {
setData(dashboardData);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
load();
const interval = setInterval(load, 60000); // Refresh every minute
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
const descriptions = data
? {
heartRate: generateMetricDescription(data.heartRate),
bloodGlucose: generateMetricDescription(data.bloodGlucose),
}
: null;
return { data, loading, error, descriptions };
}
Step 4: Build the Accessible Line Chart Component
This is the core chart component. It renders a D3 line chart inside an SVG while providing a hidden data table for screen readers and keyboard-navigable data points.
// src/components/AccessibleLineChart.tsx
import { useRef, useEffect, useState, useCallback } from 'react';
import * as d3 from 'd3';
import type { HealthMetric, TimeSeriesPoint } from '../types/health-metrics';
interface AccessibleLineChartProps {
metric: HealthMetric;
width?: number;
height?: number;
description: string;
}
export function AccessibleLineChart({
metric,
width = 600,
height = 300,
description,
}: AccessibleLineChartProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const xScale = d3.scaleTime()
.domain(d3.extent(metric.data, (d: TimeSeriesPoint) => new Date(d.timestamp)) as [Date, Date])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([
Math.min(d3.min(metric.data, (d: TimeSeriesPoint) => d.value)!, metric.normalRange.min),
Math.max(d3.max(metric.data, (d: TimeSeriesPoint) => d.value)!, metric.normalRange.max),
])
.range([innerHeight, 0]);
const line = d3.line<TimeSeriesPoint>()
.x((d) => xScale(new Date(d.timestamp)))
.y((d) => yScale(d.value))
.curve(d3.curveMonotoneX);
const normalRangeRect = {
x: 0,
y: yScale(metric.normalRange.max),
width: innerWidth,
height: yScale(metric.normalRange.min) - yScale(metric.normalRange.max),
};
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
setFocusedIndex((prev) =>
prev === null ? 0 : Math.min(prev + 1, metric.data.length - 1)
);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
setFocusedIndex((prev) =>
prev === null ? metric.data.length - 1 : Math.max(prev - 1, 0)
);
} else if (event.key === 'Home') {
event.preventDefault();
setFocusedIndex(0);
} else if (event.key === 'End') {
event.preventDefault();
setFocusedIndex(metric.data.length - 1);
}
},
[metric.data.length]
);
useEffect(() => {
if (focusedIndex !== null && svgRef.current) {
const point = svgRef.current.querySelector(
`[data-index="${focusedIndex}"]`
) as SVGElement;
point?.focus();
}
}, [focusedIndex]);
const chartId = `chart-${metric.id}`;
const descriptionId = `${chartId}-desc`;
return (
<div className="relative">
{/* Visible chart */}
<svg
ref={svgRef}
role="img"
aria-labelledby={chartId}
aria-describedby={descriptionId}
width={width}
height={height}
tabIndex={0}
onKeyDown={handleKeyDown}
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
>
<title id={chartId}>{metric.label} over time</title>
<desc id={descriptionId}>{description}</desc>
<g transform={`translate(${margin.left},${margin.top})`}>
{/* Normal range band */}
<rect
{...normalRangeRect}
fill="#22c55e"
fillOpacity={0.1}
role="presentation"
aria-hidden="true"
/>
<text
x={normalRangeRect.x + 4}
y={normalRangeRect.y + 14}
fontSize={10}
fill="#16a34a"}
aria-hidden="true"
>
Normal range
</text>
{/* X axis */}
<g
transform={`translate(0,${innerHeight})`}
ref={(g) => {
if (g) d3.select(g).call(d3.axisBottom(xScale).ticks(5));
}}
role="presentation"
aria-hidden="true"
/>
{/* Y axis */}
<g
ref={(g) => {
if (g) d3.select(g).call(d3.axisLeft(yScale).ticks(5));
}}
role="presentation"
aria-hidden="true"
/>
{/* Line */}
<path
d={line(metric.data) || ''}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
role="presentation"
aria-hidden="true"
/>
{/* Interactive data points */}
{metric.data.map((point, index) => {
const cx = xScale(new Date(point.timestamp));
const cy = yScale(point.value);
const isFocused = focusedIndex === index;
return (
<circle
key={index}
data-index={index}
cx={cx}
cy={cy}
r={isFocused ? 6 : 4}
fill={isFocused ? '#1d4ed8' : '#3b82f6'}
stroke="#fff"
strokeWidth={2}
tabIndex={-1}
role="button"
aria-label={`${metric.label}: ${point.value} ${metric.unit} at ${new Date(point.timestamp).toLocaleDateString()}`}
onFocus={() => setFocusedIndex(index)}
className="cursor-pointer transition-all duration-150"
/>
);
})}
</g>
</svg>
{/* Screen reader data table */}
<div className="sr-only">
<table aria-label={`${metric.label} data table`}>
<caption>{description}</caption>
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">{`${metric.label} (${metric.unit})`}</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{metric.data.map((point, index) => {
const inRange = point.value >= metric.normalRange.min && point.value <= metric.normalRange.max;
return (
<tr key={index}>
<td>{new Date(point.timestamp).toLocaleDateString()}</td>
<td>{point.value}</td>
<td>{inRange ? 'Normal' : 'Abnormal'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Focused point tooltip */}
{focusedIndex !== null && (
<div
className="absolute bg-gray-900 text-white text-sm px-3 py-1.5 rounded shadow-lg pointer-events-none"
style={{
left: `${margin.left + xScale(new Date(metric.data[focusedIndex].timestamp))}px`,
top: `${margin.top + yScale(metric.data[focusedIndex].value) - 40}px`,
transform: 'translateX(-50%)',
}}
role="status"
aria-live="polite"
>
{metric.data[focusedIndex].value} {metric.unit}
</div>
)}
</div>
);
}
Step 5: Build Metric Summary Cards
Metric cards provide at-a-glance summaries. Each card uses semantic HTML and ARIA live regions to announce changes.
// src/components/MetricCard.tsx
import type { HealthMetric } from '../types/health-metrics';
interface MetricCardProps {
metric: HealthMetric;
description: string;
}
export function MetricCard({ metric, description }: MetricCardProps) {
const latest = metric.data[metric.data.length - 1];
const previous = metric.data[metric.data.length - 2];
const change = latest.value - previous.value;
const changeDirection = change > 0 ? 'up' : change < 0 ? 'down' : 'stable';
const inRange = latest.value >= metric.normalRange.min && latest.value <= metric.normalRange.max;
return (
<article
className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
aria-label={`${metric.label} summary card`}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-500">{metric.label}</h3>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
inRange
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
aria-label={`Status: ${inRange ? 'Within normal range' : 'Outside normal range'}`}
>
{inRange ? 'Normal' : 'Alert'}
</span>
</div>
<div className="mt-2">
<p className="text-3xl font-bold text-gray-900" aria-label={`Current value: ${latest.value} ${metric.unit}`}>
{latest.value}
<span className="ml-1 text-sm font-normal text-gray-500">{metric.unit}</span>
</p>
</div>
<div className="mt-2 flex items-center gap-1 text-sm" aria-label={`Trend: ${changeDirection} by ${Math.abs(change)} ${metric.unit}`}>
{changeDirection === 'up' && (
<svg className="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
)}
{changeDirection === 'down' && (
<svg className="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
<span className={change > 0 ? 'text-red-600' : change < 0 ? 'text-green-600' : 'text-gray-500'}>
{change !== 0 && `${change > 0 ? '+' : ''}${change}`} {metric.unit}
</span>
</div>
{/* Screen reader live region for updates */}
<div aria-live="polite" className="sr-only">
{description}
</div>
</article>
);
}
Step 6: Compose the Dashboard Page
Bring all components together into the main dashboard page.
// src/pages/DashboardPage.tsx
import { useHealthMetrics } from '../hooks/useHealthMetrics';
import { AccessibleLineChart } from '../components/AccessibleLineChart';
import { MetricCard } from '../components/MetricCard';
export function DashboardPage() {
const { data, loading, error, descriptions } = useHealthMetrics();
if (loading) {
return (
<div role="status" aria-live="polite" className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">Loading health dashboard...</p>
</div>
);
}
if (error) {
return (
<div role="alert" className="flex items-center justify-center min-h-screen">
<p className="text-red-600">Error loading dashboard: {error.message}</p>
</div>
);
}
if (!data || !descriptions) return null;
return (
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Health Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">
Last updated: {new Date(data.lastUpdated).toLocaleString()}
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8" role="list" aria-label="Health metric summaries">
<div role="listitem">
<MetricCard metric={data.heartRate} description={descriptions.heartRate} />
</div>
<div role="listitem">
<MetricCard metric={data.bloodGlucose} description={descriptions.bloodGlucose} />
</div>
<div role="listitem">
<MetricCard
metric={data.bloodPressure.systolic}
description={`Systolic blood pressure: ${data.bloodPressure.systolic.data.slice(-1)[0].value} mmHg`}
/>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section aria-label="Heart rate chart">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Heart Rate Trend</h2>
<AccessibleLineChart
metric={data.heartRate}
description={descriptions.heartRate}
/>
</section>
<section aria-label="Blood glucose chart">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Blood Glucose Trend</h2>
<AccessibleLineChart
metric={data.bloodGlucose}
description={descriptions.bloodGlucose}
/>
</section>
</div>
{/* Live region for real-time updates */}
<div aria-live="polite" aria-atomic="true" className="sr-only" id="dashboard-live-region" />
</main>
);
}
Step 7: Write Tests
Test accessibility attributes, keyboard navigation, and data rendering.
// src/__tests__/AccessibleLineChart.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { AccessibleLineChart } from '../components/AccessibleLineChart';
import type { HealthMetric } from '../types/health-metrics';
const mockMetric: HealthMetric = {
id: 'heart-rate',
label: 'Heart Rate',
unit: 'bpm',
normalRange: { min: 60, max: 100 },
description: 'Heart rate over the past 7 days',
data: [
{ timestamp: '2026-03-30T08:00:00Z', value: 72 },
{ timestamp: '2026-03-31T08:00:00Z', value: 75 },
{ timestamp: '2026-04-01T08:00:00Z', value: 68 },
{ timestamp: '2026-04-02T08:00:00Z', value: 80 },
{ timestamp: '2026-04-03T08:00:00Z', value: 78 },
],
};
describe('AccessibleLineChart', () => {
it('renders the chart with correct ARIA attributes', () => {
render(
<AccessibleLineChart
metric={mockMetric}
description="Test description"
/>
);
const svg = screen.getByRole('img');
expect(svg).toHaveAttribute('aria-labelledby');
expect(svg).toHaveAttribute('aria-describedby');
});
it('renders a data table for screen readers', () => {
render(
<AccessibleLineChart
metric={mockMetric}
description="Test description"
/>
);
const table = screen.getByRole('table');
expect(table).toBeInTheDocument();
expect(screen.getByText('72')).toBeInTheDocument();
});
it('supports keyboard navigation with arrow keys', () => {
render(
<AccessibleLineChart
metric={mockMetric}
description="Test description"
/>
);
const svg = screen.getByRole('img');
fireEvent.keyDown(svg, { key: 'ArrowRight' });
// First data point should be focused
const firstPoint = screen.getByRole('button', {
name: /Heart Rate: 72 bpm/i,
});
expect(firstPoint).toBeInTheDocument();
});
it('marks abnormal values correctly in the data table', () => {
const abnormalMetric: HealthMetric = {
...mockMetric,
data: [
...mockMetric.data,
{ timestamp: '2026-04-04T08:00:00Z', value: 120 },
],
};
render(
<AccessibleLineChart
metric={abnormalMetric}
description="Test description"
/>
);
expect(screen.getByText('Abnormal')).toBeInTheDocument();
});
});
Best Practices
Accessibility
- Always use
role="img"on SVG charts witharia-labelledbypointing to a<title>element andaria-describedbypointing to a<desc>element. - Provide a hidden data table as an alternative representation of the chart data for screen reader users.
- Implement keyboard navigation using arrow keys for data point traversal, following the DHTML style guide patterns.
- Use
aria-liveregions to announce real-time data updates without interrupting the user. - Maintain a 4.5:1 contrast ratio for all text and meaningful visual elements against their backgrounds.
Performance
- Memoize D3 scales when data has not changed to avoid redundant recalculations.
- Use
useReffor D3 selections instead of re-selecting the DOM on every render. - Debounce resize handlers when implementing responsive charts to prevent layout thrashing.
- Virtualize long data series by rendering only visible points when dealing with thousands of measurements.
Security
- Sanitize all data before rendering in SVG to prevent XSS through malicious metric names or values.
- Validate API responses against your TypeScript types using a runtime validation library like Zod.
- Never expose patient identifiers in client-accessible data structures.
Common Pitfalls
-
Using only color to convey information: Users with color vision deficiencies cannot distinguish red from green alerts. Always pair color with text labels, icons, or patterns.
-
Forgetting the hidden data table: SVG charts are essentially images to screen readers. Without a data table alternative, screen reader users get no information from the chart.
-
Hardcoding pixel dimensions: Use responsive containers with
viewBoxattributes on SVGs so charts scale properly across devices and zoom levels. -
Not managing D3 and React lifecycle conflicts: D3 mutates the DOM directly, which conflicts with React's virtual DOM. Always use
useRefto contain D3 operations and clean up in the effect's return function. -
Ignoring focus management after data updates: When data refreshes, the focused data point index may become invalid. Always clamp or reset the focused index after new data loads.
Deploying to Production
When deploying your accessible health dashboard:
- Enable Brotli compression for SVG-heavy pages to reduce transfer size by up to 70%.
- Set appropriate cache headers for static chart assets while ensuring data endpoints return fresh metrics.
- Use
loading="lazy"on below-fold charts to reduce initial page weight. - Implement server-side rendering for the data tables so screen readers get content before JavaScript hydration.
- Run automated accessibility audits in your CI pipeline using axe-core or pa11y.
# Add to your CI pipeline
npx pa11y http://localhost:3000/dashboard --standard WCAG2AA
Complete Code
The key code sections are:
- Type definitions:
src/types/health-metrics.ts - Data hook:
src/hooks/useHealthMetrics.ts - Chart component:
src/components/AccessibleLineChart.tsx - Card component:
src/components/MetricCard.tsx - Page composition:
src/pages/DashboardPage.tsx - Tests:
src/__tests__/AccessibleLineChart.test.tsx
Resources
- WAI-ARIA Authoring Practices for Charts - Official ARIA patterns for data visualizations
- D3.js Documentation - D3 API reference and examples
- WCAG 2.1 Guidelines - Web Content Accessibility Guidelines
- Highcharts Accessibility - Reference implementation of accessible charts
- React Testing Library - Testing utilities for React components