Notification Centres and Unread Counts

Permalink to "Notification Centres and Unread Counts"

A notification centre is where job results, mentions, alerts and system messages live after their toasts have gone. It is what makes transient toasts acceptable, because nothing is lost. It is also a component with plenty of accessibility traps: a bell icon whose badge count is visible but not in the name, a panel that opens without moving focus, a list of unlabelled cards, and a count that changes silently — or noisily, on every incoming message.

This guide belongs to notifications & toasts for data operations.

Spec reference

Permalink to "Spec reference"
  • SC 4.1.2 Name, Role, Value (A) — the button’s name must include the unread count (“Notifications, 3 unread”); a visual badge alone is not programmatic.
  • SC 1.1.1 Non-text Content (A) — the bell icon is decorative once the button has a text name.
  • SC 1.3.1 Info and Relationships (A) — notifications are a list; each item’s time and status are part of its content.
  • SC 2.4.3 Focus Order (A) — opening the centre moves focus into it; closing returns to the button.
  • SC 4.1.3 Status Messages (AA) — count changes may be status messages; announce meaningful ones (an error arrived) rather than every increment.
  • SC 2.5.3 Label in Name (A) — if the button shows the visible text “Notifications”, the accessible name must start with it.
Structure of a notification centre Hierarchy: the notifications button with its count; the panel as a non-modal dialog with a heading, a Mark all as read button and a list of notifications, each containing a title, time, read state and actions. Structure of a notification centreNotifications button"Notifications, 3 unread"Panel (non-modal dialog)labelled "Notifications"Heading and actionsMark all as read; SettingsListnewest firstExport ready10:42, unread; Download, Mark as readImport finished, 3 rejected10:15, unread; View, Mark as readReport failed09:58, read; Retry
Each notification is a list item with its own heading or name.

When to use — and when not to

Permalink to "When to use — and when not to"

Use a notification centre whenever the product emits more than occasional toasts — background jobs, collaboration events, scheduled reports. It is the accessible fallback for every timed toast.

Do not open it automatically when notifications arrive; that steals focus. Do not make it the only place errors appear for actions the user is currently performing — those belong in context.

The misapplication: a bell button named “Notifications” with a red “3” badge in a pseudo-element. Screen reader users never learn there is anything new.

Badge-only count versus count in the name Comparison: a visual badge is invisible to screen readers and silent on change; including the count in the button name makes it available on focus, with sparing announcements for important changes. Badge-only count versus count in the name✗ Badge only"Notifications, button"Count invisible to screen readersBadge colour carries urgencyNew items silently increment✓ Count in the accessible name"Notifications, 3 unread, button"Count read on focusUrgent items announced onceErrors announced; routine items counted

Annotated code example

Permalink to "Annotated code example"
<button type="button" id="notif-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="notif-panel">
  <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">…</svg>              <!-- decorative (SC 1.1.1) -->
  <span>Notifications</span>                                     <!-- visible label (SC 2.5.3) -->
  <span class="badge" id="notif-count">3<span class="visually-hidden"> unread</span></span>
</button>

<div id="notif-panel" role="dialog" aria-labelledby="notif-h" hidden>   <!-- non-modal -->
  <h2 id="notif-h" tabindex="-1">Notifications</h2>
  <button type="button" id="mark-all">Mark all as read</button>
  <ul class="notif-list">
    <li class="unread">
      <h3>Export ready: invoices.xlsx</h3>
      <p><time datetime="2026-10-02T10:42">10:42</time> · <span>Unread</span></p>  <!-- state as text -->
      <a href="/exports/123">Download invoices.xlsx</a>
      <button type="button">Mark “Export ready” as read</button>
    </li>
  </ul>
</div>
function setCount(n, { urgent } = {}) {
  countEl.firstChild.nodeValue = String(n);
  countEl.hidden = n === 0;
  // Announce only urgent arrivals, not every increment (SC 4.1.3)
  if (urgent) status.textContent = `New error notification: ${urgent}. ${n} unread.`;
}

