Skip to content
academia.sh

Lesson 16 / 25

Notification Banners and Toast Notifications

The distinction between the notification banner and the toast notification, computing how long a notification stays visible from the text's reading speed, coalescing events that arrive one after another, and measuring loss in the polite–assertive queue.

Contents

The user opened the tooltip: they turned toward an element and the layer appeared. A notification has no such link. The system sends word of something on its own; the user is looking wherever they happen to be looking on the screen, and might not be looking at the screen at all.

This raises three new questions. How long should a notification stay on screen — text that disappears counts as unread. What should happen with two notifications that arrive at the same time — if one suppresses the other, what is lost? And how does a user outside the visual channel receive the notification at all? This lesson ties all three to a computable constraint.

What It Solves, When Not to Use It

There are two separate patterns, and confusing them is the most common defect.

A notification banner is a section that stays inside the page’s flow and remains until the user or the system removes it. It stays as long as its content remains valid: “Three of your records are overdue”, “The catalog is read-only for maintenance”. Because it is persistent, it can hold an action and focus can reach inside it.

A toast is a layer that appears above the flow and disappears on its own once its time runs out. It announces only a completed and reversible action: “Record borrowed”.

Situations where it should not be used all derive from a single rule: a layer that disappears cannot carry anything the user is required to do.

  • An error requiring recovery does not belong in a toast; if the user is not looking at the screen, they never see the error at all, and the system fails silently.
  • A question requiring confirmation does not belong in a toast; once time runs out, the question goes unanswered.
  • Information that must be kept on record does not belong in a toast; the notification also needs a counterpart in a persistent list.

Can a toast hold an action? It can, under exactly one condition: the action must be optional. “Undo” fits this — if the user does nothing, the result is still consistent. “Retry” does not — if nothing is done, the request is lost.

Native Element First, ARIA Second

There is no native element set aside for notifications; the setup is built with roles.

Part Source
Polite notification role="status" (implicit aria-live="polite")
Assertive notification role="alert" (implicit aria-live="assertive")
Accumulating record role="log" — an ordered, non-expiring notification list
The banner’s name if the banner is a section, bound to its heading with aria-labelledby

The rule set out in the Screen Reader Experience lesson is a setup requirement here: the live region’s container must already exist in the document. Adding the container together with its content when the notification arrives can result in the notification never being announced. The correct setup places an empty container when the page is built and writes only the text into it.

The second rule is choosing a priority. Polite priority joins a queue; assertive priority drops whatever is waiting. Borrow confirmation is polite; an error that stops the user’s work is assertive. Making every notification assertive strips the priority of its meaning and produces the loss the measurement below shows.

Duration Is Computed from the Text

A toast’s duration is not a design preference; it is a quantity derived from the length of the text. The model sums three allowances: attention turning toward the notification, reading the text, and — if there is an action — reaching the action and deciding. The result is clamped to a lower and an upper bound.

// notification.mjs — computing visible duration from text, coalescing, and the announcement queue

// Model values (chosen parameters): reaction allowance, reading speed, action allowance.
const REACTION = 700;       // ms — attention turning toward the notification
const READING_SPEED = 200;  // words/minute — the value chosen for reading from a screen
const ACTION = 1500;        // ms — allowance for reaching and deciding on the notification's action
const MIN_MS = 4000, MAX_MS = 10000;

const wordCount = (m) => m.trim().split(/\s+/).length;
function duration(m, hasAction) {
  const raw = REACTION + (wordCount(m) / READING_SPEED) * 60000 + (hasAction ? ACTION : 0);
  return { raw, visible: Math.min(MAX_MS, Math.max(MIN_MS, Math.round(raw / 100) * 100)) };
}

const EXAMPLES = [
  { m: "Record borrowed.", action: false },
  { m: "Record borrowed. Return date is August 11.", action: false },
  { m: "Record borrowed. Return date is August 11.", action: true },
  { m: "Filter updated; 128 records in the list.", action: false },
  { m: "The borrow request could not be sent. The connection dropped; use the retry button.", action: true },
];

console.log("notification text                                          words  action  raw duration  visible");
for (const o of EXAMPLES) {
  const s = duration(o.m, o.action);
  const short = o.m.length > 55 ? o.m.slice(0, 52) + "..." : o.m;
  console.log(
    `${short.padEnd(58)} ${String(wordCount(o.m)).padStart(5)}  ${(o.action ? "yes" : "no").padStart(6)}  ` +
      `${(s.raw.toFixed(0) + " ms").padStart(12)}  ${(s.visible + " ms").padStart(7)}`,
  );
}

// --- Consecutive events: coalesced and uncoalesced ----------------------------
const EVENTS = [
  { at: 0,    type: "borrow", subject: "Light and Shadow" },
  { at: 350,  type: "borrow", subject: "Handbook of Network Protocols" },
  { at: 900,  type: "borrow", subject: "North Slope Measurements" },
  { at: 4200, type: "filter", subject: "128" },
  { at: 4600, type: "filter", subject: "42" },
  { at: 5100, type: "filter", subject: "7" },
  { at: 7000, type: "error",  subject: "North Slope Measurements" },
];

const COALESCE_WINDOW = 1500;   // ms — the interval in which events of the same type are gathered
const PRIORITY = { borrow: "polite", filter: "polite", error: "assertive" };
const HAS_ACTION = { borrow: true, filter: false, error: true };

const writeText = (type, subjects) => {
  if (type === "borrow")
    return subjects.length === 1 ? `${subjects[0]} borrowed.` : `${subjects.length} records borrowed.`;
  if (type === "filter") return `Filter updated; ${subjects[subjects.length - 1]} records in the list.`;
  return `${subjects[0]} could not be borrowed. The request was declined because no copy remains.`;
};

function notifications(events, coalesce) {
  if (!coalesce)
    return events.map((o) => ({ at: o.at, type: o.type, text: writeText(o.type, [o.subject]) }));
  const groups = [];
  for (const o of events) {
    const last = groups[groups.length - 1];
    if (last && last.type === o.type && o.at - last.lastAt <= COALESCE_WINDOW) {
      last.subjects.push(o.subject);
      last.lastAt = o.at;
    } else groups.push({ type: o.type, subjects: [o.subject], lastAt: o.at });
  }
  // A coalesced group is written once its window closes.
  return groups.map((g) => ({ at: g.lastAt, type: g.type, text: writeText(g.type, g.subjects) }));
}

function queue(list) {
  const incoming = [...list].sort((a, b) => a.at - b.at);
  let i = 0, active = null, waiting = [];
  let dropped = 0, cutShort = 0, complete = 0;
  const log = [];

  const start = (b, at) => {
    const s = duration(b.text, HAS_ACTION[b.type]).visible;
    active = { ...b, start: at, end: at + s, duration: s };
    log.push({ at, priority: PRIORITY[b.type], wait: at - b.at, text: b.text, duration: s });
  };

  while (i < incoming.length || waiting.length || active) {
    const nextEvent = i < incoming.length ? incoming[i].at : Infinity;
    const end = active ? active.end : Infinity;
    if (end <= nextEvent) {
      complete++;
      active = null;
      if (waiting.length) start(waiting.shift(), end);
      continue;
    }
    if (nextEvent === Infinity) break;
    const b = incoming[i++];
    if (PRIORITY[b.type] === "assertive") {
      dropped += waiting.length;
      waiting = [];
      if (active) { cutShort++; log[log.length - 1].cut = b.at - active.start; }
      start(b, b.at);
    } else if (!active) start(b, b.at);
    else waiting.push(b);
  }
  return { dropped, cutShort, complete, log };
}

for (const coalesce of [false, true]) {
  const list = notifications(EVENTS, coalesce);
  const s = queue(list);
  console.log(`\n${coalesce ? "coalesced" : "each event its own notification"}: ` +
    `${EVENTS.length} events -> ${list.length} notifications -> ${s.log.length} displays`);
  console.log("    at  priority  wait    visible  text");
  for (const k of s.log) {
    const visible = k.cut === undefined ? `${k.duration} ms` : `${k.cut}/${k.duration}`;
    console.log(`  ${String(k.at).padStart(4)}  ${k.priority.padEnd(9)} ${(k.wait ? k.wait + " ms" : "—").padStart(8)} ` +
      `${visible.padStart(10)}  ${k.text}`);
  }
  console.log(`  never displayed: ${s.dropped}, cut short: ${s.cutShort}, fully displayed: ${s.complete}`);
}
notification text                                          words  action  raw duration  visible
Record borrowed.                                               2      no       1300 ms  4000 ms
Record borrowed. Return date is August 11.                     7      no       2800 ms  4000 ms
Record borrowed. Return date is August 11.                     7     yes       4300 ms  4300 ms
Filter updated; 128 records in the list.                       7      no       2800 ms  4000 ms
The borrow request could not be sent. The connection...       14     yes       6400 ms  6400 ms

each event its own notification: 7 events -> 7 notifications -> 3 displays
    at  priority  wait    visible  text
     0  polite           —    4000 ms  Light and Shadow borrowed.
  4000  polite     3650 ms  3000/4000  Handbook of Network Protocols borrowed.
  7000  assertive        —    6700 ms  North Slope Measurements could not be borrowed. The request was declined because no copy remains.
  never displayed: 4, cut short: 1, fully displayed: 2

