Skip to content

Live regions that render inside a shadow root are not announced #251

Description

@tlouisse

Live regions that render inside a shadow root are not announced

What happens

A live region whose content is rendered inside its own shadow root is never
announced. The shape that hits this is a host element carrying aria-live, with
the message rendered into its shadow root - which is what a web component does
when it renders its own validation feedback.

Reproduced with @guidepup/virtual-screen-reader@0.33.0:

<button id="trigger">Trigger</button>
<div id="host" aria-live="assertive" aria-atomic="false"></div>

<script type="module">
  const host = document.getElementById("host");
  const shadowRoot = host.attachShadow({ mode: "open" });

  document.getElementById("trigger").addEventListener("click", () => {
    shadowRoot.innerHTML = "<span>Please enter a valid IBAN.</span>";
  });
</script>
await virtual.start({ container: document.body });
document.getElementById("trigger").focus();
await virtual.act();

// expected
["document", "button, Trigger", "assertive: Please enter a valid IBAN."]
// actual
["document", "button, Trigger"]

The same region in the light DOM is announced correctly.

Why

Three things, each of which alone is enough to lose the announcement:

  1. The observer never sees the shadow root. observeDOM observes the
    container with subtree: true, and a MutationObserver does not cross shadow
    boundaries.
  2. A shadow root mutation target resolves to undefined.
    getElementFromNode is isElement(node) ? node : node.parentElement!.
    Rendering into a shadow root replaces the children of the ShadowRoot itself,
    and a ShadowRoot has no parentElement.
  3. The walk up cannot leave the shadow root. getLiveRegionAttributes walks
    up with parentElement only, so the aria-live attribute on the host is
    unreachable from a node inside the shadow root.

Measured while fixing it: even after observing shadow roots, a mutation whose
target is the ShadowRoot still does not reach the announcement, because of (2).

Suggested fix

A PR follows:

  • observeDOM observes shadow roots too, and wraps attachShadow so shadow
    roots attached later are covered as they are created - a shadow root that is
    already populated when observation starts never replays those changes.
  • getElementFromNode maps a ShadowRoot target to its host.
  • the ancestor walk crosses the shadow boundary (assignedSlot ?? parentElement ?? getRootNode().host).

Context

Found while adding screen reader tests for @lion/ui (ing-bank/lion):
<lion-input-iban> renders its invalid message in the shadow root of
lion-validation-feedback, which has aria-live="assertive". NVDA and
VoiceOver announce it; the virtual reader did not, so the tests could not assert
on the message locally.

Minor observation while testing: when a live region produces both an additions
phrase and a text phrase, they are joined as "Error,, Please enter a valid IBAN." - the duplicate comma comes from joining the phrase parts, which is
cosmetic but ends up in spokenPhraseLog.

Activity

  1. cmorten commented on Oct 15, 2026

    @cmorten
    Contributor

    Potential duplicate of #182, though the emphasis here firmly on live region support whereas the prior on the tree (but it does reference the observe behaviours as well)

    Need to review whether #239 might solve this already / what (if any) differences in approaches.

    There is also #183 which the original author closed, but seemed reasonably close at the time.

  2. added theissue type on Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions