Cory Rylan

My name is , Google Developer Expert, Speaker, Software Developer. Building Design Systems and Web Components.

Follow @coryrylan
Web Components

Accessibility with ID Referencing and Shadow DOM

Cory Rylan

- 4 minutes

Often when working with Web Components, I hear a statement along the lines of "The Shadow DOM isn't Accessible". This statement is usually is not the case but rather a misunderstanding of some of the Shadow DOM API behavior and Web Components. While rough spots exist with making Web Components Accessible, Web Components can be fully accessible.

This post will focus on the most common mistake when making accessible (a11y) components, id, and label associations. When making a Web Component, we typically use Shadow DOM. Shadow DOM enables our styles to be scoped to only our component and global styles, not override our component styles. This scoping provides improved maintenance of our CSS. Because of this encapsulation, you can use the same id value within the same document. Shadow DOM will scope an id attribute to the Shadow DOM it was declared within. In contrast, light DOM (global) id attributes must be unique to the page.

This concept of id attributes scoped to a Shadow DOM or Component can trip up specific patterns for a11y functionality. Let's start with a simple example using a label and input.

  <label for="input">one</label>
  <input id="input" />

When we associate a label to an input, it enables the label to be clicked and focus the input. The label is essential to users who have vision impairments that may need to use a screen reader. Screen reader software will read aloud the label that describes the input if the id and for attributes are correctly associated.

Shadow DOM and Referencing IDs

If we were to make an input Web Component, we need to consider the a11y behavior when associating id attributes to aria attributes or other elements. Let's look at a simple Web Component example.

const template = document.createElement('template');
template.innerHTML = `
  <label for="input">input</label>
  <input id="input" />
`;
class UIInputAttr extends HTMLElement {
  static get observedAttributes() {
    return ['label'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }

  attributeChangedCallback(attrName, oldValue, newValue) {
    if (attrName === 'label') {
      this.shadowRoot.querySelector('label').innerText = newValue;
    }
  }
}

customElements.define('ui-input-attr', UIInputAttr);

In this Web Component, we have a label and input wrapped in our component. The component accepts the label text as an attribute.

<ui-input-attr label="two"></ui-input-attr>

The attribute will assign the "two" text to the label text. Notice the input id is input similar to our global label and input. Because of Shadow DOM, the id is scoped and encapsulated to our component. This means we no longer have id collisions if two ids have the same name between components. This is a helpful feature to help ensure CSS selectors don't override other elements unintentionally.

Content Slot

We can also associate labels when we use the Content Slot API. The Content Slot API allows us to project content into the template of a Web Component. This is useful for container-style components like cards and modals.

const template = document.createElement('template');
template.innerHTML = `
  <div>
    <slot></slot>
  </div>
`;
class UIInputSlot extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}
 
customElements.define('ui-input-slot', UIInputSlot);

We create a slot element that specifies where to render our projected content.

  <ui-input-slot>
    <label for="three">three - projected slot</label>
    <input id="three" />
  </ui-input-slot>

When projecting content, the content remains in the light DOM while rendered to the Shadow DOM template. Because our label and input exist together outside the Shadow DOM, they remain associated with the id and for attributes. However, where things break down is when we attempt to associate across the Shadow DOM boundary.

Breaking a11y with Shadow DOM

When associating elements and working with Web Components, there are no issues with associating elements as long as they both are within the light DOM or within the same Web Component's Shadow DOM template. In this next example, we will see how this can break down and hurt a11y.

<label for="four">four - broken cross boundary</label>
<ui-input-broken id="four"></ui-input-broken>

In this example, if we were to wrap an input in our Web Component but want to associate a label to the element, we would break the association. First, the id in this template will refer to the ui-input-broken, not the input contained within. We could change the attribute not to use id and pass it into the inner input id.

<label for="four">four - broken cross boundary</label>
<ui-input-broken input-id="four"></ui-input-broken>
const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host {
    }
  </style>

  <div>
    <input id="" />
  </div>
`;
class UIInputBroken extends HTMLElement {
  static get observedAttributes() {
    return ['input-id'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }

  attributeChangedCallback(attrName, oldValue, newValue) {
    console.log(newValue)
    if (attrName === 'input-id') {
      this.shadowRoot.querySelector('input').setAttribute('id', newValue);
    }
  }
}
 
customElements.define('ui-input-broken', UIInputBroken);

This strategy would seemingly work. However, we have broken the association between the label and input. The input is scoped within the Shadow DOM template while the label exists in the global light DOM. This breaks the association and all the a11y semantics the label provides.

As of now, there are no great workarounds for associating elements across the Shadow DOM boundary. Either all the associated elements should exist in the light DOM or Shadow DOM. The Custom Elements API, which defines how Web Components are registered, allows us to extend native built-in elements. For our use case, we could extend the native input element and allow our label to associate to ui-input correctly and gain all the behavior of the native input.

Unfortunately, this API is not viable as Safari has refused to implement this part of the Custom Elements API. You can read about it in detail here. However, we do have a new API coming to browsers called the Accessibility Object Model (AOM) API. The AOM API allows us to define our own a11y semantics for elements via JavaScript. This API will enable us to create custom elements and define their intent easier. Specifically, you can see how it can solve our issue of id associations here.

To follow the progress of this API, check out the Github WICG Spec. See the full working demo below!

View Demo Code   
Twitter Facebook LinkedIn Email
 

No spam. Short occasional updates on Web Development articles, videos, and new courses in your inbox.

Related Posts

Lit Web Components

High Performance HTML Tables with Lit and CSS Contain

Learn how to easily create HTML tables in Lit with high performance rendering using CSS contain.

Read Article
Lit Web Components

High Performance HTML Tables with Lit and Virtual Scrolling

Learn how to easily create HTML tables in Lit from dynamic data sources.

Read Article
Lit Web Components

Creating Dynamic Tables in Lit

Learn how to easily create HTML tables in Lit from dynamic data sources.

Read Article