Search topics
Chat widgets that never announce their messages
A support chat keeps its messages in a plain div, so a screen reader never reads the greeting when the panel opens, or a reply when it arrives.
messages.append(bubble) The problem
Screen reader users don't hear a support chat's greeting when they open it, or its replies when they arrive.
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.
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.
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.
1 of 10 groups affected
- No vision (affected)
- Low vision (not affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (not affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Without vision Screen reader users open the chat and hear only the message field, then send a question and hear nothing back. To find out what the greeting said and whether anyone has answered, they leave the field and read through the conversation again and again, while the support team may be waiting for them.
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.
Try it
Open the chat and send the message. What does a screen reader read?
- Press Tab to reach Chat with us, then Enter. Focus goes to the message field: is the greeting read with it?
- Press Enter to send the question, and watch the panel under the page for what a screen reader says when the reply arrives.
- Close the chat with Escape or the ×, and open it again: what is read now?
Broken Can you fix this?
Delivery
Most orders arrive within five working days. Large items are delivered on a day you choose.
Chat with Birchwood
Birchwood: Hi! How can we help with your order?
Screen reader says:
(Open the chat and send the message)
The fix
Read the conversation with the message field when the panel opens, and add each reply to a log or live region that is already on the page.
- 1The conversation so far is read when the panel opens.
- 2Every reply lands in a region a screen reader watches, and focus stays where it is.
- 3The panel closes with a named button and with Escape, and focus goes back to the launcher.
<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>A log for the messages, read when the panel opens
<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>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.
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."
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.
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, 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.
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 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 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.
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.
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. 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. 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. 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 shows the ways to make it come next after the launcher.
Fixing this with an AI coding assistant? Get this guide as Markdown, or get every guide in your coding agent.
Verify the fix
5 checks, no mouse.
Test with: Screen reader, Keyboard
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 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.
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.
Keep this fixed as your code changes.
Turn guides like this into rules your coding agents and CI follow, then have the result retested with a keyboard and screen readers.