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];
Step-by-Step Walkthrough
-
Pick a shape per component and stay in it. The failures all come from mixing.
-
Never write an id reference across a boundary. If a reference must cross, pass the element.
-
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. -
Delegate focus. Without
delegatesFocus,focus()on the host is a no-op, so first-invalid focus and summary links silently fail. -
Expose a part for the inner control.
::part(input)lets the form’s stylesheet indicate invalid state without piercing the boundary. -
Compose your events. A custom
inputorchangeevent must be dispatched withcomposed: 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).
Verification Checklist
Related
- Web Components and Form Association — value and validity across the boundary
- Form-Associated Custom Elements with ElementInternals — the element implementation
- Wiring aria-describedby for Multiple Errors — the token model inside one tree
← 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.