WellAlly Logo
WellAlly康心伴
Development

Building Accessible Health Dashboards with React and D3

Learn how to build WCAG-compliant health data dashboards using React and D3.js. This tutorial covers accessible chart patterns, keyboard navigation, screen reader support, and real-time data visualization for patient health metrics.

W
WellAlly Dev Team
2026-04-06
12 min read

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

TechnologyVersionPurpose
React19.xComponent framework
D3.js7.xData visualization
TypeScript5.xType safety
Tailwind CSS4.xStyling
Vitest3.xUnit testing
@testing-library/react16.xComponent 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.

code
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
Code collapsed

Step-by-Step Implementation

Step 1: Project Setup

Initialize the project with the required dependencies.

code
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
Code collapsed

Create the TypeScript configuration.

code
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}
Code collapsed

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.

code
// 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;
}
Code collapsed

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.

code
// 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 };
}
Code collapsed

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.

code
// 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>
  );
}
Code collapsed

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.

code
// 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>
  );
}
Code collapsed

Step 6: Compose the Dashboard Page

Bring all components together into the main dashboard page.

code
// 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>
  );
}
Code collapsed

Step 7: Write Tests

Test accessibility attributes, keyboard navigation, and data rendering.

code
// 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();
  });
});
Code collapsed

Best Practices

Accessibility

  • Always use role="img" on SVG charts with aria-labelledby pointing to a &lt;title&gt; element and aria-describedby pointing to a &lt;desc&gt; 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-live regions 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 useRef for 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

  1. 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.

  2. 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.

  3. Hardcoding pixel dimensions: Use responsive containers with viewBox attributes on SVGs so charts scale properly across devices and zoom levels.

  4. Not managing D3 and React lifecycle conflicts: D3 mutates the DOM directly, which conflicts with React's virtual DOM. Always use useRef to contain D3 operations and clean up in the effect's return function.

  5. 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.
code
# Add to your CI pipeline
npx pa11y http://localhost:3000/dashboard --standard WCAG2AA
Code collapsed

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

#

Article Tags

React
D3.js
Accessibility
Health Dashboard
WCAG

Found this article helpful?

Try KangXinBan and start your health management journey