# Chat widgets that never announce their messages

Source: https://easeweb.dev/learn/chat-widgets
Topics: Dialogs and overlays > Chat widgets announce their messages (WCAG 4.1.3)
The fix: When the panel opens, point the message field's aria-describedby at every message in the conversation, so the greeting is read with the field. For replies, either give the message list role="log" and a name, or keep the list as it is and also write each reply into a live region that is on the page from the start.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA23 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

Birchwood Home, a furniture shop, has a "Chat with us" button in the corner of every page. It opens a small chat panel in its place: a heading, Chat with Birchwood, a × to close it, the greeting "Hi! How can we help with your order?", and a message field with a Send button. A screen reader user presses the button. Focus moves to the message field, and the screen reader says "Message, edit text", and nothing of the greeting.

They type "Can I change my delivery address?" and press Enter. The question appears in the conversation. A few seconds later the answer appears under it: "Yes. Until your order ships, you can change it under Your orders." The screen reader says nothing. The user waits, then leaves the field to read back through the conversation and find out what the greeting said and whether anyone replied.

## Why it fails

The script adds each message as a new paragraph inside a plain `div`. Assistive technology generally announces a change to the page only inside a live region, and this `div` is not one, so the reply is shown and never announced. A reply is news about what the person just did, and it arrives without moving focus, which is what WCAG 4.1.3 covers: W3C's technique for it with `role="log"` uses a chat history as its example. A screen reader user can still find the reply by reading back, but nothing tells them when to look.

The greeting is missed for another reason. It is already in the panel when the panel is shown, and moving focus to the field reads the field's name and nothing around it. The greeting can still be found by reading, so on its own this fails no criterion, but the person starts the chat without knowing what it says, and a greeting can hold what they need first: how long a reply takes, or what to have ready.

## Who is affected

The problem stays out of sight for anyone looking at the panel: they see the greeting when it opens and the reply when it arrives. It reaches the people who rely on what is read to them.

## What automation and AI miss

A scan of the page sees a button, a labelled field and a `div` of text, and finds nothing wrong. The failure exists only in time: a reply has to arrive, which means sending a message, and an audit must never send one on a live site, because it goes to a real support team. Most chat widgets are a vendor's script, often drawn inside a frame from the vendor's own domain, so nobody on the site's team has read its code. AI assistants asked for a chat widget can write this pattern, because each piece looks complete on its own: the bubbles appear, the panel scrolls, and the newest message stays in view.

## Before: a chat that reads neither the greeting nor the replies

```html
<button type="button" class="chat-launcher">Chat with us</button>
<section id="chat" class="chat-panel" aria-labelledby="chat-title" hidden>
  <div class="chat-header">
    <h2 id="chat-title">Chat with Birchwood</h2>
    <button type="button" class="chat-close" aria-label="Close chat">×</button>
  </div>
  <div class="chat-messages">
    <p class="message from-us"><span class="visually-hidden">Birchwood: </span>Hi! How can we help with your order?</p>
  </div>
  <form class="chat-form">
    <label for="chat-input" class="visually-hidden">Message</label>
    <input id="chat-input" autocomplete="off">
    <button>Send</button>
  </form>
</section>
```

```js
const launcher = document.querySelector('.chat-launcher');
const panel = document.getElementById('chat');
const messages = panel.querySelector('.chat-messages');
const input = document.getElementById('chat-input');
const socket = new WebSocket('wss://chat.example/birchwood');

// The panel opens in the launcher's place.
function openChat() {
  launcher.hidden = true;
  panel.hidden = false;
  input.focus();
}
function closeChat() {
  panel.hidden = true;
  launcher.hidden = false;
  launcher.focus();
}
function addMessage(text, from) {
  const bubble = document.createElement('p');
  const sender = document.createElement('span');
  bubble.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'us' ? 'Birchwood: ' : 'You: ';
  bubble.append(sender, text);
  messages.append(bubble);
}

launcher.addEventListener('click', openChat);
panel.querySelector('.chat-close').addEventListener('click', closeChat);
panel.addEventListener('keydown', (event) => {
  // An input method uses Escape to cancel a conversion; leave that key to it.
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closeChat();
});
panel.querySelector('.chat-form').addEventListener('submit', (event) => {
  event.preventDefault();
  socket.send(input.value);
  addMessage(input.value, 'you');
  input.value = '';
});
socket.addEventListener('message', (event) => addMessage(event.data, 'us'));
```