markAll.addEventListener('click', () => {
  markAllRead();
  setCount(0);
  status.textContent = 'All notifications marked as read.';
});

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Tab to button “Notifications 3 unread, button, collapsed” —
Enter Focus to heading: “Notifications, heading level 2” Non-modal: Tab can leave the panel
Tab through list Item headings and actions VoiceOver reads list size on entry
Escape Panel closes; focus to button —
Error arrives while working “New error notification: Report failed. 4 unread.” —
Routine item arrives Silent; count in button name updates —
Count updates and what is said Chain of events: a routine job completes and the count rises silently; an error arrives and is announced once; the user opens the centre; Mark all as read sets the count to zero and is confirmed. Count updates and what is saidRoutine resultcount 3 to 4, silentError"New errornotification…"spoken onceCentre openfocus on headingCount 0"All notificationsmarked as read."error arrivesuser opensMark all as read
Only the error and the user's own action produce speech.

Integration context

Permalink to "Integration context"

Toasts that feed the centre are covered in accessible toast notifications for background jobs. The panel’s focus behaviour follows the non-modal option in record inspector panels: dialog or region?. Time stamps follow the time element and relative timestamps.

Gotchas

Permalink to "Gotchas"

Counts above 99. “99+” is read as “99 plus” — fine — but the accessible name should say “more than 99 unread”.

Read state as colour. Bold or a blue dot for unread items is visual only; add text (“Unread”) or a visually hidden equivalent.

Infinite lists. Long histories should paginate or load more with an announced count, as in accessible load more versus infinite scroll.

Framework notes

Permalink to "Framework notes"

In React, derive the button’s name from the same count state that renders the badge so they never diverge. In Angular, MatBadge adds aria-describedby with the badge text; set matBadgeDescription="3 unread" explicitly, since descriptions are read after the name. In Vue, compute the name string and bind it to a visually hidden span rather than aria-label, so the visible text stays part of the name.

<!-- Angular Material -->
<button mat-icon-button matBadge="3" matBadgeDescription="3 unread notifications" aria-label="Notifications">
  <mat-icon aria-hidden="true">notifications</mat-icon>
</button>

Worked example

Permalink to "Worked example"

A data platform sends three kinds of notification: job results, mentions in comments, and failures of scheduled reports. Before the redesign, the bell showed a red dot, its name was “Notifications”, and the panel was a div that opened without focus. After it, the button reads “Notifications, 4 unread” and announces only report failures as they arrive (“New error notification: Weekly revenue report failed. 5 unread.”). Opening the panel moves focus to its heading; each notification is a list item with a heading, a time and a text read state; and “Mark all as read” confirms itself. A screen reader user who ignores the bell for a morning can catch up in under a minute by opening the panel and pressing H to move from notification to notification.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Provide a NotificationCentre with a typed message model (kind, title, time, actions, read) shared with the toast API, so every toast is automatically recorded. Expose urgency from message kind, and keep the arrival announcement rules inside the component.

FAQ

Permalink to "FAQ"
Should the notification panel be modal?

Usually not. Users often want to read a notification and act on the page behind it. A non-modal dialog with Escape to close and focus return is the common choice.

Should each notification have a heading?

Headings make long lists navigable with the H key. For short lists, a list of named items without headings is also fine.

Should the count include read items?

No. The count should be unread items only, and the name should say “unread”.

How do I announce a burst of notifications?

Summarise: “5 new notifications, 1 error”. Announcing each one separately overwhelms users.

Should the count update while the panel is open?

Yes, but silently; the user is already looking at the list. New items can be inserted at the top with a short polite message (“1 new notification”) if they arrive while the panel has focus.

Should notifications be grouped by day or type?

Grouping with headings (“Today”, “Earlier this week”) helps long lists, and each group heading becomes a jump target for screen reader users. Keep groups few and stable.

Permalink to "Related"

← Back to Notifications & Toasts for Data Operations