Accessibility pattern · Feedback and status

Badge and count

A badge on its own is decoration; the count belongs in the name of the button it sits on, so "Notifications, 3 unread" is what a screen reader hears, and it changes when items are read. Status pills pair a word with an icon of a different shape, so Paid in full, Pending, Overdue and Draft read the same in color, in grayscale and aloud.

WCAG criteria
7
Keyboard rules
4
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.

Hisaab

Messages, 128 unread

Recent invoices

  • Priya Nair INV-2041 · 2 Oct ₹12,400 Paid in full
  • Kabir Studio INV-2040 · 28 Sep ₹8,750 Pending
  • Meera Textiles INV-2037 · 14 Sep ₹23,100 Overdue
  • Arjun Mehta INV-2042 · Today ₹4,200 Draft
  • Don't: a bare red dot

    Heard as “button”. No name, no count, and the dot is color alone.

  • Do: the count in the name

    Heard as “Notifications, 3 unread, button”.

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 through the nav links, the Messages link, the Notifications button and, when it is open, the panel's buttons.
Enter or SpaceOn Notifications, opens or closes the panel; focus stays on the button and the next Tab goes into the panel.
Enter or SpaceOn Mark as read, marks that item; the button becomes Mark as unread and the count on Notifications drops by one.
EscapeIn the open panel, closes it and returns focus to Notifications.

Screen readers

What it announces

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

