The most common accessibility failure in web-component form controls is an input with no accessible name: the page has a perfectly good <label> and error message, but they live in the light DOM while the <input> lives in a shadow root, and IDREF attributes such as for, aria-labelledby and aria-describedby do not cross that boundary.

Screen readers then announce “edit text, blank”, and the error message that visually sits under the field is never read. Web components and form association explains how custom elements join forms; this page solves naming and description — which patterns work today across browsers, and what delegatesFocus does and does not fix.


Context and prerequisites

The rules that constrain you:

  • IDREFs resolve within one tree. aria-describedby="err-1" on an input inside a shadow root looks for id="err-1" inside the same shadow root. A light-DOM element with that id is invisible to it.
  • <label for> targets labelable elements in the same tree. A form-associated custom element (static formAssociated = true) is labelable, so <label for="host-id"> names the host — not the inner input.
  • delegatesFocus: true makes focusing the host (by click, label click, host.focus(), or Tab) forward focus to the first focusable element in its shadow root. It does not transfer names or descriptions.
  • Reference Target is a proposal to let a shadow host forward IDREF-based relationships to an inner element; it is not something to depend on today without checking current browser support.

So in practice you choose between two architectures: put the label, hint and error inside the shadow root with the input, or make the host the accessible control through ElementInternals ARIA properties.

Which relationships cross the shadow boundary A label with for pointing to the inner input's id does not work across the boundary. A label with for pointing to a form-associated host works and names the host. aria-labelledby and aria-describedby from the inner input to light DOM ids do not work. ElementInternals aria properties such as ariaLabel and ariaDescription on the host work for string values. Keeping label, hint and error inside the same shadow root as the input works fully. Relationship Works? Instead <label for> → inner input id no label the host, or label inside <label for> → form-associated host yes names the host aria-describedby → light DOM id no describe inside the shadow root internals.ariaLabel / ariaDescription yes string values only label, hint, error all inside yes the most robust option

The core pattern: everything inside, attributes in

// Pattern A (recommended): the component renders label, hint, input and error
// in ONE shadow root, so every IDREF resolves within the same tree.
class DsField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ["label", "hint", "error", "required"];
  #internals = this.attachInternals();
  #root = this.attachShadow({ mode: "open", delegatesFocus: true });

  connectedCallback() { this.#render(); }
  attributeChangedCallback() { this.#render(); }

  #render() {
    const label = this.getAttribute("label") ?? "";
    const hint = this.getAttribute("hint");
    const error = this.getAttribute("error");
    const required = this.hasAttribute("required");
    const describedBy = [error && "err", hint && "hint"].filter(Boolean).join(" ");
    const current = this.#root.querySelector("input")?.value ?? "";
    this.#root.innerHTML = `
      <label for="in">${escapeHtml(label)}${required ? ' <span aria-hidden="true">*</span>' : ""}</label>
      ${hint ? `<p id="hint">${escapeHtml(hint)}</p>` : ""}
      <input id="in" ${required ? "required" : ""} ${error ? 'aria-invalid="true"' : ""}
             ${describedBy ? `aria-describedby="${describedBy}"` : ""}>
      ${error ? `<p id="err">${escapeHtml(error)}</p>` : ""}`;
    const input = this.#root.querySelector("input")!;
    input.value = current;                                   // keep typed text across re-render
    input.addEventListener("input", () => this.#internals.setFormValue(input.value));
    this.#internals.setValidity(error ? { customError: true } : {}, error ?? "", input);
  }
}
customElements.define("ds-field", DsField);

function escapeHtml(s: string) {
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
}
<!-- Usage: text passed as attributes; no cross-boundary IDREFs needed. -->
<ds-field name="email" label="Email address" hint="We'll send the receipt here." required></ds-field>
// Pattern B: the host is the control. Useful when the design system's label
// must stay in light DOM (e.g. a shared <ds-form-row> layout).
// <label for="email-host">Email address</label>
// <ds-input id="email-host"></ds-input>
// Inside ds-input: delegatesFocus so clicking the label focuses the inner input,
// and mirror a string description onto the host for the error:
//   this.#internals.ariaDescription = errorText;   // read when the host is announced

Step-by-step walkthrough

  1. Prefer Pattern A for design-system fields. The component owns label, hint, input and error, so for, aria-describedby and aria-invalid all resolve inside one tree and work in every browser.
  2. Pass text in, not references. Labels, hints and errors enter as attributes or properties (or slotted content the component copies), never as ids pointing across the boundary.
  3. Order aria-describedby error-first. Screen readers read descriptions in order; the error should be heard before the hint, as recommended in wiring aria-describedby for multiple errors.
  4. Use delegatesFocus for focus, not names. It makes label clicks, host.focus() and error-summary links land in the inner input.
  5. If the label must stay outside, label the host. Make the element form-associated so <label for="host"> names it, and mirror descriptions with ElementInternals string ARIA properties.
  6. Test with a screen reader, not just the accessibility tree. Chrome’s accessibility pane shows the computed name, but announcement order and description reading differ between NVDA, JAWS and VoiceOver.

Why slots do not solve it

A natural idea is to slot the light-DOM label into the component (<slot name="label">) so it appears next to the input. Slotting changes rendering, not tree membership: the slotted label is still a light-DOM node, and an input inside the shadow root still cannot reference it by id. Some teams work around this by having the component read the slotted text and copy it into an internal label or aria-label — which works, but it is Pattern A with extra steps and a synchronisation risk when the slotted content changes. If the design system needs slotted rich content in labels, copy it deliberately on slotchange and treat the copy as the source for the accessible name.

Choosing a labelling pattern If the component can render its own label, hint and error, keep everything in one shadow root and pass text in through attributes. If the label must remain in the light DOM for layout reasons, make the host form-associated and label the host with label for, use delegatesFocus so focus reaches the inner input, and mirror the error as a string description through ElementInternals. Otherwise, as a last resort, copy slotted label content into the shadow root on slotchange. Can the component render its own label and error? Pattern A: all inside yes no Must the label stay in light DOM? Pattern B: label the host yes no Copy slotted content On slotchange; keep it in sync.

Failure modes and edge cases

1. Re-rendering destroys the input

Rebuilding innerHTML on every attribute change, as in the simple example, recreates the input and loses focus and selection. Production components should update text nodes and attributes in place (or use a rendering library such as Lit) and only create the input once.

2. Error messages that are not announced

Changing the error text inside the shadow root updates the description, but screen readers read descriptions on focus, not on change. Announce new errors through a live region — inside the component or at page level — following ARIA live regions for form errors.

3. Duplicate names

With Pattern B, if the component also renders an internal label, the host’s label and the internal label can combine into a doubled name (“Email Email”). Pick one source of the name.

4. Nested shadow roots

A field inside a card component inside a form-layout component has several boundaries between the page’s label and the input. Each boundary blocks IDREFs; Pattern A remains the robust answer because it does not rely on any.

5. Focus from the error summary

Summary links (href="#email") target the host id; the browser scrolls to the host, but focus goes to the inner input only if delegatesFocus is set. Test that activating the link lands in the input, as in moving focus to the first invalid field.

What each tool does and does not do delegatesFocus forwards focus from the host to the inner input on clicks, label activation and host.focus, but does not carry names or descriptions. ElementInternals ARIA properties give the host a role, name and string description, but cannot reference other elements by id across the boundary. Rendering label, hint, input and error in the same shadow root makes every relationship work but means the component must own that markup. delegatesFocus Moves focus inside. Does not carry names or descriptions. Internals ARIA Role, name, string description on the host. No cross-boundary IDREFs. All inside one root Every relationship works. The component owns the markup.

Verification checklist


Frequently Asked Questions

Can I use aria-label on the inner input instead of a visible label?

Only when there is genuinely no visible label, which is rare in forms. Visible labels help everyone, including speech-input users who say “click Email address”; an aria-label that differs from visible text breaks that. Render a visible label inside the component.

Does delegatesFocus affect tab order?

The host becomes part of the sequential focus order and forwards focus to its first focusable descendant, so Tab reaches the inner input once. Avoid putting tabindex on the host as well, which can create an extra stop.

Will cross-root ARIA eventually make this simpler?

Proposals such as Reference Target aim to let hosts forward relationships to inner elements, which would allow light-DOM labels and descriptions to reach shadow inputs. Until support is broad and stable, design components so they do not need it.


Related

← Web Components and Form Association