Skip to content

Instantly share code, notes, and snippets.

@alice
Last active February 8, 2023 17:23
Show Gist options
  • Save alice/b535e8334cc7a8c220f8ccc8c35cb6c0 to your computer and use it in GitHub Desktop.
Save alice/b535e8334cc7a8c220f8ccc8c35cb6c0 to your computer and use it in GitHub Desktop.

How Shadow DOM and accessibility are in conflict

Shadow DOM allows web developers to create and use components which encapsulate their internals. Like encapsulation in any other programming context, being able to hide the implementation details of an HTML component has many benefits to both developers using the component in their web pages, and the developers who author and maintain the component.

However, there is one major way that Shadow DOM's encapsulation mechanism is in conflict with techniques authors use to provide an accessible user experience. Shadow DOM encapsulation essentially makes children of a shadow root "private" to any siblings or ancestors of the shadow host. This means that any HTML feature which creates a relationship between elements can't work when a relationship needs to be expressed between an element within a shadow root and one outside of it.

This has some technical reasons; in particular, element IDs are scoped within a shadow root, so a reference from outside of shadow root can't refer to an element with that ID inside a shadow root. However, it's also a logical quandary: if the elements inside the shadow root are implementation details which are intentionally opaque from the point of view of any code outside the component, how can they also be a part of a semantic association mediated by code?

Semantic relationships and accessibility

What does it mean for an element to have a semantic relationship with another element?

Probably the most easily familiar example of a semantic association between one element and another is the relationship between an <input> and a <label>. By either placing the <input> between the opening and closing tags of the <label>, or using the for attribute on the <label> element pointing to the ID of the <input> element, we can express that there is a relationship between the two elements. The <label> provides a label for the <input>, and the <input> is labelled by [1] the <label> .

The association between an <input> and a <label> makes absolutely no difference to how those elements are rendered. If I put a <label> (or even just some text) right next to an <input>, a user who can see both will easily intuit that the label text is associated with the input field. And the label also makes no difference to what data is submitted with the associated <form>.

By creating the semantic association, essentially what we're doing is explicitly expressing in code what is implicitly expressed by the way the elements relate to each other visually.

In the case of <label> and <input>, we do get a nice little bonus in that it makes the entire contents of the <label> a click/tap target for the <input>. So if you're labelling a checkbox, for example, you're making it easier for users of pointer-based devices (i.e. most users) to actually use the checkbox itself.

A less easily appreciated effect of creating a semantic relationship between <label> and <input> is the way it affects the experience of Assistive Technogy (AT) users. Assistive technologies are software and hardware tools which can augment a user experience to meet the needs of people with certain disabilities.

For example, a blind person might use a refreshable braille display which consumes information about an application's user interface via an assistive technology API. The braille display can display a single line of text (sometimes up to 80 characters, but more often 20-40), and the user interface the user is interacting with needs to be expressed in a textual way in order for it to be useful to them.

This is achieved by generating a textual, descriptive representation of a particular UI element. So, for example, if we have the following HTML:

<label for="agree">I agree</label>
<input id="agree" type="checkbox">

- then a braille display might allow a user to interact with it when it has keyboard focus by displaying a braille version of the string I agree (x) tck bx. This concisely expresses three critical things:

  • the check box's label ("I agree"), which gives the user an indication of the upshot of the checkbox being checked or not;
  • the check box's state ("(x)" - i.e. checked); and
  • the check box's role ("tck bx" i.e. "tick box"), which gives the user an idea of what types of interactions are possible (i.e. checking or un-checking, which will indicate whether the user agrees or not).

In this case, the label of the checkbox can reliably be part of the textual representation because of the semantic association between the <label> and the <input type="checkbox">.

Semantic relationships and Shadow DOM

For more complicated custom elements, the types of relationships expressed go beyond a <label>/<input> relationship. In these cases, ARIA attributes provide a larger vocabulary of relationships which can be expressed.

An accessible combobox involves a number of ARIA relationship attributes. It's a good example of the type of complex component that can take a lot of subtle work to get right, making it a good candidate for a reusable component [2].

