Skip to content

Technology

Chat Widget Accessibility: Where a Floating Panel Fails a Keyboard and a Screen Reader

A widget that floats above the page inherits none of the page's accessibility work. The places a chat interface fails a keyboard or a screen reader, each stated against the WCAG 2.2 criterion it breaches.

Zealsync Insights27 min read
Making a chat widget usable without a mouse

A chat widget is usually the last thing added to a site and the first thing to break the keyboard path through it. It arrives late, often from a different team or a different vendor, and it renders itself on top of a page whose focus order, contrast decisions and landmark structure were settled months earlier. None of that work transfers. The widget is a second interface sharing one document, and it has to satisfy the same success criteria from scratch.

What follows is not a reminder to be accessible. It is a component-by-component account of where a floating chat panel fails, with the WCAG 2.2 success criterion named each time, and with the conformance level stated so you can tell an A failure from an AAA preference. Some of these are strict conformance failures. Some are choices that pass conformance and still leave a person stranded. Both are worth knowing, and it is worth knowing which is which.

A floating widget inherits none of the page's accessibility work

The page you have already built probably has a skip link, semantic landmarks, one h1, a heading order that makes sense, a focus ring you chose deliberately, and a colour palette somebody checked against a contrast ratio. The widget has its own DOM subtree, usually its own stylesheet with its own reset, sometimes its own shadow root, and increasingly its own iframe. Every one of those boundaries is an accessibility boundary as well as a styling one.

The consequences are specific rather than vague. A focus ring defined by a page-level rule does not reach into a shadow root that resets outlines. Heading levels inside the panel have no relationship to the heading levels outside it, so an h3 in the chat header sits in the document outline as though it belonged to whatever h2 preceded it. Text-spacing overrides applied by a user, which 1.4.12 Text Spacing exists to protect, hit the page's typography and are ignored by a widget that hard-codes line height in pixels. If the panel is an iframe, it needs a title attribute or a screen reader announces an unnamed frame, and the content inside it is a separate document the user has to enter deliberately.

One thing does cross the boundary, and it is worth being precise about it. The reduced-motion preference is a user-agent-level setting exposed to every document, including a cross-origin frame. What does not cross is your CSS. A widget that never wrote a prefers-reduced-motion rule of its own has none, and the fact that your page handles motion correctly does nothing for it.

  • Focus ring style and offset, which reset inside a shadow root or an iframe.
  • Heading level context, so the panel's headings float free of the page outline.
  • Landmark structure, so the panel is not reachable by landmark navigation unless it declares its own.
  • User text-spacing and font-size overrides, wherever the widget hard-codes dimensions.
  • Your reduced-motion rules, your contrast tokens, and every other decision that lives in your stylesheet.

There is a further failure that only appears at zoom. 1.4.10 Reflow requires content to be usable at a viewport width equivalent to 320 CSS pixels without two-dimensional scrolling. A fixed-position launcher that occupies a modest corner at 1440 pixels can cover a serious fraction of the viewport at 400 per cent zoom, and a panel sized as a percentage of the viewport can become the viewport. Neither is caught by testing at a single desktop width, which is how most widgets are tested.

The launcher: a button, a target size, and a name that says what it opens

Start with the element. The launcher must be a button element, or something with an explicit button role and full keyboard behaviour attached by hand. A div with a click handler has no role, is not in the tab order, and does not respond to Enter or Space. That is a failure of 4.1.2 Name, Role, Value at level A and of 2.1.1 Keyboard at level A simultaneously, and it remains the most common defect in home-grown chat widgets. Using the native element gets you the role, the tab stop, the key handling and the disabled semantics for nothing.

Then the name. An icon-only launcher has no accessible name unless you give it one, and the name should describe what activating the control does, not what the icon depicts. Chat is a label for a thing; Open chat is a label for an action. When the panel is open, the state has to be programmatically determinable, which means either the control carries aria-expanded, or its accessible name changes to Close chat, or the open panel supplies its own labelled close control and the launcher leaves the tab order. Any of those is defensible. What is not defensible is a control whose name still says Open chat while the chat is open.