Opening the panel moves focus to the field, which is read by its label, "Message", and nothing more, so the greeting is never read. The conversation is a plain `div`, so appending the reply changes the screen and nothing else. The rest of the panel works: the launcher and the close control are named buttons, Escape closes the panel too, and focus moves into the panel and back out to the launcher.

## After: a log for the messages, read when the panel opens

```html
<button type="button" class="chat-launcher">Chat with us</button>
<section id="chat" class="chat-panel" aria-labelledby="chat-title" hidden>
  <div class="chat-header">
    <h2 id="chat-title">Chat with Birchwood</h2>
    <button type="button" class="chat-close" aria-label="Close chat">×</button>
  </div>
  <div class="chat-messages" role="log" aria-label="Messages">
    <p id="message-0" class="message from-us"><span class="visually-hidden">Birchwood: </span>Hi! How can we help with your order?</p>
  </div>
  <form class="chat-form">
    <label for="chat-input" class="visually-hidden">Message</label>
    <input id="chat-input" autocomplete="off">
    <button>Send</button>
  </form>
</section>
```

```js
const launcher = document.querySelector('.chat-launcher');
const panel = document.getElementById('chat');
const messages = panel.querySelector('.chat-messages');
const input = document.getElementById('chat-input');
const socket = new WebSocket('wss://chat.example/birchwood');
let count = 0;

// The panel opens in the launcher's place, and the field is read with every message so far.
function openChat() {
  launcher.hidden = true;
  panel.hidden = false;
  const ids = [...messages.children].map((message) => message.id);
  input.setAttribute('aria-describedby', ids.join(' '));
  // Read them once, not again each time focus comes back to the field.
  input.addEventListener('blur', () => input.removeAttribute('aria-describedby'), { once: true });
  input.focus();
}
function closeChat() {
  panel.hidden = true;
  launcher.hidden = false;
  launcher.focus();
}
function addMessage(text, from) {
  const bubble = document.createElement('p');
  const sender = document.createElement('span');
  bubble.id = `message-${++count}`;
  bubble.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'us' ? 'Birchwood: ' : 'You: ';
  bubble.append(sender, text);
  messages.append(bubble);
}

launcher.addEventListener('click', openChat);
panel.querySelector('.chat-close').addEventListener('click', closeChat);
panel.addEventListener('keydown', (event) => {
  // An input method uses Escape to cancel a conversion; leave that key to it.
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closeChat();
});
panel.querySelector('.chat-form').addEventListener('submit', (event) => {
  event.preventDefault();
  socket.send(input.value);
  addMessage(input.value, 'you');
  input.value = '';
});
socket.addEventListener('message', (event) => addMessage(event.data, 'us'));
```

Opening the panel sets the field's `aria-describedby` to the id of every message, so the field is read with the conversation so far: "Message, edit text. Birchwood: Hi! How can we help with your order?" Opening it again later reads every message since the start. The ids point at the messages, not at the log, because a description taken from the log can come out as its name, "Messages", instead of what is in it. The description is removed when focus first leaves the field, so going to Send and back doesn't read the conversation again.

`role="log"` makes the conversation a live region: it is polite by default, so a screen reader finishes what it is saying and then reads each message added to the end, without moving focus. The log is in the page's HTML before any reply arrives, which is what lets the browser see each reply as a change. Give the log a name, here "Messages", so a screen reader can say what it is. Each message carries its sender in visually hidden text, so the reply is read as "Birchwood: Yes. Until your order ships, you can change it under Your orders."

## After: a separate announcer for replies, and the conversation read on opening

```html
<button type="button" class="chat-launcher">Chat with us</button>
<section id="chat" class="chat-panel" aria-labelledby="chat-title" hidden>
  <div class="chat-header">
    <h2 id="chat-title">Chat with Birchwood</h2>
    <button type="button" class="chat-close" aria-label="Close chat">×</button>
  </div>
  <div class="chat-messages">
    <p id="message-0" class="message from-us"><span class="visually-hidden">Birchwood: </span>Hi! How can we help with your order?</p>
  </div>
  <div id="chat-announcer" class="visually-hidden" aria-live="polite"></div>
  <form class="chat-form">
    <label for="chat-input" class="visually-hidden">Message</label>
    <input id="chat-input" autocomplete="off">
    <button>Send</button>
  </form>
</section>
```