One way this might look with Shadow DOM is that the <input> might be tucked away inside a shadow root with some logic, extra elements for presentation, and so on, while the autocomplete options might be provided by the author of the page.

<!-- I'll use #shadowRoot and #/shadowroot to denote the beginning and end of 
     the shadow root node, and | (vertical bars) to indicate which nodes are 
     within the shadow root. -->
<custom-autocomplete>
  #shadowRoot
  | <input id="innerInput" role="combobox" aria-autocomplete="list" 
  |        aria-expanded="false" aria-controls="autocompleteOptions">
  | <div role="listbox" id="autocompleteOptions">
  |   <slot>
  |     <!-- author-provided options will be slotted in here -->
  |   </slot>
  | </div>
  #/shadowRoot
  <custom-option id="opt1">Cassowary</custom-option>
  <custom-option id="opt2">Currawong</custom-option>
  <custom-option id="opt3">Emu</custom-option>
  <custom-option id="opt4">Ibis</custom-option>
  <custom-option id="opt5">Magpie</custom-option>
</custom-autocomplete>

From the point of view of the author using the <custom-autocomplete> this is a pretty good deal: they give it a set of <custom-option>s and the logic encapsulated within the component creates an accessible, encapsulated autocomplete widget with those options.

Well, with a few caveats.

Referring from Shadow DOM outwards

When a user is interacting with the autocomplete component, keyboard focus will be on the <input> element, so the user can start typing the name of the option they're interested in. When the user wants to select an option from the list, they may use the arrow keys to quickly select the option they're interested in. The currently selected option will be visually indicated, but keyboard focus remains in the text field so that the user can keep typing and refine the options further.

a screenshot of the Google search box with the text "combobox html" followed by an insertion caret, and autocomplete options beginning "combobox tkinter", "combobox c#", "combobox html", with "combobox html" highlighted

For AT users, we need to ensure that the AT is aware that an option is "active", so that it can display it to the user in the most appropriate way (such as showing its text on the single line of a braille display), without moving keyboard focus off the <input>.

The ARIA attribute which achieves this is aria-activedescendant. Like the <label>'s for', it creates an association between two elements via the ID of the element being referred to; for example:

<!-- this is an extra-simplified example! -->
<input role="combobox" aria-expanded="true" aria-activedescendant="option2">
<ul role="listbox">
    <li role="option" id="option1">Hawksbill</li>
    <li role="option" id="option2">Leatherback</li>
    <li role="option" id="option3">Loggerhead</li>
</ul>

So, in this case, when keyboard focus is on the <input>, the AT will place its "virtual cursor" [3] on the second option, showing the user that option in the appropriate manner while allowing the user to continue typing.

If you're familiar with Shadow DOM, or you read the introduction to this article carefully, you can probably see where this falls apart with the Shadow DOM example above:

<custom-autocomplete>
  #shadowRoot
  | <input id="innerInput" role="combobox" aria-autocomplete="list" 
  |        aria-expanded="true" aria-controls="autocompleteOptions"
  |        aria-activedescendant="?????">
  | <div role="listbox" id="autocompleteOptions">
  |   <slot>
  |     <!-- author-provided options will be slotted in here -->
  |   </slot>
  | </div>
  #/shadowRoot
  <custom-option id="opt1">Cassowary</custom-option>
  <custom-option id="opt2">Currawong</custom-option>
  <custom-option id="opt3">Emu</custom-option>
  <custom-option id="opt4">Ibis</custom-option>
  <custom-option id="opt5">Magpie</custom-option>
</custom-autocomplete>

In this case, even though the <custom-option> is outside the shadow root and thus not part of any encapsulation guarantees, those very encapsulation guarantees prevent the <input> from referring to any of the <custom-option>s, because the shadow root creates a new tree. Because of this, the IDs of the <custom-option>s aren't "visible" from within the shadow root, so there's no value for the aria-activedescendant attribute that could refer to one of them.

There is one option available (not yet universally) to the author of the <custom-autocomplete>: using the ariaActiveDescendantElement IDL attribute:

