Shadow DOM and Cross-Root ARIA References
Permalink to "Shadow DOM and Cross-Root ARIA References"ARIA’s relationship attributes — aria-labelledby, aria-describedby, aria-controls, aria-owns, aria-activedescendant — work by ID reference. IDs are scoped to a document or a shadow root, so a reference from inside a web component’s shadow DOM to an element outside it (or the other way round) silently fails: the accessibility tree shows no name, no description, no active descendant. For data grids built as web components, this breaks common patterns — a grid labelled by a page heading, a header cell described by help text, a combobox filter whose listbox lives in another component.
This guide belongs to web components & framework-agnostic grids.
Spec reference
Permalink to "Spec reference"- IDREF scoping — ID references resolve within the same tree (document or shadow root).
ElementInternals— lets custom elements set default ARIA role and properties (internals.role,internals.ariaLabel), and, in supporting browsers, element reflection properties (ariaLabelledByElements) that can reference elements across some boundaries.- ARIA element reflection —
element.ariaLabelledByElements = [el]sets references by element rather than ID; cross-root behaviour is limited (references from shadow to light DOM ancestors are allowed in some implementations; the reverse is not). - Reference target proposal — a proposed
referenceTargetfor shadow roots to let external IDREFs target an inner element; check current browser support before relying on it. - Relevant criteria: SC 4.1.2, 1.3.1 (A).
When to use — and when not to
Permalink to "When to use — and when not to"Audit relationships whenever you build or adopt a web component that participates in labelling, describing or composite focus — grids, comboboxes, menus, tabs. Design components so the relationships they need live inside them, or accept the related content through slots.
Do not try to make every external relationship work across roots with clever tricks; they are fragile and vary by browser. The misapplication: a <data-grid> component whose shadow root contains role="grid" aria-labelledby="invoices-heading", pointing at an h2 in the page. The grid has no name in any browser.
Annotated code example
Permalink to "Annotated code example"// Broken: IDREF crosses the shadow boundary
class DataGrid extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML =
`<div role="grid" aria-labelledby="invoices-heading">…</div>`; } // resolves inside the shadow root only: no name
}
// Working: accept the label as a property and keep the reference internal (SC 4.1.2)
class DataGrid2 extends HTMLElement {
static observedAttributes = ['label'];
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<span id="lbl" hidden></span>
<div role="grid" aria-labelledby="lbl"><slot name="caption"></slot>…</div>`;
}
attributeChangedCallback(name, _, value) {
if (name === 'label') this.shadowRoot.getElementById('lbl').textContent = value; // same-root reference
}
}
customElements.define('data-grid', DataGrid2);
<!-- Page usage: pass the visible heading text as the label, or slot a caption -->
<h2 id="invoices-heading">Invoices</h2>
<data-grid label="Invoices">
<span slot="caption">Invoices, filtered to overdue</span> <!-- slotted content is part of the flattened tree -->
</data-grid>
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Relationship | Across roots | Same root | What users hear when broken |
|---|---|---|---|
| aria-labelledby (grid name) | Fails | Works | “grid” with no name |
| aria-describedby (column help) | Fails | Works | No description |
| aria-controls (filter → grid) | Fails | Works | Usually no audible change |
| aria-activedescendant (combobox → option) | Fails | Works | Options not announced while arrowing |
| Slotted text in labelling element | Works (flattened) | — | Name includes slotted text |
Integration context
Permalink to "Integration context"Custom element semantics via ElementInternals are covered in ElementInternals for custom grid elements. Slotting table content is discussed in slotted content and table semantics. The underlying naming rules are in labelling grids with aria-labelledby.
Gotchas
Permalink to "Gotchas"Closed shadow roots. Testing tools cannot inspect closed roots easily; prefer open roots for components that need accessibility testing.
Nested components. A grid component containing cell components, each with its own shadow root, multiplies boundaries; header-to-cell associations can break. Keep the table structure in one root where possible.
Framework wrappers. React or Vue wrappers around web components may pass aria-* attributes to the host element, where they do nothing for the inner grid. Map them to properties the component understands.
Worked example
Permalink to "Worked example"A design system’s <ds-combobox> filter rendered its input in one component and its listbox in a separate <ds-popover> component, each with its own shadow root. The input’s aria-controls and aria-activedescendant pointed at the listbox’s options by ID. In every browser, arrowing through options announced nothing. Moving the listbox into the combobox’s own shadow root (and using the top layer for positioning) restored option announcements immediately; the popover component remained for non-composite overlays.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Inspect the grid in Chrome DevTools’ accessibility pane; confirm it has a name.
- Check any described headers for descriptions.
- With NVDA, Tab into the grid; listen for the name.
- For comboboxes, arrow through options and listen for each option being announced.
- Repeat in Firefox and Safari, since cross-root behaviour can differ.
Common mistakes
Permalink to "Common mistakes"- IDREFs from shadow DOM to page elements.
- Splitting a combobox input and its listbox into different components.
- Assuming
aria-*attributes on the host affect inner elements. - Not testing in more than one browser.
Adoption checklist for teams
Permalink to "Adoption checklist for teams"- Inventory every IDREF attribute in your components.
- Redesign components so relationships stay internal, or accept content via slots.
- Offer
labelanddescriptionproperties for names that come from outside. - Document which ARIA attributes on the host element are supported and mapped internally.
- Add tests that read computed names from the accessibility tree.
FAQ
Permalink to "FAQ"Can aria-labelledby point from light DOM into shadow DOM?
No; IDs inside a shadow root are not visible from outside.
Does element reflection solve cross-root references?
Partly. It allows references to elements in ancestor scopes in some cases, but not arbitrary cross-root references. Test before relying on it.
What about referenceTarget?
It is a proposal designed to address this; check current support and fall back to same-root designs.
Do slots preserve labelling?
Slotted content participates in the flattened tree, so text slotted into a labelling element contributes to the name.
Is this a reason to avoid web components for grids?
No, but grids are a hard case. Keep the whole grid in one shadow root and design inputs for external names.
How do frameworks handle this?
Frameworks that render light DOM avoid the problem; web components with shadow DOM must design around it.
Can I use aria-label on the host?
Only if the host itself is the element that needs the name (via ElementInternals role). Otherwise map it to the inner element.
Related
Permalink to "Related"- ElementInternals for custom grids — semantics on custom elements
- Slotted content and table semantics — slots and tables
- Labelling grids with aria-labelledby — naming basics