```js
const launcher = document.querySelector('.chat-launcher');
const panel = document.getElementById('chat');
const messages = panel.querySelector('.chat-messages');
const announcer = document.getElementById('chat-announcer');
const input = document.getElementById('chat-input');
const socket = new WebSocket('wss://chat.example/birchwood');
let count = 0;

// The panel opens in the launcher's place, and the field is read with every message so far.
function openChat() {
  launcher.hidden = true;
  panel.hidden = false;
  const ids = [...messages.children].map((message) => message.id);
  input.setAttribute('aria-describedby', ids.join(' '));
  // Read them once, not again each time focus comes back to the field.
  input.addEventListener('blur', () => input.removeAttribute('aria-describedby'), { once: true });
  input.focus();
}
function closeChat() {
  panel.hidden = true;
  launcher.hidden = false;
  launcher.focus();
}
function addMessage(text, from) {
  const bubble = document.createElement('p');
  const sender = document.createElement('span');
  bubble.id = `message-${++count}`;
  bubble.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'us' ? 'Birchwood: ' : 'You: ';
  bubble.append(sender, text);
  messages.append(bubble);
}
// Only replies are announced; each is a new line, so two quick replies are both read.
function announce(text) {
  const line = document.createElement('p');
  line.textContent = `Birchwood: ${text}`;
  announcer.append(line);
  if (announcer.childElementCount > 5) announcer.firstElementChild.remove();
}

launcher.addEventListener('click', openChat);
panel.querySelector('.chat-close').addEventListener('click', closeChat);
panel.addEventListener('keydown', (event) => {
  // An input method uses Escape to cancel a conversion; leave that key to it.
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closeChat();
});
panel.querySelector('.chat-form').addEventListener('submit', (event) => {
  event.preventDefault();
  socket.send(input.value);
  addMessage(input.value, 'you');
  input.value = '';
});
socket.addEventListener('message', (event) => {
  addMessage(event.data, 'us');
  announce(event.data);
});
```

The conversation stays as it was, and a visually hidden live region beside it says each reply once: "Birchwood: Yes. Until your order ships, you can change it under Your orders." The region is in the HTML from the start and empty, so each new line is a change the browser announces. Only what the script writes into it is said, so the visitor's own message is not read back, and anything else the conversation does (loading older messages, redrawing itself) stays quiet. Removing old lines is not announced. Opening the panel reads the conversation with the field, as in the first fix.

## Which option to use

Both read the conversation when the panel opens and announce every reply without moving focus. The log is less code, and it is the pattern W3C describes for a chat history: the conversation is one named region, and nothing has to be kept in step. It reads everything added to it, which includes the visitor's own message as it is sent. Some people find that a useful confirmation and others find it noise. Choose the separate announcer when the conversation does more than add messages at the end: when it loads older messages at the top as people scroll back, redraws the whole list on each message, or puts a typing indicator and read receipts inside the list. A log would read all of those too. The announcer says only what you write into it, at the cost of a second copy of each reply to keep in step with the first.

## When the chat comes from a third party

Most chat widgets are a vendor's script. It often draws the launcher and the panel inside a frame served from the vendor's domain, and then your CSS and scripts can't reach into it at all. What you can do:

- Test the widget before you choose it, with the steps under "Verify the fix", on the vendor's own demo page or a test page of yours.
- Check the widget's settings. Turn off panels that open by themselves after a few seconds and greeting bubbles that pop up over the page, and give the launcher a visible label rather than an icon alone if the settings allow it.
- Ask the vendor for their accessibility conformance report, and ask in so many words whether the greeting and earlier messages are read when the panel opens, whether new messages are in a log or live region, whether the launcher and the close control are buttons with names, whether Escape closes the panel and returns focus to the launcher, and which screen readers they tested with.
- If the vendor's script offers events for a new message, you can write each reply into an announcer of your own on the page, as in the second fix. Check first that the widget doesn't announce them too, or people hear each reply twice.
- Report what fails to the vendor, with the steps to reproduce it. Until it is fixed, list it as a known issue in your [accessibility statement](/learn/accessibility-statement), and give another way to ask, such as an email address, beside the chat.

Vendor scripts change without a deploy on your side, so repeat the checks after each vendor update and each change to the widget's settings.

## Implementation decisions

**The region comes first.** The log, or the announcer, must be in the page before the first reply. In a component framework, render it with the panel, never inside a condition that adds it together with the first message: a region added with its message is not announced, as [status messages that are shown but never announced](/learn/status-messages) explains. The same goes for `aria-hidden="true"` on any part of the panel that holds it.