coalesced: 7 events -> 3 notifications -> 3 displays
    at  priority  wait    visible  text
   900  polite           —    4000 ms  3 records borrowed.
  5100  polite           —  1900/4000  Filter updated; 7 records in the list.
  7000  assertive        —    6700 ms  North Slope Measurements could not be borrowed. The request was declined because no copy remains.
  never displayed: 0, cut short: 1, fully displayed: 2

The first table ties duration to the text. A two-word notification’s raw duration is 1.3 seconds and gets pulled up to the floor; the fourteen-word error message calls for 6.4 seconds. The floor keeps short notifications from flashing past unread, and the ceiling keeps long text from becoming permanent on screen. The third row shows the action allowance: the same text rises to 4.3 seconds when it carries an “Undo” button, because reading is not enough — the user also has to decide.

A notification that leans on the ceiling is a signal: the text is too long for a notification and belongs in a banner or a persistent list.

The second and third blocks process the same seven events with two different setups. In the uncoalesced setup, only three of the seven events reach the screen; four are never displayed, and one is cut short. Worse is the second row: the user sees the notification for the second record 3.65 seconds after borrowing it. When a notification arrives this long after the action it reports, the user mistakes it for the result of a third action.

In the coalesced setup, the seven events come down to three notifications and nothing is lost. Two rules achieve this. Events of the same type are gathered across a window and turn into a single, counted text: “3 records borrowed” instead of three separate “borrowed” notifications. For consecutive filter changes, only the final state is written; intermediate values are not announced, because the final number is what matters to the user.

In both setups, the assertive error notification cuts off the active one. This follows directly from the definition of priority and is not a defect to fix; what needs fixing is making sure the information that was cut short exists somewhere else. If the cut-short notification’s counterpart stands in a persistent record list, the loss stops being real.

Measurable Constraints

2.2.1 Timing Adjustable. A notification that disappears on its own is a time limit. The criterion requires that the limit can be turned off, adjusted, or extended. In practice this comes down to three rules: the timer stops when the notification is hovered over or gains focus; the notification can be dismissed by hand; and its content also exists in a persistent list.

4.1.3 Status Messages. The notification must be announced without moving focus. Code that moves focus to the notification violates the criterion from the opposite direction: the user is pulled away from what they were doing.

2.2.2 Pause, Stop, Hide. If the notification slides in from somewhere, that motion must last less than five seconds or be able to be stopped. The same criterion also covers a notification log accumulating on screen that advances on its own.

1.4.13. If a notification banner is positioned above the page and covers the content beneath it, the three conditions from the previous lesson apply here too, dismissibility above all.

Position and scrolling. A toast appears in a corner of the screen and covers the content in that corner. If the covered content is a focused element, the 2.4.11 criterion is violated. Space set aside beneath the notification area is not a design flourish; it is what this criterion requires.

Common Mistakes and How to Recognize Them

One notification per event. In a bulk operation, notifications pile up on top of each other. How to recognize it: borrow several records quickly and count how many notifications appear on screen; if the count equals the number of actions, there is no coalescing.

An error inside a toast. If the user is not looking at the screen when the request fails, no trace remains at all. How to recognize it: produce the error and wait; if no trace of it remains on screen once the time runs out, the wrong pattern was chosen.

The notification container is added afterward. The announcement works sometimes and not other times. How to recognize it: check whether the live region container is in the document when the page is first built.

Fixed duration. If every notification stays for the same length of time, long text goes unread. How to recognize it: measure the longest notification text by word count and compare it against the duration the model gives.

Summary

  • A notification banner stays inside the flow and is persistent; a toast appears above the flow and disappears on its own.
  • A layer that disappears cannot carry anything the user is required to do; a toast can hold only an optional action.
  • The live region container exists in the document beforehand; polite priority joins a queue and assertive priority drops what is waiting, and this distinction loses its meaning in a setup that makes every notification assertive.
  • Visible duration is computed from the text’s word count, the reaction allowance, and the action allowance; it is clamped to a floor and a ceiling, and text that leans on the ceiling belongs to a banner rather than a notification.
  • Consecutive events of the same type are coalesced within a window; countable events reduce to a single counted text, and consecutive state changes reduce to only the final state.
  • Disappearing on its own is a time limit: it must stop on hover, must be dismissible by hand, and its content must also exist in a persistent list.

Next Step

The notification said that a task finished. What appears on screen during the interval that the same task runs is a separate pattern. A waiting indicator splits into two basic forms: an indicator that reports a task of unknown duration is running, and an indicator that shows the progress of a task whose completion ratio can be computed. The two differ in role, announcement, and update frequency — and for the second, how the percentage is computed and how the remaining time is estimated determines the user’s decision to wait or not. The next lesson writes both indicators as a specification.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close