Target size is where the launcher usually passes and the controls inside the panel usually do not. 2.5.8 Target Size (Minimum) is level AA and asks for 24 by 24 CSS pixels, with exceptions for inline targets and for equivalents available elsewhere. 2.5.5 Target Size (Enhanced) is level AAA and asks for 44 by 44. A 56-pixel circular launcher clears both comfortably. The 16-pixel close glyph in the panel header, with four pixels of padding, clears neither, and the same is true of a small send arrow, a tiny attachment control, and a dismiss cross on a suggestion chip.

  • Launcher is a real button with an accessible name describing the action (4.1.2, 2.1.1).
  • Open or closed state exposed through aria-expanded or a changing accessible name (4.1.2).
  • Every interactive control in the panel meets 24 by 24 CSS pixels (2.5.8).
  • An unread count is text in the accessible name, not a coloured dot alone (1.4.1 Use of Colour).
  • The launcher does not obscure a focused element elsewhere on the page (2.4.11 Focus Not Obscured).

That last item is new in WCAG 2.2 and it catches floating widgets specifically. 2.4.11 Focus Not Obscured (Minimum), level AA, requires that when a user interface component receives keyboard focus, it is not entirely hidden by author-created content. A launcher pinned to the bottom-right corner sits over whatever is beneath it, and on many sites that is the footer navigation. Tab down the page with the mouse untouched and watch what happens when focus reaches the link the launcher is covering. Nothing appears to happen at all, which is precisely the problem: the focus indicator exists, and the user cannot see it.

Focus on open, focus on close, and the trap in between

Opening the panel raises a question with only a few good answers: where does focus go? Leaving it on the launcher is what happens if you write no code at all, and it is wrong, because the next Tab press continues from the launcher's position in the document rather than from inside the panel. Most widgets are appended to the end of the body, so if focus is left behind, a keyboard user who has just opened the panel may need to traverse the entire remaining page to reach the thing they opened.

There are two reasonable destinations. The first is the panel container itself, given tabindex of minus one and an accessible name, so that a screen reader announces the panel and then reads forward through the greeting, any existing transcript, and finally the input. The second is the message input directly, on the reasoning that a person who opened a chat intends to type. The trade-off is real and worth stating plainly. Focusing the input is faster for a sighted keyboard user and skips the greeting for a screen reader user, who then has to navigate backwards to find out what they were asked. Focusing the labelled container is slower by a keystroke or two and loses nothing. For a chat interface, where the greeting frequently carries the disclosure that a machine is answering, the container is the better default.

Closing has one correct answer. Focus returns to the launcher, or to whatever control now stands in its place. If focus falls to the document body, the next Tab starts from the top of the page, and a user who opened the panel from the footer is deposited back at the skip link. That is a 2.4.3 Focus Order failure at level A, and it is trivially reproducible: open, close, press Tab, see where you are.

A widget that opens with a mouse and closes only with a mouse is not a widget with an accessibility gap. It is a dead end with a chat log in it.

Between those two moments sits the decision most implementations get wrong in one direction or the other. If the panel is declared as a dialog with aria-modal set true, the rest of the page is hidden from assistive technology and focus must be contained inside the panel. Containment is only legitimate if there is a keyboard route out, which is what 2.1.2 No Keyboard Trap requires at level A, and for a modal that route is conventionally Escape. A modal chat panel with no Escape handler is a keyboard trap in the strict sense of the criterion, not merely an inconvenience.

The alternative is a non-modal panel: the page stays operable behind it, focus is not contained, and the user can Tab out into the page and back in. This is usually the right model for chat, because a person may want to read the page while the conversation is open, and hiding the page from assistive technology to show a chat window is a heavy-handed thing to do. But non-modal carries its own obligation. Tabbing out of the panel must not strand the user, and returning must be possible without traversing the whole document. In practice that means the panel sits adjacent to the launcher in the DOM, or the widget manages focus explicitly, or both. Choosing modal because it is easier to implement, on an interface that is not conceptually modal, is how you end up with a chat window nobody can escape without a mouse.

