Accessibility across a shadow root
ARIA references are ID references and IDs do not cross tree boundaries, so the standard label-and-describe patterns silently stop working — what the element-reflection work fixes, and what remains unsolved today.
The host has a design system and its form fields are labelled properly. Their template writes <label for="confidence">Confidence</label> and their accessibility review passes. You drop the gate in, give your internal input id="confidence", and a screen reader announces it as an unlabelled edit field.
Nothing warned you. The for attribute is not invalid, the id is not duplicated, and every automated checker on the host’s page sees a label sitting next to a control. The association simply does not exist, because the two elements are in different trees. This lesson is about that rule, the half of it that was fixed last year, the half that was not, and what you write down instead of claiming the component is accessible.
One rule, and it is about trees
ARIA relationships are built almost entirely out of ID references: aria-labelledby, aria-describedby, aria-controls, aria-activedescendant, and the HTML for attribute. Every one of them resolves an id, and MDN states the scope rule plainly: ID references are “only in-scope for target elements declared in the same DOM or shadow DOM as the element.”
A shadow root is a separate tree. So an id inside it is invisible to the host, and an id in the host is invisible from inside it. There is no error, no console warning, and no failure any linter can see, because both halves are individually valid markup. The relationship is just absent.
Two directions, and only one of them got fixed
Element reflection is the fix people have heard about, and it is real. It is also directional, and the direction it solves is not the one you will usually need. Keep these apart in your head, because a claim that blurs them is the claim a host’s accessibility specialist will catch.
Outward, shadow to light: solved. The element reflection IDL properties take element references rather than id strings, and MDN records that reflected element properties reach “the same DOM… or a parent DOM.” So a control inside your shadow root can point at a hint paragraph in the host page:
// inside your shadow root
const hint = document.getElementById('gate-hint') // an element in the host page
this.shadowRoot.querySelector('input').ariaDescribedByElements = [hint]MDN records ariaLabelledByElements and its siblings as Baseline 2025, newly available since April 2025 — Chrome 135, Firefox 136, Safari 16.4 (checked 2026-09-03). “Newly available” means all four core engines support it now and nothing more; a host with an older floor does not have it.
Inward, light to shadow: not solved. The same MDN page closes the other direction explicitly: “Elements in other DOMs, including shadow DOMs that are children or peers of the referring DOM, are out of scope.” A host cannot label into your component, by element reference or by id, in any shipping browser:
<!-- does not associate, in any shipping browser -->
<label for="confidence">Confidence</label>
<agent-review-gate></agent-review-gate>
<!-- the input carrying id="confidence" lives inside the gate's shadow root -->That is the direction a component author needs, because the host owns the page, the host owns the surrounding copy, and the host’s design system already has a labelling pattern it applies to every field. Element reflection does not help with any of it.
Where people get burned
The sentence to never write in a README is “element reflection fixes ARIA across the shadow boundary.” It fixes the outward direction. Saying it unqualified will get you agreement in the room and a contradiction in the audit, and the second conversation is much worse than the first.
The proposed fix, and its actual status
There is exactly one proposal on the table for the inward direction: Reference Target, which would let a component nominate an internal element as the target of references made to its host, keeping encapsulation intact. It is a WICG explainer written by the people who want it shipped, and there is no formal specification.
Its implementation status, checked 2026-09-05: Chrome has the most complete implementation, and Firefox and WebKit each have prototype implementations behind flags — dom.shadowdom.referenceTarget.enabled in Firefox, ShadowRootReferenceTargetEnabled in WebKit. By the implementers’ own tracking, the functionality that could not be tested through web platform tests has not been implemented in those two engines yet. That account comes from an Igalia write-up cited by WICG’s own tracking, which is a source adjacent to the proponents rather than an independent one, and it is the best available. WebKit’s standards-position issue for it, opened 29 May 2024, still carries no position label.
Do not design against it. Do not put it in a contract as a mitigation with a date. It is worth watching and it is worth naming to a host team as the reason the gap is architectural rather than sloppy, and that is the whole of what it is good for today.
What you actually do
Three moves, in order of how much of the problem they remove. The first two follow directly from the scope rule; the ordering is this course’s own.
- Keep both ends of every reference in the same tree. Take the label text in as an attribute or property on your host element, then render your own
<label>and your own control together inside the shadow root. Both ends are now in scope for each other and the reference resolves. This removes the problem rather than working around it, and it costs you one attribute in the public API. - Reach outward, never expect inward. When the host has copy you need to reference — a hint, an error summary, a legal line — use the element reflection properties from inside, with the support floor named above. Never publish an id inside your shadow root as something a host may point at, because it will look like it works to whoever writes the markup.
- Do not design a widget whose ARIA relationships span the boundary. A combobox whose listbox is inside your root and whose input is the host’s, anything driven by
aria-activedescendant, anything wherearia-controlshas to cross — these are patterns that the platform does not currently let you build across a shadow root. Choose a shape where the whole relationship lives on one side.
And then write the residue down. The graft contract has an accessibility clause for exactly this: which patterns work, which fail silently, what the host should do instead, and the one thing you cannot offer. A host team that reads that paragraph before installing you does not raise it as a defect later.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Write the accessibility clause of your graft contract in four sentences: what works, what fails silently, what the host does instead, and what you cannot offer.
Check your answer
Something close to this. Inside the gate, labels, descriptions and roles are complete, and every ARIA relationship resolves because both ends live in the same shadow tree. A host <label for> or aria-labelledby that points at an id inside the gate will not associate and will not raise an error, so it must not be used. To supply your own label text, set the label attribute on <agent-review-gate>, and to have the gate reference copy in your page, give it the element itself through the documented property, which takes element references rather than ids and requires Chrome 135, Firefox 136 or Safari 16.4 and above.
The fourth sentence is the one people skip: there is no supported way for your page to reference an element inside the gate, this is a platform gap rather than an implementation choice, the proposal that would close it is not usable in shipping browsers, and the gate is designed so no pattern depends on it. That last clause is what makes the paragraph a disclosure rather than an excuse.
Hands on
Write the gap down, with evidence
Done when: ARTIFACT.md carries an accessibility section for the gate listing every ARIA relationship it needs, each marked works / fails silently / avoided by design, plus one screen-reader transcript from a real host page showing the failing case and the same case after the fix. No entry may be marked “works” on the basis of a devtools inspection alone.
- List every ARIA relationship the gate needs: what labels the confidence control, what describes the irreversibility of approval, what announces the source evidence, what conveys per-field state. Write them as pairs — referring element, referenced element — and mark which tree each end is in.
- Build the failing case on purpose. Put a host
<label for>outside the gate pointing at an id inside it, then run a screen reader — VoiceOver or NVDA, not an automated checker — and record what it announces verbatim. - Run an automated checker over the same page and record that it found nothing. That contrast is the entire argument for why this gap needs a written disclosure: the tooling a host team trusts cannot see it.
- Fix it by moving both ends into your tree. Add a
labelattribute to the host element, render the label inside the shadow root, re-run the screen reader, and record the new announcement beside the old one. - Try the outward direction once so you have used it: put a hint paragraph in the host page and set
ariaDescribedByElementsfrom inside the shadow root. Confirm it announces. Note the browser you used and whether it clears your host’s floor. - Write the four-sentence contract clause into
learning/grafting-ui/ARTIFACT.mdunder the Module 2 accessibility checkpoint. Then read it as if you were the host’s accessibility lead and delete any sentence that would sound like reassurance rather than information. - Bring the clause and both transcripts into the chat. I will look for the word “accessible” used without a qualifier, and for any relationship marked as working that you only checked in devtools.
What this does not cover
This lesson stayed on the relationships the platform scopes per tree. It did not cover the rest of the accessibility work the gate needs regardless of delivery mechanism — roles, keyboard interaction, the announcement of an agent’s confidence, and what a run that failed halfway through should say. The trace component itself, and how an agent’s work is made legible in the first place, belongs to “Making an agent’s work legible”; this course only delivers what that one builds.
The last lesson in this module is the one where the gate stops being hand-written DOM: wrapping an existing React tree as a custom element, and the specific condition under which doing that twice in one page breaks every hook in your component.
Read this next — primary source
Reflected attributesMDN Web Docs — free
This page contains the two sentences the whole lesson turns on: that ID references are in scope only for targets in the same DOM or shadow DOM as the element, and that elements in other DOMs — including shadow DOMs that are children or peers of the referring DOM — are out of scope. Read the full page because it also explains the element-reflection mechanism generally, which is the half of the problem that is solved, and because it names the reflected properties by attribute so you can check whether the one you need is covered. It is the source that lets you say what is broken without guessing.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.