Validating Inputs Inside Shadow DOM

Why does form.checkValidity() return true when your design-system <ds-text-field required> is empty — and why does the value never appear in FormData? Because an <input> inside a shadow root does not belong to the outer form: the shadow boundary hides it from form.elements, from submission and from constraint validation. This recipe fixes that without giving up encapsulation. The component becomes form-associated, keeps a native <input> in its shadow tree for typing, keyboard and accessibility, forwards the inner input’s constraints and validity to ElementInternals, anchors errors on the inner input so reportValidity() focuses it, and delegates focus and labels across the boundary — so the outer form’s canonical novalidate plus reportValidity() flow from the Constraint Validation API works as if the input were in the light DOM.

When You Need This Pattern

You need it whenever a component wraps a native form control in a shadow root, which is the default architecture of most web-component design systems. It applies when:

  • Your components use Shadow DOM for style encapsulation (Lit, Stencil, FAST and hand-written elements alike).
  • The inner control is native<input>, <textarea>, <select> — and you want to keep its behaviour rather than rebuild it.
  • The component must work in plain forms, not only inside a framework that reads its value through events.

If your component renders into light DOM instead, the inner input is already part of the form and needs none of this. For custom controls with no native input at all, see ElementInternals setValidity for custom inputs.

Crossing the shadow boundary The outer form sees only the host element; the host's ElementInternals carries the value and validity that are forwarded from the native input inside the shadow root. Outer form form.elements, FormData, reportValidity Host element formAssociated, ElementInternals Shadow boundary inner input invisible to the form Native input typing, constraints, keyboard, a11y
The outer form never sees the inner input; the host re-publishes its value and validity through ElementInternals.

Minimal Working Shadow-Wrapped Field

const FORWARDED = ["required", "minlength", "maxlength", "pattern", "type", "inputmode", "autocomplete", "placeholder"] as const;