Whichever model you pick, the indicator has to stay visible throughout. 2.4.7 Focus Visible is level AA and asks only that a keyboard focus indicator exists. 2.4.13 Focus Appearance is level AAA and specifies the size and contrast of that indicator. A widget that removes outlines in its reset and never restores them fails the AA criterion outright, and the failure is completely invisible to anyone testing with a mouse, which is why it survives so many rounds of review.

Announcing a new message without announcing every keystroke

4.1.3 Status Messages, level AA, covers content that reports a change and does not receive focus. A reply arriving in a chat transcript is the textbook case. The user is somewhere else in the interface, or has typed and is waiting, and something appeared. If nothing is announced, a screen reader user has no way to know a reply arrived short of navigating back into the transcript to check, repeatedly, which is the interaction equivalent of refreshing a page to see whether anything happened.

Live regions and politeness

The mechanism is an ARIA live region: a container marked aria-live, into which new content is inserted. The politeness setting decides what happens to whatever is currently being spoken. Polite queues the announcement until the current utterance finishes. Assertive interrupts it. A chat reply is not an emergency and does not justify cutting a user off mid-sentence, so polite is correct here, and assertive should be reserved for something genuinely time-critical, such as a session about to expire.

Two structural details matter more than the attribute value. The region has to exist in the accessibility tree before the content arrives, because a container created and populated in the same operation is often not treated as a change to an existing region; the empty transcript container should therefore be present from the moment the panel renders. And aria-atomic should stay false for a transcript, so that only the added node is announced rather than the entire conversation being re-read from the beginning on every reply. An atomic transcript is technically announced and practically unusable by the fifth message.

Attribution is the part most often skipped. If the announcement carries only the message text, the user cannot distinguish a reply from an echo of what they just typed, and in a widget that shows an assistant now and a human later, they cannot tell which is speaking. The announced string needs the author. Whether that comes from a visually hidden label inside each message, or from the accessible name of a group wrapping it, is an implementation choice. That it must be present is not.

  • The live region container renders empty and stays in the DOM (4.1.3).
  • Politeness is polite, not assertive, for ordinary replies.
  • aria-atomic left false so only new messages are announced, not the whole transcript.
  • Every announced message carries its author as text, not as alignment or colour (1.3.1, 1.4.1).
  • The user's own sent message is confirmed once, through a short status, rather than read back in full.

Streaming text, and binding the announcement to a completed message

Streaming a reply token by token is now the default presentation for an assistant, and it collides with live regions in a way that follows from the criterion alone, without any need to test a particular screen reader. A live region announces changes to its contents. If the visible text node inside that region is mutated once per token, then a reply of a hundred tokens is a hundred changes to the region. The criterion asks you to expose status changes; the implementation is manufacturing them at the rate of the model's output. The result is determined by arithmetic, not by any vendor's behaviour.

The design consequence is therefore that the announcement must be bound to a completed message rather than to the stream. In practice the element the tokens are written into is not the live region. Stream into a container whose live setting is off, or one marked aria-busy while it fills, and write the finished message into the polite region once generation completes. Sighted users still see the text appear progressively. The announcement fires once, carries the whole reply, and can be attributed to a speaker.

Take that decision the other way and you pay a different cost, so it is worth naming rather than glossing. If nothing is announced until the reply completes, a screen reader user waits in silence for however long generation takes, with no signal that anything is happening and no way to distinguish a slow answer from a failed one. That is a worse experience than a noisy one. It is why the shape that actually works has two announcements rather than none: a short polite status when generation begins, along the lines of Assistant is replying, and the completed message when it ends. Two announcements per turn is proportionate. One per token is not.

