Shadow DOM is a kind of retcon for the web. As I’ve written in the past, shadow DOM upends a lot of developer expectations and invalidates many tried-and-true techniques that worked fine in the pre-shadow DOM world. One potentially surprising example is ARIA.
Quick recap: shadow DOM allows you to isolate parts of your DOM into encapsulated chunks – typically one per component. Meanwhile, ARIA is an accessibility primitive, which defines attributes like aria-labelledby
and aria-describeddby
that can reference other elements by their IDs.
Do you see the problem yet? If not, I don’t blame you ‒ this is a tricky intersection of various web technologies. Unfortunately though, if you want to use shadow DOM without breaking accessibility, then this is one of the things you will have to grapple with. So let’s dive in.
Sketch of the problem
In shadow DOM, element IDs are scoped to their shadow root. For instance, here are two components:
<custom-label> #shadow-root <label id="foo">Hello world</label> </custom-label> <custom-input> #shadow-root <input type="text" id="foo"> </custom-input>
In this case, the two elements have the same ID of "foo"
. And yet, this is perfectly valid in the world of shadow DOM. If I do:
document.getElementById('foo')
…it will actually return null
, because these IDs are not globally scoped – they are locally scoped to the shadow root of each component:
document.querySelector('custom-label') .shadowRoot.getElementById('foo') // returns the <label> document.querySelector('custom-input') .shadowRoot.getElementById('foo') // returns the <input>
So far, so good. But now, what if I want to use aria-labelledby
to connect the two elements? I could try this:
<!-- NOTE: THIS DOES NOT WORK --> <custom-input> #shadow-root <input type="text" aria-labelledby="foo"> </custom-input>
Why does this fail? Well, because the "foo"
ID is locally scoped. This means that the <input>
cannot reach outside its shadow DOM to reference the <label>
from the other component. (Feel free to try this example in any browser or screen reader – it will not work!)
So how can we solve this problem?
Solution 1: janky workarounds
The first thing you might reach for is a janky workaround. For instance, you could simply copy the text content from the <label>
and slam it into the <input>
, replacing aria-labelledby
with aria-label
:
<custom-input> #shadow-root <input type="text" aria-label="Hello world"> </custom-input>
Now, though, you’ve introduced several problems:
- You need to set up a
MutationObserver
or similar technique to observe whenever the<label>
changes. - You need to accurately calculate the accessible name of the
<label>
, and many off-the-shelf JavaScript libraries do not themselves support shadow DOM. So you have to hope that the contents of the<label>
are simple enough for the calculation to work. - This works for
aria-labelledby
because of the correspondingaria-label
, but it doesn’t work for other attributes likearia-controls
,aria-activedescendant
, oraria-describedby
. (Yes there isaria-description
, but it doesn’t have full browser support.)
Another workaround is to avoid using the <input>
directly, and to instead expose semantics on the custom element itself. For instance:
<custom-input role="textbox" contenteditable="true" aria-labelledby="foo" ></custom-input> <custom-label id="foo"></custom-label>
(ElementInternals
, once it’s supported in all browsers, could also help here.)
At this point, though, you’re basically building everything from scratch out of <div>
s, including styles, keyboard events, and ARIA states. (Imagine doing this for a radio button, with all of its various keyboard interactions.) And plus, it wouldn’t work with any kind of nesting – forget about having any wrapper components with their own shadow roots.
I’ve also experimented with even jankier workarounds that involve copying entire DOM trees around between shadow roots. It kinda works, but it introduces a lot of footguns, and we’re already well on our way to wild contortions just to replace a simple aria-labelledby
attribute. So let’s explore some better techniques.
Solution 2: ARIA reflection
As it turns out, some smart folks at browser vendors and standards bodies have been hard at work on this problem for a while. A lot of this effort is captured in the Accessibility Object Model (AOM) specification.
And thanks to AOM, we have a (partial) solution by way of IDREF attribute reflection. If that sounds like gibberish, let me explain what it means.
In ARIA, there are a bunch of attributes that refer to other elements by their IDs (i.e. “IDREFs”). These are:
aria-activedescendant
aria-controls
aria-describedby
aria-details
aria-errormessage
aria-flowto
aria-labelledby
aria-owns
Historically, you could only use these as HTML attributes. But that carries with it the problem of shadow DOM and ID scoping.
So to solve that, we now have the concept of the ARIA mixin, which basically states that for every aria-*
attribute, there is a corresponding aria*
property on DOM elements, available via JavaScript. In the case of the IDREF attributes above, these would be:
ariaActiveDescendantElement
ariaControlsElements
ariaDescribedByElements
ariaDetailsElements
ariaErrorMessageElement
ariaFlowToElements
ariaLabelledByElements
ariaOwnsElements
This means that instead of:
input.setAttribute('aria-labelledby', 'foo')
… you can now do:
input.ariaLabelledByElements = [label]
… where label
is the actual <label>
element. Note that we don’t have to deal with the ID ("foo"
) at all, so there is no more issue with IDs being scoped to shadow roots. (Also note it accepts an array, because you can actually have multiple labels.)
Now, this spec is very new (the change was only merged in June 2022), so for now, these properties are not supported in all browsers. The patches have just started to land in WebKit and Chromium. (Work has not yet begun in Firefox.) As of today, these can only be used in WebKit Nightly and Chrome Canary (with the “experimental web platform features” flag turned on). So if you’re hoping to ship it into production tomorrow: sorry, it’s not ready yet.
The even more unfortunate news, though, is that this spec does not fully solve the issue. As it turns out, you cannot just link any two elements you want – you can only link elements where the containing shadow roots are in an ancestor-descendant relationship (and the relationship can only go in one direction). In other words:
element1.ariaLabelledByElements = [element2]
In the above example, if one of the following is not true, then the linkage will not work and the browser will treat it as a no-op:
element2
is in the same shadow root aselement1
element2
is in a parent, grandparent, or ancestor shadow root ofelement1
This restriction may seem confusing, but the intention is to avoid accidental leakage, especially in the case of closed shadow DOM. ariaLabelledByElements
is a setter, but it’s also a getter, and that means that anyone with access to element1
can get access to element2
. Now normally, you can freely traverse up the tree in shadow DOM, even if you can’t traverse down – which means that, even with closed shadow roots, an element can always access anything in its ancestor hierarchy. So the goal of this restriction is to prevent you from leaking anything that wasn’t already leaked.
Another problem with this spec is that it doesn’t work with declarative shadow DOM, i.e. server-side rendering (SSR). So your elements will remain inaccessible until you can use JavaScript to wire them up. (Which, for many people, is a dealbreaker.)
Solution 3: cross-root ARIA
The above solutions are what work today, at least in terms of the latest HTML spec and bleeding-edge versions of browsers. Since the problem is not fully solved, though, there is still active work being done in this space. The most promising spec right now is cross-root ARIA (originally authored by my colleague Leo Balter), which defines a fully-flexible and SSR-compatible API for linking any two elements you want, regardless of their shadow relationships.
The spec is rapidly changing, but here is a sketch of how the proposal looks today:
<!-- NOTE: DRAFT SYNTAX --> <custom-label id="foo"> <template shadowroot="open" shadowrootreflectsariaattributes="aria-labelledby"> <label reflectedariaattributes="aria-labelledby"> Hello world </label> </template> </custom-label> <custom-input aria-labelledby="foo"> <template shadowroot="open" shadowrootdelegatesariaattributes="aria-labelledby"> <input type="text" delegatedariaattributes="aria-labelledby"> </template> </custom-input>
A few things to notice:
- The spec works with Declarative Shadow DOM (hence I’ve used that format to illustrate).
- There are no restrictions on the relationship between elements.
- ARIA attributes can be exported (or “delegated”) out of shadow roots, as well as imported (or “reflected”) into shadow roots.
This gives web authors full flexibility to wire up elements however they like, regardless of shadow boundaries, and without requiring JavaScript. (Hooray!)
This spec is still in its early days, and doesn’t have any browser implementations yet. However, for those of us using web components and shadow DOM, it’s vitally important. Westbrook Johnson put it succinctly in this year’s Web Components Community Group meeting at TPAC:
“Accessibility with shadow roots is broken.”
Given all the problems I’ve outlined above, it’s hard for me to quibble with this statement.
What works today?
With the specs still landing in browsers or still being drafted, the situation can seem dire. It’s hard for me to give a simple “just use this API” recommendation.
So what is a web developer with a deadline to do? Well, for now, you have a few options:
- Don’t use shadow DOM. (Many developers have come to this conclusion!)
- Use elaborate workarounds, as described above.
- If you’re building something sophisticated that relies on several
aria-*
attributes, such as a combobox, then try to selectively use light DOM in cases where you can’t reach across shadow boundaries. (I.e. put the whole combobox in a single shadow root – don’t break it up into multiple shadow roots.) - Use an ARIA live region instead of IDREFs. (This is the same technique used by canvas-based applications, such as Google Docs.) This option is pretty heavy-handed, but I suppose you could use it as a last resort.
Unfortunately there’s no one-size-fits-all solution. Depending on how you’ve architected your web components, one or multiple of the above options may work for you.
Conclusion
I’m hoping this situation will eventually improve. Despite all its flaws, I actually kind of like shadow DOM (although maybe it’s a kind of Stockholm syndrome), and I would like to be able to use it without worrying about accessibility.
For that reason, I’ve been somewhat involved recently with the AOM working group. It helps that my employer (Salesforce) has been working with Igalia to spec and implement this stuff as well. (It also helps that Manuel Rego Casasnovas is a beast who is capable of whipping up specs as well as patches to both WebKit and Chromium with what seems like relative ease.)
If you’re interested in this space, and would like to see it improve, I would recommend taking a look at the cross-root ARIA spec on GitHub and providing feedback. Or, make your voice heard in the Interop 2022 effort – where web components actually took the top spot in terms of web developer desire for more consistency across browsers.
The web is always improving, but it improves faster if web developers communicate their challenges, frustrations, and workarounds back to browser vendors and spec authors. That’s one of my goals with this blog post. So even if it didn’t solve every issue you have with shadow DOM and accessibility (other than maybe to scare you away from shadow DOM forever!), I hope that this post was helpful and informative.
Thanks to Manuel Rego Casasnovas and Westbrook Johnson for feedback on a draft of this blog post.