Foundations

Announcements

Saying aloud what happened where focus is not: once, politely, in a region beside what speaks.

Markdown

Some of what an interface does happens where nobody is looking: rows removed, cells pasted, a shape moved by a key, a value held to its range. Someone who sees the screen sees it happen. Someone listening to a screen reader hears only what has focus — unless the interface says the rest aloud. An announcement is that sentence: brief, polite, and said once.

Best practices

Say what was done, not what was pressed. "Removed 2 rows.", "Pasted 4 cells; 1 is read-only and was left.", "Moved to 20, 30." A person who pressed Delete knows they pressed it; what they need is the outcome, with the numbers that tell them it was what they meant.

Say only what focus does not. When focus moves, the screen reader reads what it lands on — its name, its state, its place. Do not repeat that. A calendar that moves to the next month as an arrow key crosses into it says nothing: the day that takes focus is named with its month. The same change made from a "Next month" button, which keeps focus, is said, because nothing else would say it.

Say what was refused, and why. A paste that left cells out, a value moved into its range, a change a lock would not allow: each is said with its reason, as the interface marks it where it happened. Nothing typed is lost without a word.

Be polite. An announcement waits for the screen reader to finish what it is saying. Interrupting — an assertive announcement — is for what cannot wait, which in an editing interface is almost nothing: a refused change is told politely, beside the mark that shows it.

Say one thing per act. When one act changes several things — a removal that also empties the selection — what is said is what the act came to: the removal. Where two things both matter, say them together in one sentence, in the order they happened.

Say the same thing twice when it happened twice. Undo pressed twice is two undos, and is heard twice, though the words are the same.

Keep up with the hand. Words that are out of date before they are heard are noise: an act that is repeated faster than it can be said — a key held down — says each step only as it is reached, each replacing the last that was not yet heard, not a queue of every step.

Never move focus to say something. An announcement is heard where the person is. Taking focus to a message, to make it read, takes them away from their work and leaves them to find their way back.

Withdraw what is no longer true. A reason that no longer holds — text corrected, so it reads — is taken back, so that someone who goes looking for it does not find a reason for nothing.

Speak in the interface's language. Announcements are words like any other label, and are given in the language of the interface, with its numbers.

Where it is said from

Each component that speaks keeps its own region, beside it in the page. Several components on one page each say their own words. A region in the part of the page that is in use is heard: when a sheet makes the rest of the page inert, a component inside the sheet is still heard, and one behind it is quiet, as the rest of what is behind it is.

Platform considerations

Screen readers. A live region is heard only for what changes in it, so it is in the page, empty, before anything is said into it; and each announcement is new content in it, so that the same words said again are not taken for nothing new.

Visual. An announcement is for those who cannot see the change. What is announced is also shown — a cell marked, a count changed — so nobody depends on the announcement alone, and the region itself is not seen.

Behaviour

Saying

What is said goes into a polite status region, which is there, and empty, from the start
const { announce, region } = await speaker();
expect([region.getAttribute("role"), region.getAttribute("aria-live"), region.textContent]).toEqual([
  "status",
  "polite",
  "",
]);
announce("Moved to 20, 30.");
await expect.poll(() => region.textContent).toBe("Moved to 20, 30.");
The same words said twice are heard twice: each time they are new in the region
const { announce, region } = await speaker();
const heard = additions(region);
announce("Undone.");
await expect.poll(() => heard.texts).toEqual(["Undone."]);
announce("Undone.");
await expect.poll(() => heard.texts).toEqual(["Undone.", "Undone."]);
heard.stop();
What is said in one turn is one announcement: the last, which is what the act came to
const { announce, region } = await speaker();
const heard = additions(region);
announce("1 selected.");
announce("Nothing selected.");
announce("Removed 1 node.");
await expect.poll(() => region.textContent).toBe("Removed 1 node.");
expect(heard.texts).toEqual(["Removed 1 node."]);
heard.stop();
Words that come faster than they can be heard are paced, and each that comes while one waits takes its place
const { announce, region } = await speaker();
const heard: Array<{ text: string; at: number }> = [];
const observer = new MutationObserver((records) => {
  for (const record of records) {
    for (const node of record.addedNodes)
      heard.push({ text: node.textContent ?? "", at: performance.now() });
  }
});
observer.observe(region, { childList: true });
// A key held down: one move after another, each in a turn of its own.
for (const at of ["Moved to 1, 0.", "Moved to 2, 0.", "Moved to 3, 0.", "Moved to 4, 0."]) {
  announce(at);
  await new Promise((resolve) => setTimeout(resolve, 30));
}
await expect.poll(() => heard.map(({ text }) => text)).toEqual(["Moved to 1, 0.", "Moved to 4, 0."]);
observer.disconnect();
expect(heard[1]!.at - heard[0]!.at).toBeGreaterThanOrEqual(ANNOUNCEMENT_PACE - 1);
Saying nothing withdraws what was said
const { announce, region } = await speaker();
announce("Not a number.");
await expect.poll(() => region.textContent).toBe("Not a number.");
announce("");
await expect.poll(() => region.textContent).toBe("");
Words said after the region has gone are let go, not failed on
const { screen, announce } = await speaker();
const errors: unknown[] = [];
const heard = (event: ErrorEvent) => {
  errors.push(event.error);
  event.preventDefault();
};
window.addEventListener("error", heard);
await screen.unmount();
announce("Removed.");
await new Promise((resolve) => setTimeout(resolve));
window.removeEventListener("error", heard);
expect(errors).toEqual([]);

Focus

Saying something leaves focus where it is
const { screen, announce, region } = await speaker();
const field = screen.container.querySelector("input")!;
await userEvent.click(field);
announce("Copied 4 cells.");
await expect.poll(() => region.textContent).toBe("Copied 4 cells.");
expect(document.activeElement).toBe(field);
The region is never in the Tab order
function Page() {
  const announcer = useAnnouncer();
  return (
    <>
      <input aria-label="Before" />
      <div {...announcer.regionProps} />
      <input aria-label="After" />
    </>
  );
}
const screen = await render(<Page />);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();

Several on a page

Each speaker has its own region, beside it: one's words are not said in another's
const board = await speaker("Board");
const grid = await speaker("Grid");
board.announce("2 selected.");
grid.announce("Sorted by Price.");
await expect.poll(() => board.region.textContent).toBe("2 selected.");
await expect.poll(() => grid.region.textContent).toBe("Sorted by Price.");
expect(board.region.closest("section")?.getAttribute("aria-label")).toBe("Board");
expect(grid.region.closest("section")?.getAttribute("aria-label")).toBe("Grid");

API

announce is the same function on every render, so what depends on it does not run again
const seen = new Set<Announcer["announce"]>();
function Counter() {
  const { announce, regionProps } = useAnnouncer();
  const [count, setCount] = useState(0);
  seen.add(announce);
  return (
    <>
      <button type="button" onClick={() => setCount(count + 1)}>
        {`Count ${count}`}
      </button>
      <div {...regionProps} />
    </>
  );
}
const screen = await render(<Counter />);
await userEvent.click(screen.getByRole("button"));
await expect.element(screen.getByRole("button")).toHaveTextContent("Count 1");
expect(seen.size).toBe(1);
Saying something renders nothing: the region is written directly
const renders: Array<Announcer["announce"]> = [];
function Quiet() {
  const { announce, regionProps } = useAnnouncer();
  renders.push(announce);
  return <div {...regionProps} />;
}
const screen = await render(<Quiet />);
const before = renders.length;
renders.at(-1)!("Filled 3 cells.");
await expect.element(screen.getByRole("status")).toHaveTextContent("Filled 3 cells.");
// Time for a render that was asked for to have happened.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(renders).toHaveLength(before);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/announcements.json
Source

src/primitives/announcements.ts

/**
 * Saying aloud what someone cannot see happen: a polite live region, kept
 * beside what speaks, written outside React so that saying something renders
 * nothing.
 *
 * Each announcement is a new node in the region, so the same words said twice
 * are heard twice: a region whose text is set to what it already holds has
 * nothing new in it to read. Words said in one turn of the event loop are one
 * announcement, the last — what an act came to, not each step of it. Words
 * that come faster than they can be heard — a key held down — are paced, and
 * each that comes while one waits takes its place.
 */
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { RefCallback } from "react";

/** The least time between two announcements, in milliseconds. */
export const ANNOUNCEMENT_PACE = 400;

export interface AnnouncerRegionProps {
  readonly ref: RefCallback<HTMLElement>;
  readonly role: "status";
  readonly "aria-live": "polite";
  readonly className: string;
}

export interface Announcer {
  /** Says `text`, politely; `""` withdraws what was said. */
  readonly announce: (text: string) => void;
  /** For the region, rendered empty: what it holds is written by `announce`. */
  readonly regionProps: AnnouncerRegionProps;
}

export function useAnnouncer(): Announcer {
  const region = useRef<HTMLElement | null>(null);
  const pending = useRef<string | null>(null);
  const spokenAt = useRef(-Infinity);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const flush = useCallback(() => {
    timer.current = undefined;
    const words = pending.current;
    pending.current = null;
    const element = region.current;
    if (words === null || element === null) return;
    if (words === "") {
      element.replaceChildren();
      return;
    }
    const node = element.ownerDocument.createElement("span");
    node.textContent = words;
    element.replaceChildren(node);
    spokenAt.current = performance.now();
  }, []);

  const announce = useCallback(
    (text: string) => {
      const waiting = pending.current !== null;
      pending.current = text;
      if (waiting) return;
      const wait = spokenAt.current + ANNOUNCEMENT_PACE - performance.now();
      if (wait > 0) timer.current = setTimeout(flush, wait);
      else queueMicrotask(flush);
    },
    [flush],
  );

  useEffect(
    () => () => {
      clearTimeout(timer.current);
      pending.current = null;
    },
    [],
  );

  const ref = useCallback((element: HTMLElement | null) => {
    region.current = element;
  }, []);

  return useMemo(
    () => ({ announce, regionProps: { ref, role: "status", "aria-live": "polite", className: "sr-only" } }),
    [announce, ref],
  );
}