Accessibility pattern · Data display

Stat cards

Each card says its number and its change in words, Up 12% from the previous 7 days, with an arrow beside it; color only adds whether that change is good. The period switch is a radio group that updates all four cards at once and sends one polite message, not four.

WCAG criteria
7
Keyboard rules
3
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Store overview

Last 7 days, compared with the 7 days before

Period

Revenue

₹4,82,300

Up 12% from the previous 7 days

Orders

1,284

Up 6% from the previous 7 days

Conversion rate

3.4%

Down 0.3 points from the previous 7 days

Refunds

Lower is better

₹18,450

Down 8% from the previous 7 days

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
TabMoves to the chosen period; the next Tab leaves the switch. The cards are not tab stops, because nothing in them does anything.
Arrow Right or Arrow DownChooses the next period; all four cards update at once, wrapping from the last period to the first.
Arrow Left or Arrow UpChooses the previous period.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Tab reaches the switchPeriod, group. 7 days, radio button, checked, 1 of 3
Arrow Right chooses 30 days30 days, radio button, checked, 2 of 3. Then, once: Last 30 days, compared with the 30 days before
The arrows go quickly from 7 to 90 daysOnly the last period is announced: Last 90 days, compared with the 90 days before
Reading the Refunds cardRefunds, group. Refunds, heading level 4. Lower is better. ₹18,450. Down 8% from the previous 7 days
Reaching the sparklineNothing: it is hidden, and the change in words says the same

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-stat-cards" data-ap-stat-cards>
  <div class="ap-stat-cards__top">
    <div>
      <h3 class="ap-stat-cards__name">Store overview</h3>
      <p class="ap-stat-cards__period" role="status" data-ap-caption>Last 7 days, compared with the 7 days before</p>
    </div>
    <fieldset class="ap-stat-cards__set">
      <legend class="ap-stat-cards__vh">Period</legend>
      <div class="ap-stat-cards__track">
        <label class="ap-stat-cards__seg">
          <input class="ap-stat-cards__radio" type="radio" name="stat-cards-period" value="7" checked />
          <span>7 days</span>
        </label>
        <label class="ap-stat-cards__seg">
          <input class="ap-stat-cards__radio" type="radio" name="stat-cards-period" value="30" />
          <span>30 days</span>
        </label>
        <label class="ap-stat-cards__seg">
          <input class="ap-stat-cards__radio" type="radio" name="stat-cards-period" value="90" />
          <span>90 days</span>
        </label>
        <span class="ap-stat-cards__thumb" aria-hidden="true"></span>
      </div>
    </fieldset>
  </div>

  <div class="ap-stat-cards__grid">
    <div class="ap-stat-cards__card" role="group" aria-labelledby="stat-cards-revenue" data-ap-metric="revenue">
      <div class="ap-stat-cards__head">
        <h4 class="ap-stat-cards__label" id="stat-cards-revenue">Revenue</h4>
        <svg class="ap-stat-cards__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 3h12M6 8h12M6 13l8.5 8M6 13h3c6.7 0 6.7-10 0-10"/></svg>
      </div>
      <p class="ap-stat-cards__value" data-ap-value>₹4,82,300</p>
      <p class="ap-stat-cards__change">
        <span class="ap-stat-cards__delta" data-dir="up" data-tone="good" data-ap-delta>
          <svg class="ap-stat-cards__arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="ap-stat-cards__up" d="M8 13V3M4 7l4-4 4 4"/><path class="ap-stat-cards__down" d="M8 3v10M4 9l4 4 4-4"/><path class="ap-stat-cards__flat" d="M3 8h10"/></svg>
          <span data-ap-change>Up 12%</span>
        </span>
        <span class="ap-stat-cards__since" data-ap-since>from the previous 7 days</span>
      </p>
      <svg class="ap-stat-cards__spark" viewBox="0 0 120 40" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="ap-stat-cards__area" d="M0.0 36.0L20.0 32.4L40.0 27.6L60.0 25.7L80.0 17.3L100.0 6.0L120.0 11.6L120 40L0 40Z"/><path class="ap-stat-cards__line" d="M0.0 36.0L20.0 32.4L40.0 27.6L60.0 25.7L80.0 17.3L100.0 6.0L120.0 11.6"/><path class="ap-stat-cards__dot" d="M120.0 11.6h0"/></svg>
    </div>
    <div class="ap-stat-cards__card" role="group" aria-labelledby="stat-cards-orders" data-ap-metric="orders">
      <div class="ap-stat-cards__head">
        <h4 class="ap-stat-cards__label" id="stat-cards-orders">Orders</h4>
        <svg class="ap-stat-cards__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18M16 10a4 4 0 0 1-8 0"/></svg>
      </div>
      <p class="ap-stat-cards__value" data-ap-value>1,284</p>
      <p class="ap-stat-cards__change">
        <span class="ap-stat-cards__delta" data-dir="up" data-tone="good" data-ap-delta>
          <svg class="ap-stat-cards__arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="ap-stat-cards__up" d="M8 13V3M4 7l4-4 4 4"/><path class="ap-stat-cards__down" d="M8 3v10M4 9l4 4 4-4"/><path class="ap-stat-cards__flat" d="M3 8h10"/></svg>
          <span data-ap-change>Up 6%</span>
        </span>
        <span class="ap-stat-cards__since" data-ap-since>from the previous 7 days</span>
      </p>
      <svg class="ap-stat-cards__spark" viewBox="0 0 120 40" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="ap-stat-cards__area" d="M0.0 36.0L20.0 32.2L40.0 29.3L60.0 27.9L80.0 21.7L100.0 6.0L120.0 14.1L120 40L0 40Z"/><path class="ap-stat-cards__line" d="M0.0 36.0L20.0 32.2L40.0 29.3L60.0 27.9L80.0 21.7L100.0 6.0L120.0 14.1"/><path class="ap-stat-cards__dot" d="M120.0 14.1h0"/></svg>
    </div>
    <div class="ap-stat-cards__card" role="group" aria-labelledby="stat-cards-conversion" data-ap-metric="conversion">
      <div class="ap-stat-cards__head">
        <h4 class="ap-stat-cards__label" id="stat-cards-conversion">Conversion rate</h4>
        <svg class="ap-stat-cards__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M18 6 6 18"/><circle cx="7.5" cy="7.5" r="2.5"/><circle cx="16.5" cy="16.5" r="2.5"/></svg>
      </div>
      <p class="ap-stat-cards__value" data-ap-value>3.4%</p>
      <p class="ap-stat-cards__change">
        <span class="ap-stat-cards__delta" data-dir="down" data-tone="bad" data-ap-delta>
          <svg class="ap-stat-cards__arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="ap-stat-cards__up" d="M8 13V3M4 7l4-4 4 4"/><path class="ap-stat-cards__down" d="M8 3v10M4 9l4 4 4-4"/><path class="ap-stat-cards__flat" d="M3 8h10"/></svg>
          <span data-ap-change>Down 0.3 points</span>
        </span>
        <span class="ap-stat-cards__since" data-ap-since>from the previous 7 days</span>
      </p>
      <svg class="ap-stat-cards__spark" viewBox="0 0 120 40" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="ap-stat-cards__area" d="M0.0 6.0L20.0 13.5L40.0 13.5L60.0 21.0L80.0 28.5L100.0 36.0L120.0 28.5L120 40L0 40Z"/><path class="ap-stat-cards__line" d="M0.0 6.0L20.0 13.5L40.0 13.5L60.0 21.0L80.0 28.5L100.0 36.0L120.0 28.5"/><path class="ap-stat-cards__dot" d="M120.0 28.5h0"/></svg>
    </div>
    <div class="ap-stat-cards__card" role="group" aria-labelledby="stat-cards-refunds" data-ap-metric="refunds" data-lower-is-better>
      <div class="ap-stat-cards__head">
        <h4 class="ap-stat-cards__label" id="stat-cards-refunds">Refunds</h4>
        <svg class="ap-stat-cards__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>
      </div>
      <p class="ap-stat-cards__hint">Lower is better</p>
      <p class="ap-stat-cards__value" data-ap-value>₹18,450</p>
      <p class="ap-stat-cards__change">
        <span class="ap-stat-cards__delta" data-dir="down" data-tone="good" data-ap-delta>
          <svg class="ap-stat-cards__arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="ap-stat-cards__up" d="M8 13V3M4 7l4-4 4 4"/><path class="ap-stat-cards__down" d="M8 3v10M4 9l4 4 4-4"/><path class="ap-stat-cards__flat" d="M3 8h10"/></svg>
          <span data-ap-change>Down 8%</span>
        </span>
        <span class="ap-stat-cards__since" data-ap-since>from the previous 7 days</span>
      </p>
      <svg class="ap-stat-cards__spark" viewBox="0 0 120 40" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="ap-stat-cards__area" d="M0.0 6.0L20.0 15.0L40.0 19.5L60.0 24.0L80.0 27.0L100.0 33.0L120.0 36.0L120 40L0 40Z"/><path class="ap-stat-cards__line" d="M0.0 6.0L20.0 15.0L40.0 19.5L60.0 24.0L80.0 27.0L100.0 33.0L120.0 36.0"/><path class="ap-stat-cards__dot" d="M120.0 36.0h0"/></svg>
    </div>
  </div>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.1.1 Non-text Content Level A

    The sparklines are aria-hidden decoration; the change in words carries the trend, so nothing is lost.

  • 1.3.1 Info and Relationships Level A

    Each card is a group named by its heading, and the periods are radio buttons in a fieldset with a legend.

  • 1.4.1 Use of Color Level A

    Direction is a word and an arrow; Lower is better on Refunds says why its fall is shown as good, so the green and red are only a hint.

  • 1.4.10 Reflow Level AA

    The cards reflow from four columns to two to one as the space narrows, with no sideways scrolling.

  • 1.4.11 Non-text Contrast Level AA

    The chosen period sits on a raised thumb with a 3:1 edge, and the arrows clear 3:1 on their fills.

  • 2.4.6 Headings and Labels Level AA

    Periods say 7 days, 30 days and 90 days, not 7d, and every card has a heading that names its measure.

  • 4.1.3 Status Messages Level AA

    Changing the period sends one polite status message after the arrows stop, instead of four live cards talking at once.

Usage

When to use it

Use it

  • A few headline numbers people check at a glance, with how each moved over a chosen period.
  • Dashboards where the number matters more than its shape; a full chart can sit behind each card.

Use something else

  • Numbers people need to compare precisely across many periods: use a table or a line chart.
  • More than five or six cards in a row: pick the measures that matter, or group them.
  • Figures that change every second: a constantly updating number is hard to read and noisy to hear.

Common failures

How it usually goes wrong

  • Red and green as the only signal

    A green 12% and a red 12% look the same to many people with color blindness. Up and Down are words here, with an arrow of a different direction.

  • Down always painted red

    Fewer refunds is good news. Tone comes from the measure's meaning (Lower is better), not from the arrow's direction.

  • Every card a live region

    Four cards updating at once in four live regions talk over each other, or get dropped. One status message says the period changed.

  • A sparkline nobody can read

    An unlabelled SVG is announced as an image, or its points as a list of numbers. Hidden, with the trend in words, it costs nothing.

  • Numbers that count up

    Figures that tick from zero make a screen reader read half-values and keep moving for people sensitive to motion. The new number simply appears.

  • 7d, 30d, MoM

    Abbreviations save a few pixels and cost understanding, and speech recognition users have to guess what to say. The periods are written out.

Notes

Building it

  • Tone is worked out, not stored: direction × whether a rise is good for this measure. data-lower-is-better on the card flips it, and No change is neutral.
  • Rates change in percentage points, not percent: conversion falling from 3.7% to 3.4% is down 0.3 points. Saying down 8% would be true and misleading.
  • The status message waits until the arrows have rested for a moment, so arrowing from 7 days to 90 days announces only where you stopped.
  • Tabular figures keep digits the same width, so values do not jitter sideways when the period changes.
  • The grid uses a container query, so the cards reflow to the space they are given, whether that is a phone or a narrow sidebar.

Sources: WCAG 2.2 Understanding: Use of Color · WCAG 2.2 Understanding: Status Messages

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction