loke.dev
Header image for My 3-Year Quest for a Native Accessible Shadow DOM

My 3-Year Quest for a Native Accessible Shadow DOM

Experience the frustration and eventual triumph of linking labels and inputs across the shadow boundary using the long-awaited Reference Target API.

· 4 min read

My 3-Year Quest for a Native Accessible Shadow DOM

I spent a solid afternoon in 2021 trying to make a simple <label> click-focus an input inside a Web Component, only to realize I was fighting the very nature of the Shadow DOM. It felt like a betrayal. We were promised encapsulation and "true" components, yet the moment I tried to connect a standard HTML label to my custom text field, the accessibility tree just... gave up. For three years, we’ve been duct-taping this problem with ElementInternals or complicated attribute mirroring. But finally, the Reference Target API has arrived to save our sanity.

The Encapsulation Paradox

The Shadow DOM is fantastic for keeping styles from leaking, but it’s a brick wall for IDs. In standard HTML, a label links to an input via an ID:

<label for="username">Username</label>
<input id="username" type="text" />

This works because they live in the same "scope." But if you put that input inside a custom element, the for attribute can't "see" through the shadow boundary.

<!-- This label is screaming into the void -->
<label for="inner-input">Username</label>

<my-text-field>
  #shadow-root
    <input id="inner-input" type="text" />
</my-text-field>

To a screen reader, that label is just floating text. To a user, clicking the label does exactly nothing. For years, we solved this by manually forwarding clicks or using the aria-labelledby attribute on the host, but it always felt like a hack.

The "Dark Ages" of Attribute Mirroring

Before the Reference Target API, if you wanted a custom element to be accessible, you had to get creative. A common pattern involved ElementInternals or manually syncing attributes from the host to the inner element.

Here is what a "robust" (and annoying) solution looked like:

class MyTextField extends HTMLElement {
  static get observedAttributes() { return ['placeholder', 'value']; }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="text" id="internal-input">
    `;
  }

  // We had to manually proxy properties to make it behave
  set value(val) {
    this.shadowRoot.querySelector('input').value = val;
  }
}

This still didn't solve the "label for" problem natively. You’d end up having to put the label *inside* the component, which limits the flexibility of your design system.

Enter the Reference Target API

The Reference Target API is the missing link. It allows us to tell the browser: *"Hey, if someone targets this Web Component with a label or an ARIA relationship, point them to this specific element inside my shadow tree."*

It works by setting a referenceTarget on the Shadow Root.

How to implement it

When you attach your shadow root, you can now specify which internal element should act as the "delegate" for things like labels.

class BetterInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ 
      mode: 'open',
      // This is the magic sauce
      referenceTarget: 'real-input' 
    });

    shadow.innerHTML = `
      <div>
        <span>Enter your name:</span>
        <input id="real-input" type="text">
      </div>
    `;
  }
}

customElements.define('better-input', BetterInput);

Now, check this out. In your main HTML, you can do this:

<label for="my-component">User Profile Name</label>
<better-input id="my-component"></better-input>

When a user clicks that label, the browser automatically focuses the real-input inside the shadow DOM. No event listeners, no manual focus() calls, no extra boilerplate. It just works.

Why this is a game-changer for ARIA

It’s not just about labels. It fixes the aria-labelledby and aria-describedby mess too. Imagine a complex data grid where headers describe cells.

If your cell is a Web Component, you can now link them properly:

<div id="col-header">Total Price</div>

<price-cell aria-labelledby="col-header">
  #shadow-root (referenceTarget: 'price-value')
    <span id="price-value">$99.00</span>
</price-cell>

The accessibility tree treats the price-cell as if it *is* the price-value span for the purpose of that ARIA relationship. This preserves the semantic meaning without breaking the encapsulation of your component's internal structure.

Things to watch out for

1. Browser Support: As of now, this is part of the "Cross-root ARIA" effort. It’s landing in Chromium-based browsers first (Chrome, Edge). Keep an eye on CanIUse for Firefox and Safari updates.
2. ID Uniqueness: Even though the ID is "hidden" inside the shadow DOM, the referenceTarget string must match the ID of an element *within that same shadow root*.
3. Dynamic Changes: You can change the referenceTarget property on the shadow root dynamically via JavaScript if your internal UI changes (e.g., swapping from a read-only span to an editable input).

// Switching targets on the fly
this.shadowRoot.referenceTarget = 'new-active-element';

Wrapping Up

The quest for a truly native, accessible Shadow DOM has been long and occasionally frustrating. We spent years building "Accessible" components that were actually just a pile of fragile workarounds.

With the Reference Target API, we finally get to keep the benefits of the Shadow DOM (scoped CSS, DOM isolation) without sacrificing the foundational accessibility of the web. It makes our components act like first-class citizens. If you're building a design system today, this is the API you’ve been waiting for. Stop hacking your labels and start targeting.