innerInput.ariaActiveDescendantElement = slot.assignedNodes()[2];

This works where the ID fails, because an IDL attribute with type Element can refer to any element that is a descendant of any "shadow-including ancestor" of the element hosting the attribute.

If you haven't had the misfortune of becoming familiar with the way the HTML spec expresses tree-related concepts, what this means in this example is:

  • the <custom-autocomplete> element is a shadow-including ancestor of the <input>, even though it's not considered an ancestor because the shadow root is considered the root of its own tree;
  • the <custom-option>s are all descendants of the <custom-autocomplete> (while the <input> is only a shadow-including descendant);
  • so, the <custom-options> may be referred to from the <input> (but not the reverse).

So, while this doesn't yet work everywhere, it offers at least a partial solution to this one part of the problem.

Referring into Shadow DOM

The above example "works" because the <custom-option>s are in the shadow root's "light tree". But what if the <custom-autocomplete> needed to use an option list which was in another shadow root? For example, the option list might encapsulate logic to fetch autocomplete options from a server, or to use a recycler pattern to manage list items.

That might look something like this [4]:

<custom-address-autocomplete>
  #shadowRoot
  | <input id="innerInput" role="combobox" aria-autocomplete="list" 
  |        aria-expanded="true" aria-controls="autocompleteOptions"
  |        aria-activedescendant="?????">
  | <custom-recycler role="listbox" id="autocompleteOptions">
  |   #shadowRoot
  |   | <custom-option id="opt3">221B Baker St</custom-option>
  |   | <custom-option id="opt1">29 Acacia Road</custom-option>
  |   | <custom-option id="opt2">724 Evergreen Terrace</custom-option>
  |   #/shadowRoot
  | </custom-recycler>
  #/shadowRoot
</custom-address-autocomplete>

In this example, there is no way to create an aria-activedescendant association from the <input> to one of the <custom-option>s. Unlike in the previous example, the <custom-option>s aren't descendants of a shadow-including ancestor of the <input>, so we can't even use the ariaActiveDescendantElement IDL attribute. This was a deliberate choice in the design of the reflection API: if authors made a reference to an element inside a shadow root available on an element outside of it, it essentially makes the encapsulation moot, particularly in the case of a closed shadow root.

The <custom-option>s are effectively an implementation detail of the <custom-recycler>, so logically it makes sense for them to be hidden or private from the <input>. However, they are only an implementation detail as long as you don't need to express a semantic relationship with one of them in code.

To put it another way: the contents of the shadow root is private to its light tree, but not to users. If a user can perceive a relationship between elements in the light tree and the shadow tree, but the author can't express that relationship in code, then the encapsulation provided by Shadow DOM is at odds with the semantics of the page, and so at odds with accessibility. This is a conundrum for Shadow DOM.

Squaring the circle: prior and current work on expressing relationships across shadow roots

There have been numerous attempts to try to address this glaring gap in Shadow DOM's capabilities.

Element IDL attribute reflection

As described above, allowing IDL attributes with type Element to express relationships from elements within Shadow DOM to elements in the light tree. At the time of writing, this is implemented in WebKit (available in Safari Technology Preview) and shipping in Blink-based browsers.

Element IDL attribute reflection to allow referring into open shadow roots

Nolan Lawson wrote a detailed explainer and proposal that Element IDL references should be permitted to refer into open shadow roots.

This idea has been floated several times, and the push-back tends to be along the lines that even the relatively weaker encapsulation guarantees of open shadow roots would be violated by an API of this form. For example, it would allow a non-Shadow DOM aware script to accidentally traverse into a shadow root's children.

That this proposal keeps being arrived at independently, and in particular that it's now being suggested by developers actively working with Shadow DOM, says something about the appeal - it would be straightforward to spec and implement, and would would be as easy to use as the existing IDL attributes.

It might even be possible to mitigate the encapsulation issues, potentially via something like retargeting as is done for event targets.