**A greeting that arrives later.** Many widgets show a typing indicator and add the greeting a moment after the panel opens. In a log, that greeting is announced like a reply; with the separate announcer, write it into the announcer too. Either way, it is not yet in the field's description, so it is read once, not twice.

**Long conversations.** Opening a panel with a long history reads all of it with the field. A screen reader user can stop the speech with Ctrl and read on from where they want. If conversations run long, list only the messages since the panel was last open, or the last few, in the field's description.

**Say who said it.** Bubbles on the left and right, in two colours, tell sighted users who wrote each message, and nothing else does. Put the sender in each message as visually hidden text, as the snippets do, or as a visible name above the bubble.

**Keep the noise out.** A typing indicator written into the log every few seconds is read every few seconds. Show it outside the log, or announce it once. Don't use `aria-live="assertive"` or `role="alert"` for replies; they cut into whatever the person is reading.

**Replies while the panel is closed.** A reply that arrives after the panel was closed is in a hidden region and is not announced. Say it on the launcher instead: change its text to "Chat with us, 1 new message". Don't open the panel by itself to show it.

**Don't move focus to new messages.** It does get them read, but it takes the caret out of the message field while the person is typing their next question.

**Modal or not.** On a desktop the panel doesn't block the page, so people can read the page while they chat, and Tab may leave the panel without closing it. Don't close it when focus leaves, either; [search panels that close on Tab](/learn/panel-dismissal) shows what that breaks. On a phone, where the panel often fills the screen, it behaves as a modal: open it as a dialog with `showModal()`, as in [modals that leave keyboard focus behind them](/learn/modal-focus-management).

**The close button.** The × must be a real `button` with a name. Drawn as a `span` with a click handler, it is never reached with Tab, and because the panel takes the launcher's place, keyboard users are left with no way to close the chat, as with [div buttons that can't be reached with Tab](/learn/native-buttons).

**The launcher.** A launcher drawn as a `div` fails the same way, and an icon-only launcher needs a name, as in [icon-only buttons with no accessible name](/learn/icon-buttons). While it floats in the corner, it can also cover the control that has focus: see [floating chat buttons that cover part of keyboard focus](/learn/focus-not-covered). At high zoom, the launcher and an open panel take far more of the window, along with every other floating part, as in [floating buy bars that cover the page when zoomed in](/learn/floating-buttons). The panel itself is often added at the end of the page, after the footer; the fixes here move focus into it as it opens, and [floating buttons and panels that keyboard focus reaches last](/learn/floating-order) shows the ways to make it come next after the launcher.

## Verify the fix

1. Before sending anything, inspect the DOM: the message list has `role="log"` and a name, or an empty `aria-live="polite"` region sits beside it; neither is inside `aria-hidden="true"` or `display: none` while the panel is open, and every message has an id.
2. With a screen reader running and the mouse put aside, Tab to the launcher and press Enter: focus is in the message field, which is read as "Message, edit text" and then the greeting.
3. Send a message and wait: the reply is read once, with who sent it, and the caret stays in the field. Close the chat and open it again: the field is read with every message so far.
4. Send a question that gets two replies in quick succession, or one that shows a typing indicator: each reply is read once, and the indicator is not read over and over.
5. Tab to the close button, which is announced as "Close chat, button", and press Enter: the panel closes and focus is on the launcher. Open it again and press Escape: the same happens.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the chat widget, its vendor's script or its settings change.

## Limits

This is a starting pattern for a chat panel that sits over the page, not a guarantee for every widget. Live regions behave differently across screen readers and browsers, especially for regions that are hidden and shown, or inside frames, so test with the ones your visitors use. When a field's description is read also depends on the screen reader and its settings: most read it after the field's name, some only after a pause or with hints turned on. Auditors differ on whether each chat reply is a status message: the Understanding document for 4.1.3 lists `role="log"` for the progress of a process, and its technique uses a chat as the example. A reply that is never announced still leaves screen reader users waiting, whichever criterion it is counted under. The message field has a problem of its own when Enter sends: an input method's Enter can send the message before the word is confirmed, as [fields that submit on Enter during IME conversion](/learn/ime-composition) shows. Escape is used by an input method too, to cancel a conversion, so the Escape handlers above ignore a key pressed while a word is being converted.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