The edge case that breaks the tidy rule is length. A single announcement carrying four hundred words of a long answer is not usable either, because the listener cannot skim it, pause at a heading, or return to a specific part of it without navigating the transcript afterwards. Where replies are routinely long, chunking is the compromise, and the boundary should be semantic rather than temporal: announce on paragraph completion, not every two seconds. Splitting on a timer cuts sentences in half, which is worse than either extreme it was meant to reconcile.

Quick replies, suggestion chips and the keyboard path through them

Suggestion chips are the row of short options offered beneath a message: see pricing, talk to a person, something else. They are a good pattern, because they turn an open text field into a closed choice and spare the visitor from guessing what the widget understands. They are also a reliable source of keyboard defects, and the defects compound as the conversation gets longer.

The first is the familiar one. Chips rendered as divs, or as anchors without an href, have no role and no tab stop, so they fail 2.1.1 and 4.1.2 in exactly the way a div launcher does. The second is subtler and specific to chat: chips accumulate. Every assistant turn adds another set, and if superseded sets stay in the DOM as focusable buttons, the tab distance from the top of the transcript to the message input grows with the length of the conversation. After ten turns, reaching the input means passing through several dozen stale controls that no longer do anything useful. Chips from superseded turns should be removed from the tab order, or removed entirely, and the current set should be a group with an accessible name so its purpose is clear the moment focus enters it.

The third question is whether a chip row should be a composite widget with one tab stop and arrow-key navigation, or simply a row of buttons with one tab stop each. There is a genuine argument for the composite: it keeps the tab order short and it matches a toolbar pattern users already know. There is a stronger argument against it in most cases. A chip row is not a toolbar, a listbox or a set of tabs, and applying one of those roles because it seemed closest produces an interface that announces expectations it does not then meet, which is worse than announcing nothing. Three buttons should be three buttons. If the row is long, horizontal and genuinely toolbar-like, a roving tabindex is defensible, provided the group carries the matching role and the arrow behaviour implements that role exactly.

Two smaller failures round this out. If the chip row scrolls horizontally and the only way to reach the fourth chip is to drag it into view, 2.5.7 Dragging Movements at level AA requires a single-pointer alternative that is not a drag, and keyboard focus moving to an off-screen chip must scroll it into view or you have recreated the obscured-focus problem inside the panel you were being careful about outside it. And when a chip is activated and its row is removed, focus has to be sent somewhere deliberate, usually the input or the newly arrived message. Focus that lands on a removed element lands on the body, and the user is back at the top of the document without having asked to go there.

Status, typing indicators and information conveyed by motion alone

The three bouncing dots that mean the assistant is composing a reply are information conveyed entirely by animation. There is no text, no label and no alternative. 1.1.1 Non-text Content, level A, requires a text alternative for non-text content that serves a purpose, and the purpose here is to tell the user that waiting is the right thing to do. Without it, silence after sending is indistinguishable from failure, and the reasonable response to apparent failure is to send the message again.

Giving it a visually hidden text equivalent is the obvious fix, and it creates a second problem if done carelessly. If the pending status is written into the same polite live region as the messages, and the indicator toggles on and off around every turn, the user receives an announcement before every reply and another after it, doubling the traffic for no additional information. The workable version announces the pending state once per turn, and only when it persists long enough to be worth saying. A short delay before the status is written means a fast reply produces one announcement rather than three.

A perpetual animated indicator carries a further obligation. 2.2.2 Pause, Stop, Hide, level A, applies to moving content that starts automatically, lasts more than five seconds, and is presented alongside other content. A typing indicator running for the duration of a long generation meets all three conditions. The honest resolution is not to bolt a pause button onto the dots. It is that the reduced-motion path replaces the animation with a static text state, which satisfies the criterion and is better design in any case.