Even if that could be done, there are still two major downsides with this proposal:

  • it would leave developers using components with closed shadow roots still lacking any option to create these associations;
  • it's not (yet) compatible with Declarative Shadow DOM.

::part()?

CSS ::parts seem like a very closely related solution: the problem they're solving was that developers wanted to interact with (in this case, style) elements within shadow roots. ::part does this in an encapsulation-preserving way, because ::parts can only be targeted by CSS styles, not by querySelector() which can only return (light tree) descendants of the parent node.

However, ::parts can't be used directly for IDREF attributes without a major change to how IDREF attributes and indeed ::parts work, since IDREF attributes are explicitly based on the id attribute of the related element, and ::parts are only valid for matching CSS selectors, not HTML attributes.

These may be surmountable issues, but they wouldn't be easy to address; it's probably easier to find new solutions inspired by ::part instead.

Explicit import/export of IDs to/from shadow roots

There was an attempt to design an API based on exportparts, which would allow the import/export of IDs to/from shadow roots.

This had a few issues with being confusing and arduous to use, most particularly that unlike exportparts, the attributes in question would be on the shadow host, meaning that they needed to be added by the author using the custom element, since they would need to ensure that each exported/imported ID was unique in the scope it was exported/imported to.

This means it would be labour-intensive for authors to use, but also that unlike ::part it doesn't have an opt-in step from the custom element; rather, any element with an ID may have its ID exported or imported using this API, regardless of the intended use or audience for the ID. Essentially, it implies that any ID becomes part of the public API for the shadow root.

Cross-root ARIA (and more?) delegation/reflection

These complementary proposals (Cross-root ARIA Delegation and Cross-root ARIA Reflection) add attributes which allow a custom element:

  • to "delegate" ARIA attributes from a shadow host to a single element (per attribute) with the shadow root, and
  • to "reflect" (export) a particular element (or several?) within a shadow root as the target of a relationship from an element outside the shadow root.

Cross-root ARIA delegation

Taking inspiration from delegatesFocus, cross-root ARIA delegation would allow a shadow root to declare that it "delegates" certain attributes from the shadow host to specific elements within the shadow root.

Taking a modified example from the explainer which uses declarative shadow DOM as an illustration:

<span id="description">Description</span>
<x-input aria-label="Name" aria-describedby="description">
  <template shadowroot="closed" 
            shadowrootdelegatesariaattributes="aria-label aria-describedby">
    <input id="input" 
           delegatedariaattributes="aria-label aria-describedby" />
    <button delegatedariaattributes="aria-label">Another target</button>
  </template>
</x-input>

Note: the API proposed includes both imperative and declarative versions, but the declarative version makes for a more concise example.

This example would result in both the <input> and the <button> having the aria-label from the shadow host ("Name") applied, and the <input> having an aria-describedby relationship with the span adjacent to the <x-input>.

This means that an author using the <x-input> element can simply apply ARIA attributes to the shadow host exactly as they would for a regular <input> element, and have them work as expected. Furthermore, like delegatesFocus, this proposal would work recursively: any number of levels of shadow roots could delegate attributes from a shadow host to the desired target for those attributes.

As shown in the example above, these relationships are intended serialisable using declarative shadow DOM, via attributes on <template>.

Furthermore, this proposal means that author attributes on custom elements can be respected without the author needing to know anything about the custom element internals - as long as the custom element author has correctly predicted what attributes should apply to which internal elements.

Cross-root ARIA reflection

Cross-root ARIA reflection is complementary to cross-root ARIA delegation. Essentially, it allows a custom element author to "export" elements inside of a shadow root to be available as a target for relationship attributes - much like a ::part.

Modifying an example from the explainer, once again using declarative shadow DOM:

<input aria-controls="options" aria-activedescendent="options">
<x-optlist id="options">
  <template shadowroot="open" 
            shadowrootreflectsariacontrols 
            shadowrootreflectsariaactivedescendent>
    <ul reflectariacontrols>
      <x-option id="opt1">221B Baker St</x-option>
      <x-option id="opt2" reflectariaactivedescendant>29 Acacia Road</x-option>
      <x-option id="opt3">724 Evergreen Terrace</x-option>
    </ul>
  </template>