export class DsTextField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = [...FORWARDED, "value", "disabled"];

  #internals = this.attachInternals();
  #input: HTMLInputElement;
  #error: HTMLElement;

  constructor() {
    super();
    const root = this.attachShadow({ mode: "open", delegatesFocus: true });
    root.innerHTML = `
      <style>
        :host { display: block; }
        input { font: inherit; inline-size: 100%; }
        :host(:state(show-error)) input { border-color: var(--ds-error, #b91c1c); }
        .error { color: var(--ds-error, #b91c1c); }
      </style>
      <input part="input">
      <p class="error" part="error" id="err" hidden></p>`;
    this.#input = root.querySelector("input")!;
    this.#error = root.querySelector(".error")!;
    this.#input.setAttribute("aria-describedby", "err");

    this.#input.addEventListener("input", () => this.#sync());
    this.#input.addEventListener("blur", () => { this.#touched = true; this.#paint(); });
  }

  #touched = false;

  attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
    if (name === "value") this.#input.value = value ?? "";
    else if (name === "disabled") this.#input.disabled = value !== null;
    else value === null ? this.#input.removeAttribute(name) : this.#input.setAttribute(name, value);
    this.#sync();
  }

  connectedCallback(): void {
    // Label forwarding: <label for="host-id"> labels the host; mirror its text onto the inner input.
    const label = this.#internals.labels[0]?.textContent?.trim();
    if (label) this.#input.setAttribute("aria-label", label);
    this.#sync();
  }

  /** Re-publish the inner input's value and validity on the host. */
  #sync(): void {
    this.#internals.setFormValue(this.#input.value);
    const v = this.#input.validity;
    if (v.valid) {
      this.#internals.setValidity({});
    } else {
      // Copy every flag and the native message; anchor on the inner input for focus.
      const flags: ValidityStateFlags = {};
      for (const key of ["valueMissing", "typeMismatch", "patternMismatch", "tooLong", "tooShort", "rangeUnderflow", "rangeOverflow", "stepMismatch", "badInput", "customError"] as const) {
        if (v[key]) flags[key] = true;
      }
      this.#internals.setValidity(flags, this.#message(), this.#input);
    }
    this.#paint();
  }

  #message(): string {
    const v = this.#input.validity;
    if (v.valueMissing) return this.dataset.msgRequired ?? `Enter ${this.#internals.labels[0]?.textContent?.trim().toLowerCase() ?? "a value"}.`;
    if (v.tooShort) return `Use at least ${this.#input.minLength} characters.`;
    return this.#input.validationMessage;          // native fallback for other flags
  }

  #paint(): void {
    const show = this.#touched && !this.#internals.validity.valid;
    this.#error.textContent = show ? this.#internals.validationMessage : "";
    this.#error.hidden = !show;
    this.#input.setAttribute("aria-invalid", String(show));
    show ? this.#internals.states.add("show-error") : this.#internals.states.delete("show-error");
  }

  formResetCallback(): void { this.#input.value = this.getAttribute("value") ?? ""; this.#touched = false; this.#sync(); }
  formDisabledCallback(disabled: boolean): void { this.#input.disabled = disabled; }

  get value() { return this.#input.value; }
  set value(v: string) { this.#input.value = v; this.#sync(); }
  get validity() { return this.#internals.validity; }
  checkValidity() { return this.#internals.checkValidity(); }
  reportValidity() { this.#touched = true; this.#paint(); return this.#internals.reportValidity(); }
}
customElements.define("ds-text-field", DsTextField);

The inner input does the real work — it evaluates required, minlength and pattern exactly as it would anywhere else — and the host simply republishes the result. Because the anchor is the inner input, form.reportValidity() focuses the text field inside the shadow root, which is only possible because the anchor argument is allowed to be a shadow descendant.

Forwarding validity across the shadow boundary The user types into the inner input, the host copies the inner validity flags and message to ElementInternals, and a later form.reportValidity focuses the inner input through the anchor. User Inner input Host internals Outer form types "ab" (minlength 3) sync(): tooShort + message, anchor = input reportValidity() focus + message at anchor FormData includes the host's value
Constraints are evaluated by the native input; the host only forwards the result and names the inner input as its anchor.

Forwarding Option Reference

Mechanism API Purpose Notes
Form association static formAssociated = true Host joins the outer form Required
Value forwarding internals.setFormValue(input.value) Value in FormData Call on every input
Validity forwarding copy flags + setValidity(flags, msg, input) Host validity mirrors inner input Anchor on the inner input
Constraint forwarding observed attributes → inner input required, pattern, … set on the host Keep the list explicit
Focus delegation attachShadow({ delegatesFocus: true }) Focusing the host focuses the input Also routes label clicks
Label forwarding internals.labels → inner aria-label Accessible name inside the shadow root Cross-root ARIA is still evolving
Error display internals.states + :state() Style the host’s error state No leaking classes

Verification Steps

import { test, expect } from "@playwright/test";

test("shadow-wrapped input participates in the outer form", async ({ page }) => {
  await page.goto("/profile");
  const host = page.locator("ds-text-field[name=nickname]");
  await page.getByRole("button", { name: "Save" }).click();
  await expect(host.locator("input")).toBeFocused();                     // Playwright pierces open shadow roots
  await host.locator("input").fill("ab");
  await host.locator("input").blur();
  expect(await host.evaluate((el: any) => el.validity.tooShort)).toBe(true);
  await host.locator("input").fill("ada");
  const value = await page.evaluate(() => new FormData(document.querySelector("form")!).get("nickname"));
  expect(value).toBe("ada");
});

Edge Cases and Failure Modes

tooShort needs user edits. Like any input, the inner field only sets tooShort after user typing, not for a value set from an attribute. Initial values that violate minlength are therefore valid until edited — matching native behaviour, but surprising in tests that set value programmatically.

Cross-root ARIA references. aria-describedby cannot point from light DOM into a shadow root or vice versa. Keep the error message in the same shadow tree as the input (as above) so the reference works; reference-target proposals will eventually allow crossing roots.

Closed shadow roots in tests. Playwright pierces open shadow roots; closed ones hide the input from tests and some assistive tooling. Prefer mode: "open" for design-system controls.

Forgetting to re-sync on attribute changes. If required is toggled on the host after render and not forwarded, the inner input’s validity is stale and so is the host’s. Re-run #sync() after every forwarded attribute change.

Exposing Styling Hooks Without Breaking Encapsulation

Consumers of a design-system field want to adjust how errors look without reaching into its shadow root. Three standard hooks make that possible while keeping the internals private. ::part(input) and ::part(error) let product CSS style the inner input and message directly, because the example marks them with part. The host’s validity drives the native pseudo-classes, so ds-text-field:user-invalid works from outside. And the show-error custom state, set through internals.states, lets consumers target exactly the moment the component decides to display its error — ds-text-field:state(show-error)::part(input) — which is more precise than :invalid, since the component only shows errors after the field is touched. Document these three hooks as the component’s styling API and avoid exposing anything else; every additional hook is a dependency you cannot change later.

Custom Rules on Top of Native Constraints

Sometimes the inner input’s native constraints are not enough — a design-system field for usernames might add a reserved-word rule. Keep native constraints on the inner input and layer custom rules in the host, deciding precedence explicitly: native failures first (they are usually the most basic), then custom ones as customError. Expose a setCustomValidity method on the host so application code can add business-rule errors from outside, exactly like a native input, and clear them on the next input event. The rule-composition approach is the same as in composing pure validator functions, with the host deciding which result wins.

// inside DsTextField
#external = "";
setCustomValidity(message: string): void { this.#external = message; this.#sync(); }
// in #sync(), before copying native flags:
// if (this.#external) return this.#internals.setValidity({ customError: true }, this.#external, this.#input);
Which validity does the host publish? A decision tree the host follows on each sync: an external custom error wins, then any native constraint failure from the inner input, otherwise the host is valid. External setCustomValidity message set? yes customError with that message no Inner input invalid? yes Copy native flags + message no setValidity({}) — valid
A single precedence order keeps the host's validity predictable no matter which layer set the error.

Frequently Asked Questions

Why doesn't my form see the input inside a web component?

Inputs inside a shadow root are not part of the outer form. Make the host element form-associated and forward the inner input's value with setFormValue and its validity with setValidity.

How do I make reportValidity focus an input inside shadow DOM?

Pass the inner input as the anchor argument to internals.setValidity. The anchor may be a shadow descendant, and the browser focuses it and points its message at it.

How do labels work across the shadow boundary?

A label for the host's id labels the host, and delegatesFocus sends clicks to the inner input. Mirror the label text onto the inner input's accessible name, because ARIA references cannot yet cross shadow roots.

Do I need to reimplement required and pattern in the component?

No. Forward those attributes to the inner native input, let it evaluate them, and copy its validity flags and message to the host's ElementInternals.

← Back to Form-Associated Custom Elements