The same reasoning covers the other statuses a chat widget likes to display. An availability indicator that is a green circle for open and a grey circle for closed conveys its meaning through colour alone, which 1.4.1 Use of Colour prohibits at level A; it needs the words beside it, not a tooltip containing them. A failed send that turns the message bubble red and does nothing else fails 1.4.1 and also 3.3.1 Error Identification, which requires the error be described in text. The retry affordance, if there is one, has to be a real button rather than a clickable bubble, or you have hidden the recovery path from every keyboard user at the exact moment they needed it most.

Reduced motion, contrast, and the widget that ignores the page's settings

Chat widgets are unusually fond of motion. The panel springs in with a scale transform, message bubbles stagger upward as they arrive, the launcher pulses every half minute to remind you it is there. Be honest about the standing of this. 2.3.3 Animation from Interactions, which asks that motion animation triggered by interaction can be disabled, is level AAA, not AA. A widget full of springy entrances is not, by itself, an AA conformance failure, and anyone who tells you otherwise has not read the level.

Honour it anyway. The cost is a media query and a handful of rules that resolve every entrance directly to its end state. The cost of not honouring it falls on people with vestibular conditions, for whom a large element scaling into the viewport is not a stylistic preference. There is also a genuine level A line close by: 2.3.1 Three Flashes or Below Threshold prohibits content that flashes more than three times in any one second, and an attention-seeking launcher animation is exactly the sort of thing that gets built enthusiastically without anyone counting the flashes.

Contrast is the other place a widget quietly diverges from the page it sits on. 1.4.3 Contrast (Minimum), level AA, requires 4.5 to 1 for body text and 3 to 1 for large text. Two elements fail it repeatedly. The first is metadata: timestamps, delivery states and sender labels set in a light grey chosen because it looked appropriately secondary next to the message. The second is the outgoing message bubble, which is usually the brand colour with white text on it, and the brand colour was selected by a designer looking at a logo rather than at a ratio.

1.4.11 Non-text Contrast, also level AA, requires 3 to 1 for user interface components and for the parts of graphics needed to understand content. In a chat widget that means the panel's own edge against the page behind it, the outline of the input field, the send icon, chip borders, and the focus ring. The focus ring deserves a specific note, because it has to clear 3 to 1 against every background it can appear on. A black ring reads perfectly against a white panel and can vanish entirely against a dark brand-coloured bubble. It passes in the state you tested and fails in the state you did not, which is the general shape of every contrast bug that reaches production.

  • Body text at 4.5 to 1, including timestamps, sender labels and delivery states (1.4.3).
  • Panel edges, input borders, icons and chip outlines at 3 to 1 (1.4.11).
  • Focus indicator at 3 to 1 against every surface it can land on, including coloured bubbles (1.4.11).
  • A reduced-motion path that resolves entrances instantly and replaces animated status with text.
  • No content flashing more than three times per second anywhere in the widget (2.3.1).

Finally, the widget's own reset becomes a liability at zoom. Bubbles with fixed heights clip their text when a user increases line height, which 1.4.12 Text Spacing exists to prevent, and a panel sized in viewport units becomes unusable when text is enlarged under 1.4.4 Resize Text. A widget that opted out of the page's typography also opted out of whatever testing that typography received, which is the recurring theme of this entire piece stated one more way.

Transcript, escalation and the exit that must always exist

The transcript is the record of the conversation and it should be structured as one. A sequence of divs distinguished only by alignment and background colour conveys, to a screen reader, a sequence of paragraphs with no speakers attached. 1.3.1 Info and Relationships, level A, requires that structure conveyed visually is also available programmatically. Left and right alignment is a visual grammar carrying real information about who is speaking, and it needs a text or semantic equivalent: a list of messages each with an author, or groups with accessible names.

Navigation inside a long transcript matters too. A screen reader user who opens a panel containing fifteen previous messages needs a way to move through them and a way to reach the most recent one without reading everything that came before it. Giving the transcript a heading, or a labelled region, is enough for heading and landmark navigation to do the rest. It costs one element and it is the difference between a transcript and a wall.

