The exact problem: a form’s “focus the first invalid field” routine returns nothing, its error summary links go nowhere, and aria-describedby points at an id that resolves to null — because the fields are inside shadow roots and every one of those mechanisms is scoped to a single tree.

Context and Prerequisites

This assumes the controls are already form-associated as described in web components and form association. Form association solves value and validity. It does not solve reference: ids, for, aria-describedby and querySelector all stop at a shadow boundary, and each needs a different answer.

What Crosses the Boundary, and What Does Not

// Inside a shadow root, ids are scoped to that root. This is the whole problem.
// document.getElementById('email-error') will not find an element inside a
// shadow tree, and aria-describedby="email-error" on a light-DOM input will
// not resolve to it either.

// Crosses: form association, events (composed), CSS custom properties, ::part.
// Does not cross: ids, label[for], aria-describedby by id, querySelector.

The practical consequence is that a message element must live in the same tree as the thing that references it. There are only two shapes that work, and mixing them is what produces the dangling references.

Shape A — the whole field is one component. The input, its label, its message element and its aria-describedby wiring all live inside one shadow root. Ids are internal, so they always resolve, and the outside world only needs the value and validity that form association already provides. This is the shape to prefer.

Shape B — the control is a leaf and the form owns the messages. The message lives in the light DOM, so it cannot be referenced by id from inside the shadow root. ElementInternals provides the escape hatch: internals.ariaDescribedByElements takes element references rather than ids, and references may cross the boundary.

// Shape B: the form hands the element the message NODE, not an id string.
// Element references cross shadow boundaries; id strings do not.
element.internals.ariaDescribedByElements = [messageEl];
Keep references inside one tree, or pass elements instead of ids Shape A: a single component owns the label, the input and the message element, all inside one shadow root. Every id reference is internal so it always resolves, and the outside world interacts only through the value and validity that form association already exposes. Shape B: the control is a leaf element and the form owns the message in the light DOM. An id reference cannot cross the boundary, so the form passes the message element itself through the internals' described-by element list, which accepts references rather than strings. Shape A — one component owns everything shadow root <label>Email</label> <input aria-describedby="msg"> <p id="msg">…</p> — same tree, resolves Shape B — the form owns the message light DOM <x-field name="email"></x-field> <p id="note"> — id cannot cross in ariaDescribedByElements = [msg] Prefer A. Reach for B when the form must own message layout — a shared summary, or a design that positions messages itself. What fails is the mixture: an id written in the light DOM and read inside the shadow root, which resolves to nothing. What crosses a shadow boundary and what does not Form association crosses, so value and validity reach the form regardless of the boundary. Composed events cross, which is why every form-facing event needs composed set to true. CSS custom properties cross, which is how the theme reaches inside. The part pseudo-element crosses, which is how the outside styles the inside. Id references do not cross, in either direction. Label for association reaches the host but not the inner control without delegated focus. And querySelector does not descend at all. Mechanism Crosses? Consequence form association yes value and validity reach the form composed events yes set composed: true on every one custom properties, ::part yes theming and styling work id references no pass elements instead of ids querySelector no use form.elements instead Four of the five are fine. The two that are not are exactly the two the form’s validation code depends on most.

Step-by-Step Walkthrough

  1. Pick a shape per component and stay in it. The failures all come from mixing.

  2. Never write an id reference across a boundary. If a reference must cross, pass the element.

  3. Rewrite queries to be boundary-aware. form.querySelectorAll('[aria-invalid="true"]') finds nothing inside shadow roots. Query the form’s elements collection instead — form-associated elements appear there — and read validity from the element’s own API.

  4. Delegate focus. Without delegatesFocus, focus() on the host is a no-op, so first-invalid focus and summary links silently fail.

  5. Expose a part for the inner control. ::part(input) lets the form’s stylesheet indicate invalid state without piercing the boundary.

  6. Compose your events. A custom input or change event must be dispatched with composed: true, or a listener on the form never sees it.

Failure Modes and Edge Cases

1. The first-invalid query returns nothing

// Wrong: attribute selectors do not descend into shadow roots.
const first = form.querySelector('[aria-invalid="true"]');

// Right: iterate the form's own elements — form-associated custom elements
// are members — and ask each one for its validity.
const first = [...form.elements].find(
  (el) => 'validity' in el && !(el as HTMLObjectElement).validity.valid,
);

2. Events that stop at the boundary

An event dispatched without composed: true does not escape the shadow root. Every event the form listens for — input, change, a custom field-committed — needs it.

3. Focus visibly lands nowhere

delegatesFocus fixes focus(), but document.activeElement then reports the host, not the inner input. Tests asserting on the inner element must read element.shadowRoot.activeElement.

4. A label outside, a control inside

<label for="x"> associates with a form-associated host, and the click focuses it — which works only with delegatesFocus. Wrapping labels (<label><x-field></x-field></label>) do not associate at all with custom elements; use for.

5. Styling invalid state from outside

The form’s stylesheet cannot select the inner input. Reflect state onto the host as an attribute and expose the inner node as a part, then style x-field[data-invalid]::part(input).

Two focus questions that have different answers With delegatesFocus set, calling focus on the host moves focus to the first focusable element inside the shadow root — so label clicks, error-summary links and first-invalid routines all work. But document.activeElement then reports the host, not the inner input, because the inner element is in a different tree. Tests that assert on the inner control must read shadowRoot.activeElement instead, and code that compares activeElement against a field list must compare against hosts. does focus() work? yes, with delegatesFocus label clicks reach the input summary links reach the input first-invalid routines work what is activeElement? the host, not the inner input because they are different trees tests read shadowRoot.activeElement field comparisons compare hosts Both answers are correct and they surprise people in opposite directions, which is why they are worth writing down.

Verification Checklist


Related

Web Components and Form Association

Frequently Asked Questions

Can aria-describedby point at an element in another tree?

Not by id — id references are scoped to the tree they are written in, so an attribute in the light DOM cannot name an element inside a shadow root, and vice versa. ElementInternals exposes ariaDescribedByElements, which takes element references rather than strings, and references cross the boundary. Where that is unavailable, the fallback is to keep the message inside the same tree as the control that references it.

Why does my first-invalid query find nothing?

Because querySelector does not descend into shadow roots, so an attribute selector on the form matches only light-DOM inputs. Iterate form.elements instead: form-associated custom elements are members of that collection, and each exposes its own validity through the API you gave it. That also avoids depending on aria-invalid being mirrored onto the host at all.

Should the label live inside or outside the component?

Inside, if the component is a whole field — then the label, the control and the message are one tree and every reference resolves. Outside, if the control is a leaf that a form composes with its own labels; in that case use label with a for attribute pointing at the host, and set delegatesFocus so the click reaches the inner input. Wrapping a custom element in a label does not create an association.