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 forid="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: truemakes 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.
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
- Prefer Pattern A for design-system fields. The component owns label, hint, input and error, so
for,aria-describedbyandaria-invalidall resolve inside one tree and work in every browser. - 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.
- Order
aria-describedbyerror-first. Screen readers read descriptions in order; the error should be heard before the hint, as recommended in wiring aria-describedby for multiple errors. - Use
delegatesFocusfor focus, not names. It makes label clicks,host.focus()and error-summary links land in the inner input. - If the label must stay outside, label the host. Make the element form-associated so
<label for="host">names it, and mirror descriptions withElementInternalsstring ARIA properties. - 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.
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.
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.