Then escalation, which outlives every other decision here. A chat widget must always offer a route to a person, reachable at any point in the conversation, including in the middle of it and including when the assistant has plainly failed to understand. That is not a WCAG criterion. It is the difference between a widget and a trap, and it is the thing a visitor remembers about the interaction long after they have forgotten what the panel looked like.

A related criterion is new in WCAG 2.2 and applies precisely at the handover. 3.3.7 Redundant Entry, level A, requires that information previously entered by the user is either auto-populated or available for selection, rather than demanded a second time in the same process. If the conversation has already collected an email address and the escalation form then asks for it again from an empty field, that is the criterion breached, and it is also the moment a frustrated person gives up. The wider argument about when a widget should hand over at all is made in when an answer is not enough, and the case for saying plainly that a machine is answering is made in telling visitors they are talking to a machine.

A disclosure belongs here rather than at the end, because it bears directly on this section. Zealsync develops Flidu, a lightweight website communication widget that can be configured as a contact widget, an AI assistant, or the two combined, so this is not a disinterested account of what floating panels ought to do. Nothing above should be read as a description of how Flidu is built. It is a description of what a published standard asks of any widget of this shape, including one built by the company writing it down.

Testing it: keyboard only, then a screen reader, then reduced motion

Automated tooling will find a portion of this. It reliably catches a missing accessible name, a control that is not focusable, and a contrast failure on static text against a solid background. It cannot tell you that focus went to the wrong place on close, that a reply was announced four times, or that the tab distance to the input grows with the conversation. Those are the defects that make the widget unusable rather than merely imperfect, and every one of them is found by hand in a few minutes.

  • Pass one, keyboard only. Put the mouse away entirely. Tab from the top of the page. Can you reach the launcher, and is the focus indicator visible when you do? Open it. Where did focus go? Tab through the panel. Does focus leave it, and should it? Press Escape. Where is focus now? Send a message. Activate a chip. Close the panel. Focus must be back on the launcher, not on the body.
  • Pass two, screen reader. Two questions only, and each has a single correct answer. Is a new message announced exactly once? Is it attributed to a speaker? If the reply streams, listen through the whole generation rather than only the last second of it.
  • Pass three, reduced motion. Set the preference at the operating system level, reload, and repeat pass one from the beginning. Nothing should be lost, no state should become unreachable, and no status that was previously animated should now be silent and invisible.
  • Pass four, zoom and reflow. 200 per cent zoom, then a viewport 320 CSS pixels wide. Does the launcher cover the footer links? Does the panel become the whole page? Does any bubble clip its own text?
  • Pass five, regression. The widget is versioned separately from the site, and often maintained by someone else, so this is not a one-off audit. It is a check that runs whenever the widget updates, on the same five passes, in the same order.

One argument cuts against everything above and deserves a hearing rather than a dismissal. If a chat interface is genuinely a separate application, with its own transcript, its own state and its own navigation, then presenting it as a floating panel over an unrelated document is the source of most of these problems. A dedicated page would give it a real landmark structure, a natural focus order, no obscured content, no fixed positioning to break at zoom, and no shadow-root reset to fight. It would also lose the thing the widget exists for, which is proximity to whatever the visitor was reading when the question occurred to them. That trade is worth making consciously rather than by default. For most sites the panel wins on merit. It simply has to be built as carefully as the page underneath it, by someone who tested it the way the people who need it will use it.

What would change the verdict on any individual decision here is evidence, and the standard is explicit about where evidence is required. Conformance levels are published for every criterion so you can separate an obligation from a preference, and this piece has stated them rather than implying that everything is equally mandatory. Claims about how a particular assistive technology behaves in a particular version appear nowhere above, because those require testing rather than reasoning. Test them against the widget you are actually shipping, on the platforms your visitors actually use, and keep the result somewhere you will look again, because it will change the next time the widget does.

Pass it on

Share this signal

Send it to someone working through the same question.