</x-foo>

The <input> ends up with an aria-controls relationship with the <ul>, and an aria-activedescendant relationship with the second <li>.

This is complementary to cross-root ARIA delegation in that it allows the creation of relationships in the opposite direction: from outside a shadow root to inside.

Limitations of these APIs

Bottleneck effect

The main limitation of these APIs is that they can only allow expressing relationships to one element or set of elements for each attribute in each direction. Effectively, it makes the shadow root a bottleneck for these types of relationships.

For example, a shadow host which has an aria-describedby value can create a relationship from any number of elements within its shadow root (and their shadow roots, recursively) to a single list of elements in its tree. If there are multiple elements within its shadow root which all need different cross-root aria-describedby values, these APIs can't achieve that.

Similarly, a shadow host may reflect an element (or several) as a target for a particular attribute, but any element referring to the shadow host for that relationship will create a relationship with the same element(s).

<custom-address id="address">
  #shadowRoot
  | <div>
  |   <slot name="labelforstreet"><label slot="labelforstreet" for="?????">Street</label> 
  |   </slot>
  |   <input id="street" aria-describedby="?????">
  |   <slot name="descriptionforstreet"><span slot="descriptionforstreet" id="streetdescription">
          The street address
        </span>
  |   </slot>
  | </div>
  | <div>
  |   <slot name="labelforsuburb"><label slot="labelforsuburb" for="?????">Suburb</label>
  |   </slot>
  |   <input id="suburb" aria-describedby="?????">
  |   <slot name="descriptionforsuburb"><span slot="descriptionforstreet" id="suburbdescription">
          The suburb
        </span>
  |   </slot>
  | </div>
  #/shadowRoot
  <label slot="labelforstreet" for="?????">Street</label>
  <span slot="descriptionforstreet" id="streetdescription">The street address</span>
  <label slot="labelforsuburb" for="?????">Suburb</label>
  <span slot="descriptionforstreet" id="suburbdescription">The suburb</span>
</custom-address>

In this extremely contrived and unlikely example, the two <input>s inside the shadow root each want different <label>s to be able to refer to them, and want to refer to different light tree elements using aria-describedby. Since the delegation/reflection APIs only allow one target or set of targets for each attribute in each direction (inwards or outwards), this could not work with these APIs.

However, this may not be a huge issue in practice, since custom elements tend to act somewhat "atomically" by design.

Proliferation of IDL attributes

Depending on the exact shape of the API, it may result in the addition of multiple IDL attributes per ARIA attribute to certain objects.

This may be fairly straightforwardly avoided, however. The proposal sketched for cross-root ARIA delegation uses token list attributes [5], for example, which means only one attribute needs to be added (plus the extra attribute on <template>/ShadowRoot). Similarly, the proposed exportfor attribute seems to be an analogous version of reflectaria* which takes a token list.

What needs to happen next?

There is an increasing sense of urgency about these issues - arguably one that's been missing since the inception of Shadow DOM - since Shadow DOM is hovering around a critical adoption threshold. Collectively, we've been arguing for almost a decade about what the optimal solution might look like, with only modest progress in the form of an API that is only now implemented in two browser engines, and not yet shipping in any.

We have a number of promising proposals as to how to solve these problems, but there is still a lot of work to be done to get us to the point where we actually have solutions shipping and able to be used. We need to investigate how well these proposals follow web standard design principles, how feasible they are to ship in browsers, and how well these proposals fit the problems actually being experienced by custom element authors. We need to prototype proposals which meet these requirements, to get them in the hands of developers who can test them in the context of their actual code. And, we need to navigate the standards process and ensure that we get to the stage of having as many browser engines as possible shipping whatever APIs will meet the needs of both developers and users.


[1] Since I'm writing this as an Australian, this is the natural way for me to spell "labelled", but it's also the way the corresponding ARIA attribute is spelled - much to the annoyance of anyone used to American spellings.

