Page loaded: A Toast Nobody Heard

Photo by Carl Newton on Unsplash

Accessibility

A Toast Nobody Heard

A live region only speaks if it was already listening, so the region belongs to the app shell, not to each message.

Miguel Carino
Focus
Front-end web architecture, Angular, team leadership, and stakeholder communication
Experience
25+ years turning enterprise web complexity into maintainable products

I was running a settings screen through NVDA, the last pass before handoff, and I pressed Save. Focus stayed on the button, which was correct. A green toast slid in at the corner of the screen and said the changes were saved. NVDA said nothing at all.

So I did what a real user would do. I pressed Save again.

That second save is the part that stayed with me. A sighted user saw the confirmation and moved on. A screen-reader user got silence, assumed the click hadn't landed, and submitted twice. On a settings form that's harmless. On a form that sends a payment or files a request, silence turns into a duplicate the user never meant to create.

The toast was there. It just wasn't said.

A Live Region Has to Be Listening First

The toast component looked right on review. Every toast carried its own aria-live attribute, politeness picked from its severity, rendered by a loop over the queue:

@for (toast of toasts(); track toast.id) {
  <div class="toast" [attr.aria-live]="toast.politeness">
    {{ toast.message }}
  </div>
}

Read it as markup and it says exactly what I meant: this is a live message, announce it. The trouble is that aria-live doesn't announce an element. It tells assistive tech to watch an element for changes. A screen reader learns about a live region when the browser exposes it in the accessibility tree, and it speaks when the content inside that region changes after that point. My toast arrived with the attribute and the text in the same instant. There was no "after." From the screen reader's side, nothing had changed inside a region it knew about. A new region had simply appeared, already full.

Think of a live region as a microphone that has to be plugged in before anyone speaks into it. My toasts plugged in and talked in the same breath, and whether any of it reached the speakers depended on the browser and screen-reader pairing. Some pairings caught it. NVDA on that build did not. MDN is blunt about this: start with an empty region, give the browser time to expose it, and know that the most reliable way to register one is to put it in the initial markup.

It's an easy bug to ship, because nothing about it is visible. The toast renders. Axe passes, because a static scan can confirm the attribute is valid but can't see when the region was registered. The unit test finds the text in the DOM. Every check that looks at the page says the feature works, and the only check that listens says it doesn't.

Moving the Microphone to the Outlet

The fix was to stop letting each toast own its own region. The regions belong to the outlet, and the outlet renders them before any message exists. Here's the whole pattern: a service that holds the queue, and an outlet with two containers, one per politeness level, both present from the first render. Each toast is routed into the right one:

import { ChangeDetectionStrategy, Component, Injectable, computed, inject, signal } from '@angular/core';

type ToastVariant = 'info' | 'success' | 'warning' | 'danger';

interface Toast {
  readonly id: number;
  readonly variant: ToastVariant;
  readonly message: string;
}

@Injectable({ providedIn: 'root' })
export class ToastService {
  private readonly queue = signal<readonly Toast[]>([]);
  readonly toasts = this.queue.asReadonly();
  private nextId = 0;

  show(variant: ToastVariant, message: string): number {
    const id = this.nextId++;
    this.queue.update((list) => [...list, { id, variant, message }]);
    return id;
  }

  // Removal is policy, not plumbing: a dismiss control or a timer calls this.
  dismiss(id: number): void {
    this.queue.update((list) => list.filter((t) => t.id !== id));
  }

  success(message: string): number {
    return this.show('success', message);
  }

  danger(message: string): number {
    return this.show('danger', message);
  }
}

@Component({
  selector: 'app-toaster',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toaster">
      <div aria-live="polite">
        @for (toast of polite(); track toast.id) {
          <p class="toaster__item">{{ toast.message }}</p>
        }
      </div>
      <div aria-live="assertive">
        @for (toast of assertive(); track toast.id) {
          <p class="toaster__item">{{ toast.message }}</p>
        }
      </div>
    </div>
  `,
})
export class Toaster {
  private readonly service = inject(ToastService);

  protected readonly polite = computed(() => this.service.toasts().filter((t) => !isUrgent(t)));
  protected readonly assertive = computed(() => this.service.toasts().filter(isUrgent));
}

// Errors and warnings ask to interrupt; everything else waits its turn.
function isUrgent(toast: Toast): boolean {
  return toast.variant === 'warning' || toast.variant === 'danger';
}

The calling code doesn't change, and that matters. A feature team still writes one line and never thinks about ARIA:

this.toasts.danger('Could not save. Nothing was changed.');

What changes is what that line produces. The regions were already in the accessibility tree, so the new toast is a child added to a region the screen reader is already watching. After the call, leaving out Angular's comment anchors, the DOM looks like this:

<div class="toaster">
  <div aria-live="polite"></div>
  <div aria-live="assertive">
    <p class="toaster__item">Could not save. Nothing was changed.</p>
  </div>
</div>

The screen reader reads the message, and focus stays on the button the user just pressed. The error lands in the assertive region, which asks the screen reader to interrupt. Whether it does is the screen reader's call: in Adrian Roselli's recent support tests, NVDA, JAWS, and Narrator all announce assertive regions politely. Politeness is a request. What the always-present region guarantees is the part that failed me, that the message gets heard at all. Politeness is still derived from the variant in one place, so no caller can get the request wrong, because no caller sets it.

One condition makes all of this work: the outlet has to be on the page before the first toast. Put it in the app shell, not behind an @if that waits for the first message, or the same bug comes back one level up.

Options I Weighed

This bug has more than one plausible fix, and a few of them are real options rather than strawmen.

Putting role="alert" on every toast is the most tempting. Browsers and screen readers give it more special handling than any other live pattern, though support still varies, and MDN's own pages disagree on whether it speaks when injected already full. But by spec, role="alert" means assertive. Every "Saved" would be flagged as urgent enough to cut off whatever the screen reader was in the middle of saying. That's the right request for an error and the wrong one for a confirmation. Making everything loud so that something is heard swaps silence for noise.

Moving focus to the toast does guarantee it gets read, and it breaks the user's place on the page to do it. WCAG's status-message criterion exists precisely so this information can arrive without receiving focus. A toast that steals focus is a dialog with a worse name.

Angular CDK's LiveAnnouncer deserves the most respect, because it solves the registration problem properly. It keeps one visually hidden live element alive and sets text into it on demand. It's the tool I reach for when the thing to announce has no visible home, like "12 results loaded" after a filter. For a toast it splits one message into two copies, the text you see and the string you announce, and those two drift the first time someone edits one and not the other. When the visible toast can be the announcement, I'd rather keep one source of truth and give it a region that was listening first.

Silence Is the Bug Nobody Files

Most accessibility bugs leave evidence. A missing label shows up in an audit, and a contrast failure shows up in a screenshot. A live region that never registers leaves nothing: the toast renders, the tests pass, and the only person who notices is the one who pressed Save twice and never filed a ticket. Once I saw it that way, the region stopped being a property of each message and became infrastructure, owned once by the outlet and in place before anyone needs it. An announcement is only as reliable as the place it lands, so build that place first.