WhenExpected announcement
Focus reaches the bellNotifications, 3 unread, button, collapsed
Focus reaches the envelope with a 99+ badgeMessages, 128 unread, link
Focus reaches the Reports linkReports New, link
An item is marked as readMarked as read
Focus returns to the bell afterwardsNotifications, 2 unread, button, expanded
A screen reader reads an invoice rowMeera Textiles INV-2037 · 14 Sep ₹23,100 Overdue

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-badge" data-ap-badge>
  <div class="ap-badge__bar">
    <p class="ap-badge__brand"><span class="ap-badge__logo" aria-hidden="true"></span>Hisaab</p>
    <nav class="ap-badge__nav" aria-label="Billing">
      <ul>
        <li><a href="#badge-invoices" aria-current="page">Invoices</a></li>
        <li><a href="#badge-clients">Clients</a></li>
        <li><a href="#badge-reports">Reports <span class="ap-badge__new">New</span></a></li>
      </ul>
    </nav>
    <div class="ap-badge__icons">
      <a class="ap-badge__icon-btn" href="#badge-messages" data-ap-capped data-total="128">
        <svg class="ap-badge__glyph" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>
        <span class="ap-badge__vh">Messages,</span>
        <span class="ap-badge__count" aria-hidden="true" data-ap-shown>99+</span>
        <span class="ap-badge__vh"><span data-ap-n>128</span> <span>unread</span></span>
      </a>
      <button type="button" class="ap-badge__icon-btn" aria-expanded="false" aria-controls="badge-panel" data-ap-bell>
        <svg class="ap-badge__glyph" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/></svg>
        <span class="ap-badge__vh">Notifications,</span>
        <span class="ap-badge__count" aria-hidden="true" data-ap-shown>3</span>
        <span class="ap-badge__vh"><span data-ap-n>3</span> <span>unread</span></span>
      </button>
    </div>
  </div>

  <section class="ap-badge__panel" id="badge-panel" aria-labelledby="badge-panel-name" hidden>
    <div class="ap-badge__panel-top">
      <h3 class="ap-badge__panel-name" id="badge-panel-name">Notifications</h3>
      <button type="button" class="ap-btn ap-btn--ghost ap-badge__all" data-ap-all>Mark all as read</button>
    </div>
    <ul class="ap-badge__notes">
      <li class="ap-badge__note" data-unread>
        <span class="ap-badge__dot" aria-hidden="true"></span>
        <div class="ap-badge__note-body" id="badge-note-1">
          <span class="ap-badge__vh" data-ap-unread-word>Unread:</span>
          <p class="ap-badge__note-text"><strong>Priya Nair</strong> paid invoice INV-2041.</p>
          <p class="ap-badge__when">10 min ago</p>
        </div>
        <button type="button" class="ap-badge__mark" data-ap-mark aria-describedby="badge-note-1">Mark as read</button>
      </li>
      <li class="ap-badge__note" data-unread>
        <span class="ap-badge__dot" aria-hidden="true"></span>
        <div class="ap-badge__note-body" id="badge-note-2">
          <span class="ap-badge__vh" data-ap-unread-word>Unread:</span>
          <p class="ap-badge__note-text"><strong>Meera Textiles</strong> is 12 days past the due date.</p>
          <p class="ap-badge__when">1 h ago</p>
        </div>
        <button type="button" class="ap-badge__mark" data-ap-mark aria-describedby="badge-note-2">Mark as read</button>
      </li>
      <li class="ap-badge__note" data-unread>
        <span class="ap-badge__dot" aria-hidden="true"></span>
        <div class="ap-badge__note-body" id="badge-note-3">
          <span class="ap-badge__vh" data-ap-unread-word>Unread:</span>
          <p class="ap-badge__note-text"><strong>Kabir Studio</strong> opened invoice INV-2040.</p>
          <p class="ap-badge__when">3 h ago</p>
        </div>
        <button type="button" class="ap-badge__mark" data-ap-mark aria-describedby="badge-note-3">Mark as read</button>
      </li>
      <li class="ap-badge__note">
        <span class="ap-badge__dot" aria-hidden="true"></span>
        <div class="ap-badge__note-body" id="badge-note-4">
          <span class="ap-badge__vh" data-ap-unread-word hidden>Unread:</span>
          <p class="ap-badge__note-text">Your GST report for September is ready.</p>
          <p class="ap-badge__when">Yesterday</p>
        </div>
        <button type="button" class="ap-badge__mark" data-ap-mark aria-describedby="badge-note-4">Mark as unread</button>
      </li>
    </ul>
  </section>

  <section class="ap-badge__invoices" aria-labelledby="badge-inv-name">
    <h3 class="ap-badge__section-name" id="badge-inv-name">Recent invoices</h3>
    <ul class="ap-badge__rows">
      <li class="ap-badge__row">
        <span class="ap-badge__who"><span class="ap-badge__client">Priya Nair</span> <span class="ap-badge__meta">INV-2041 · 2 Oct</span></span>
        <span class="ap-badge__amount">₹12,400</span>
        <span class="ap-badge__pill ap-badge__pill--paid"><svg class="ap-badge__pill-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.8 2.8L16.2 9.6"/></svg>Paid in full</span>
      </li>
      <li class="ap-badge__row">
        <span class="ap-badge__who"><span class="ap-badge__client">Kabir Studio</span> <span class="ap-badge__meta">INV-2040 · 28 Sep</span></span>
        <span class="ap-badge__amount">₹8,750</span>
        <span class="ap-badge__pill ap-badge__pill--pending"><svg class="ap-badge__pill-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>Pending</span>
      </li>
      <li class="ap-badge__row">
        <span class="ap-badge__who"><span class="ap-badge__client">Meera Textiles</span> <span class="ap-badge__meta">INV-2037 · 14 Sep</span></span>
        <span class="ap-badge__amount">₹23,100</span>
        <span class="ap-badge__pill ap-badge__pill--overdue"><svg class="ap-badge__pill-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M10.3 4.1 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0Z"/><path d="M12 9.5v4"/><path d="M12 17h.01"/></svg>Overdue</span>
      </li>
      <li class="ap-badge__row">
        <span class="ap-badge__who"><span class="ap-badge__client">Arjun Mehta</span> <span class="ap-badge__meta">INV-2042 · Today</span></span>
        <span class="ap-badge__amount">₹4,200</span>
        <span class="ap-badge__pill ap-badge__pill--draft"><svg class="ap-badge__pill-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>Draft</span>
      </li>
    </ul>
  </section>

  <ul class="ap-badge__compare" aria-label="A bare dot compared with a count in the name">
    <li class="ap-badge__case ap-badge__case--dont">
      <span class="ap-badge__sample" aria-hidden="true"><svg class="ap-badge__glyph" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/></svg><span class="ap-badge__bare-dot"></span></span>
      <div class="ap-badge__case-text">
        <p class="ap-badge__verdict">Don't: a bare red dot</p>
        <p>Heard as “button”. No name, no count, and the dot is color alone.</p>
      </div>
    </li>
    <li class="ap-badge__case ap-badge__case--do">
      <span class="ap-badge__sample" aria-hidden="true"><svg class="ap-badge__glyph" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/></svg><span class="ap-badge__count">3</span></span>
      <div class="ap-badge__case-text">
        <p class="ap-badge__verdict">Do: the count in the name</p>
        <p>Heard as “Notifications, 3 unread, button”.</p>
      </div>
    </li>
  </ul>

  <p class="ap-badge__vh" role="status" data-ap-live></p>
</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 bell and envelope glyphs are hidden from assistive technology; the words beside them, kept visually hidden, give each control its name.

  • 1.3.1 Info and Relationships Level A

    The visible count is aria-hidden and the same number sits in the name in words, so the badge is never a stray "3" read out of context.

  • 1.4.1 Use of Color Level A

    Each status pill has its own word and an icon of a different shape, and the unread items carry a word as well as a dot.

  • 1.4.3 Contrast (Minimum) Level AA

    Pill text uses the success, info, danger and secondary text tokens on their own soft fills, each 4.5:1 or more in light and dark.

  • 2.4.4 Link Purpose (In Context) Level A

    The Mark as read buttons are tied to their item's text with aria-describedby, so three identical labels still say which item they act on.

  • 4.1.2 Name, Role, Value Level A

    The bell is a button with aria-expanded, and its name changes with the count, so the name a screen reader reads is always current.

  • 4.1.3 Status Messages Level AA

    Marking items read is confirmed in a polite status message, without moving focus.

Usage

When to use it

Use it

  • A count of new or unread things on the control that opens them: notifications, messages, a cart.
  • A short status on an item in a list or table: paid, pending, failed, draft.
  • Flagging something new in navigation for a while, like a feature added last week.

Use something else

  • Information people must act on: a badge is easy to miss, so use an alert or a callout.
  • Long text: a badge holds a word or two; anything longer belongs in the content.
  • Status that changes while people watch, like an upload: a status message or a progress bar says so.

Common failures

How it usually goes wrong

  • A bare red dot

    An unlabeled dot says something changed to people who see red, and nothing to anyone else. Put the count in the control's name and draw the badge as a number.

  • A count read out of context

    Without aria-hidden, a visible "3" inside a link is read as part of its name with no meaning: "Notifications 3". Here the number is spoken as "3 unread".

  • 99+ read as ninety-nine plus

    The cap is for the eye, not the ear. The visible "99+" is hidden and the full count, "128 unread", is in the name.

  • Status in color only

    Green, amber and red pills look the same to many people. Each pill here pairs its word with an icon of its own shape.

  • A name that never updates

    An aria-label written once at page load still says 3 after everything is read. The name here is built from text the script updates.

  • Pale text on a pale pill

    Soft tinted badges often fall well below 4.5:1. The pill colors here are tokens tested against their own fills in both themes.

Notes

Building it

  • Hide the visible count with aria-hidden and put the full sentence piece, "3 unread", in visually hidden text inside the control, so the name reads naturally and the number can be capped for the eye only.
  • Keep the count's text in the page rather than in an aria-label, so translation tools and the page's own language switch reach it.
  • A New badge inside a link becomes part of the link's name ("Reports New"). That is useful, but remove the badge once people have seen the page, or it stops meaning anything.
  • Don't announce every count change in a live region: confirm what the person did ("Marked as read") and let the name carry the new number.
  • The demo's links point to #… because their pages do not exist here; in a real product they are ordinary links.

Sources: WAI-ARIA Authoring Practices: Providing accessible names · WCAG 2.2 Understanding 1.4.1: Use of Color

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