[2] The examples shown here, and in the ARIA Authoring Practices Guide, are not production-ready code, which often takes into account extra considerations over and above those discussed here.

[3] The virtual cursor typically follows keyboard focus, but the user may move it themselves in order to consume non-interactive content, or the AT may move it in cases like this.

[4] Thank you to Westbrook Johnson for patiently explaining this use case to me.

[5] <template shadworootdelegatesariaattributes="aria-labelledby, aria-describedby"> rather than <template shadowrootdelegatearialabelledby shadowrootdelegatesariadescribedby>

@nolanlawson
Copy link

@alice This is a fantastic writeup. Thank you so much for doing this!

Some thoughts:

it would leave developers using components with closed shadow roots still lacking any option to create these associations

I still think my proposal is a decent one, even though you're right that it doesn't solve the declarative shadow DOM use case. It may be a useful stopgap, though, if browser vendors can be convinced.

Last I checked the Chrome data, open shadow DOM has ~3x the usage of closed shadow DOM, and every web component framework I'm aware of defaults to open mode. As far as I can tell, closed mode is mostly used by third-party libraries that truly want to isolate their DOM from the rest of the page, but without the overhead of an iframe.

In this extremely contrived and unlikely example, the two <input>s inside the shadow root each want different <label>s to be able to refer to them

This is a good call-out. Given the extreme levels of composition I've seen in our (Salesforce's) usage of web components (or heck, take any non-web-component framework like React – they love wrapper components too), I would expect this pattern to pop up pretty frequently.

For instance, a datepicker component might contain two <input>s – one for the date and another for the time – but would expect the consumer to label them independently. (We are actually wrangling with exactly this problem – see the date-aria-labelledby and time-aria-labelledby attributes in our <lightning-input> component. Note we can cheat only because we use a shadow DOM polyfill currently.)

Proliferation of IDL attributes

IIUC, the concern is that elements will have to explicitly enumerate every possible ARIA value they want to reflect/delegate? In that case, I agree that having some kind of "reflect aria*" / "delegate aria*" shorthand makes sense.

@alice
Copy link
Author

alice commented Feb 8, 2023

I still think my proposal is a decent one, even though you're right that it doesn't solve the declarative shadow DOM use case. It may be a useful stopgap, though, if browser vendors can be convinced.

Yeah, the more I think about it the more I agree (and in fact this was one of the key use cases we'd hoped to enable with the element reflection API to begin with). I'm not sure whether the vague ideas I describe here around preserving encapsulation have been floated before in this context - there was some discussion of providing this type of API for the exportids version.

I certainly think we need to solve the rest of the use cases (closed shadow roots, declarative API, honouring author-provided attributes easily) but as you say this would solve an immediate and crucial user need in the interim (and, indeed, allow experimental polyfills of solutions for the other problems).

For instance, a datepicker component might contain two <input>s – one for the date and another for the time – but would expect the consumer to label them independently. (We are actually wrangling with exactly this problem – see the date-aria-labelledby and time-aria-labelledby attributes in our <lightning-input> component. Note we can cheat only because we use a shadow DOM polyfill currently.)

I'm a little confused - isn't this manageable with the (not yet shipping) element reflection API, which allows references to "point" outwards? But in any case, it wouldn't work for <label> relationships which need to go in the opposite direction.

@smhigley has also mentioned instances she's seen which are kind of the inverse - a component which has some sub-component which should act as a label for something outside the component, but without affecting the name computation of the component as a whole.

It's such a tricky problem; like the aria-activedescendant issue, you need to simultaneously think of the component as an "atom" and a collection of sub-components.

@nolanlawson
Copy link

I'm a little confused - isn't this manageable with the (not yet shipping) element reflection API

Good catch! Yes, and this is actually exactly what we plan to use to migrate this component from our shadow DOM polyfill to native shadow DOM. But it's pure luck that it works – it just happens that the component was designed in such a way that the IDREF-targeted element must be in the same document/shadowroot as the shadow host. You can imagine a scenario where the targeted element is in a sibling shadow root instead, in which case element reflection would not work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment