Skip to main content

How to create a screen reader accessible graph like Apple's with D3.js

Posted on

After previously writing about the accessibility of Apple Health’s data visualizations, I felt inspired to recreate one of them with D3.js. I already covered some of the basics in the form of a bar chart, so this time I decided to go for a different type of graph: the activity rings.

Before we start

While we will build the graph together step by step, this tutorial does require some previous knowledge or experience with D3.js. If you haven’t used D3 before, I suggest starting with some of these tutorials:

Part 1: Drawing the rings.

First, we’ll need to add a container in the HTML, and (optionally) style the page with CSS already. Next, we’ll draw an SVG element using JavaScript:

/* Define properties */
const width = 450;
const height = 450;
const margin = 40;

/* Add SVG inside <div id="activity"></div> */
const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height);

Now that we have an <svg> we can start adding elements to it. First, we’ll create a group to draw the rings in, and center it within its parent (<svg>).

const rings = chart.append('g')
  .attr('transform', `translate(${width / 2}, ${height / 2})`);

Then we’ll need to draw our three rings for moving, exercising, and standing. For now, we’ll be using the following input data:

const stats = [
 {
    name: 'Moving',
    value: 122,
    goal: 350,
    perc: 0.35,
    unit: 'kcal',
    color: 'hotpink'
  }, {
    name: 'Exercising',
    value: 40,
    goal: 40,
    perc: 1.00,
    unit: 'min',
    color: 'limegreen'
  }, {
    name: 'Standing',
    value: 9,
    goal: 12,
    perc: 0.75,
    unit: 'h',
    color: 'turquoise'
  }
];

There are a few different ways to draw the rings, but I chose to drawpaths in combination with the d3.arc() function by looping through the stats and using the perc (percentage) to define start and stop positioning.

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius(150)
      .outerRadius(200)
      .startAngle(0)
      .endAngle(Math.PI) // full circle: Math.PI * 2
     )
    .attr('fill', 'white');

This would give us half a donut that’s 200px in radius (400px in diameter), has a band width of 50px and a gap of 2px.

When we look back at the activity rings, we can see that each ring should decrease in size, and we should have a small gap between each of the rings.

Concretely, this means that for each row of data, the innerRadius and outerRadius should get smaller.

1st ring: moving: outerRadius: radius, innerRadius: radius - stroke. 2nd ring: exercising: outerRadius: radius - stroke - gap, innerRadius: radius - 2 * stroke - gap. 3rd ring: standing: outerRadius: radius - 2*stroke - 2*gap, innerRadius: radius - 3*stroke - 2*gap.

If we set our radius to (width - margin) / 2 (so it takes up the entire space of the SVG minus a predefined margin) and the stroke/donut width to 50, the first row of data would look like this:

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius((width - margin) / 2 - 50)
      .outerRadius((width - margin) / 2)
      .startAngle(0)
      .endAngle(Math.PI * 2 * 0.35)
     )
    .attr('fill', 'hotpink');

Because Math.PI * 2 gives us a full circle, we can multiply it with the goal completion percentage (stat.perc) to calculate the correct endAngle.

For the second ring, this would have to be:

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius((width - margin) / 2 - 100 - 2)
      .outerRadius((width - margin) / 2 - 50 - 2)
      .startAngle(0)
      .endAngle(Math.PI * 2 * 1)
     )
    .attr('fill', 'limegreen');

Which we can generalize as:

stats.forEach((stat, index) => {
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(0)
        .endAngle(Math.PI * 2 * stat.perc)
      )
      .attr('fill', stat.color);
});

Then, we’ll need to add a similar <path> for the darker, uncompleted part of the circle. The only thing we need to do for that is set the startAngle to fullCircle * stat.perc, so that it starts where the bright circle ends and set the endAngle to Math.PI * 2. We’ll also turn down the opacity.

stats.forEach((stat, index) => {
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(0)
        .endAngle(Math.PI * 2 * stat.perc)
      )
      .attr('fill', stat.color);
      
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(Math.PI * 2 * stat.perc)
        .endAngle(Math.PI * 2)
      )
      .attr('fill', stat.color)
      .attr('opacity', 0.25);
});

I made a few more modifications to this and moved part of the code into a drawRings function, so I wouldn’t have to repeat the calculations for the inner and outer radius. You can see the full code for this part in the pen below 👇🏻.

If we listen to this with a screen reader, such as VoiceOver or Narrator, we won’t hear much useful. In fact, we won’t hear anything at all. That is because so far we have only drawn shapes, which doesn’t really tell a screen reader what to do.

In my previous tutorial we used <text> elements to read out the data, but for this one I decided to go for another option: the aria-labelledby property in combination with a <title> and <desc> element. This is inspired by how FiveThirtyEight labeled their graphs in their 2020 presidential election forecast (I reviewed those graphs before).

We’ll want to:

  1. Set the role of the graph to img.
  2. Include a <title> and <desc> inside the SVG, and give each a unique id.
  3. Link the title and description to image by adding aria-labelledby=”titleID descID” to the graph.

(Full transcript)

If we want to mimic Apple’s native behavior, the completion percentage for all three rings should be read simultaneously. Eg. “Moving: 35%. Exercising: 100%. Standing: 75%“.

To generate this text, we’ll create a function that extracts the label (moving, exercising, standing) and the values (35%, 100%, 75%) from the array with the data and then puts it in a sentence.

const generateDescription = () => {
  return stats.map((stat) => {
    return `${stat.name}: ${stat.perc * 100}%.`;
  }).join(' ');
}

Here we loop through the objects inside the stats array and replace each of them with a string. So after we’re finished looping through the stats, this is our output:

[
  'Moving: 35%.',
  'Exercising: 100%.',
  'Standing: 75%.'
]

Lastly, we’ll use .join(' ') at the end to create one long description, and use the output of the function to fill out the text inside the <desc> element.

/* Create the chart. */
const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('role', 'img') // SR support
  .attr('aria-labelledby', 'activityTitle activityDesc'); // SR support

/* Add title. */
chart.append('title')
  .text('Activity')
  .attr('id', 'activityTitle');

/* Add the description. */
chart.append('desc')
  .text(generateDescription)
  .attr('id', 'activityDesc');

(transcript)

Alternative: Using aria-label

We can achieve the same result by using aria-label instead of aria-labelledby in combination with the same generateDescription() function.

const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('role', 'img') 
  .attr('aria-label', generateDescription());

Part 3: Explaining the data.

So now we have three screen reader accessible rings, but visually those don’t tell us that much yet. Pink, green and blue don’t really mean anything, and don’t work well for color blind folks either.

3 progress circles (activity rings) in pink, green and blue. They have icons for moving, exercising and standing, which are circled.

Let’s start by adding icons. For the sake of simplicity, I didn’t draw or import any icons but used existing symbols as text.

/* Define icons */
const icons = {
  moving: '↦',
  exercising: '↠',
  standing: '↟'
};

/* Inside of stats.forEach(...), 
  at the end of the loop */
rings.append('text')
    .text('icons[stat.name.toLowerCase()]')
    .attr('fill', '#000')
    .attr('transform', `translate(${circleSpace}, -${(arc.outer + arc.inner) / 2 - circleSpace * (index + 2)})`)
    .attr('font-size', '1.5rem');
});

In addition, we should explain what the colors and symbols mean in a legend. Apple combines this explanation with statistics that show the data in a more detailed way.

This doesn’t just add context to the colors of the graph, but also makes the same data available in different formats, which also improves accessibility.

We can implement a simplified version of this by adding <text> elements containing the label, total, goal and percentage values. We’ll also need to add the corresponding icons and colors, and adjust the vertical position for each row.

chart.append('text')
    .text(`${icons[stat.name.toLowerCase()]} ${stat.name}: ${stat.value}/${stat.goal}${stat.unit} (${stat.perc * 100}%)`)
    .attr('text-anchor', 'middle')
    .attr('transform', `translate(${width / 2}, ${radius * 2 + 20 * (index + 2)})`)
    .attr('fill', stat.color);

The text is added directly to the <svg>, not to the same group as the rings, so that it can be focused when using VoiceOver.

Right now the icons in the legend will still be read. If we want that to prevent that from happening, we can add the aria-hidden='true' attribute to the icons this way:

const legend = chart.append('text')
    .attr('text-anchor', 'middle')
    .attr('transform', `translate(${width / 2}, ${radius * 2 + 20 * (index + 2)})`)
    .attr('fill', stat.color);
  
  legend.append('tspan')
      .text(`${icons[stat.name.toLowerCase()]} `)
      .attr('aria-hidden', 'true');
  
  legend.append('tspan')
    .text(`${stat.name}: ${stat.value}/${stat.goal}${stat.unit} (${stat.perc * 100}%)`);

Our activity graph now sounds like this (transcript):

Alternative: Expanding the aria-label solution

Next steps.

We can keep styling the graph to make it look more similar to Apple’s graphs, or apply our own styling to it. A few possible next steps could be to move the color scheme to the CSS file, replace the icons or add gradients and shadows.

If you’re new to working with D3.js, SVGs or (dataviz) accessibility, here are a few more articles that can help you with this:

Feel free to share the results with me (you can tag me on Twitter) if you build something similar using this tutorial or have a different way of solving this 👀

Bonus solutions:

Different type of input.

Navigate through the activity rings.

(transcript)

Hi! 👋🏻 I'm Sarah, an independent developer, designer and accessibility advocate, located in Oslo, Norway. You might have come across my photorealistic CSS drawings, my work around dataviz accessibility, or EthicalDesign.guide, a directory of learning resources and tools for creating more inclusive products. You can follow me on social media or through my newsletter or RSS feed.

💌 Have a freelance project for me or want to book me for a talk? Contact me through collab@fossheim.io.

If you like my work, consider:

Sign up for notifications and extra content

Subscribe to my newsletter to receive a notification when a new post goes live. I will also send occasional newsletter-only content about front-end development, accessibility and ethical/inclusive design.

You'll need to confirm your email address. Check your spam folder if you didn't receive the confirmation email.

Similar posts

View post archive