ElementInternals setValidity for Custom Inputs
How do you make a custom input — a tag picker, a colour swatch, a masked phone field — report “required”, “too short” or your own custom error through the same constraint validation machinery as <input>, so form.checkValidity() counts it, form.reportValidity() focuses it and :user-invalid styles it? The answer is one method, ElementInternals.setValidity(flags, message, anchor), used correctly. This recipe builds a tag input that requires at least one tag and allows at most five, shows exactly which ValidityState flags to set for each case, explains the anchor argument that makes focus work, clears validity properly, and keeps everything aligned with the Constraint Validation API and the site’s novalidate plus reportValidity() pattern.
When to Use setValidity
Use it inside any form-associated custom element (static formAssociated = true) whose value can be invalid. Choose it over alternatives when:
- The component is a real control that should submit a value and block submission when invalid.
- Its constraints are intrinsic — required, minimum or maximum counts, allowed formats — rather than business rules that belong in a form-level schema.
- You want native behaviour for free — the browser’s focus-and-message on
reportValidity(),:invalidand:user-invalidstyling, and correct handling inside disabled fieldsets.
The element must already be form-associated; the setup is covered in the form-associated custom elements topic. Business rules that depend on other fields or the server still belong in your form-level validation, which can set the element’s custom error through a public method.
Minimal Working Tag Input With setValidity
const MAX_TAGS = 5;
const MAX_LEN = 20;
const TAG_RE = /^[\p{L}\p{N}-]+$/u;
export class TagInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ["required"];
#internals = this.attachInternals();
#tags: string[] = [];
#field!: HTMLInputElement; // the anchor: a real, focusable input inside the component
#list!: HTMLUListElement;
connectedCallback(): void {
this.innerHTML = `<ul class="tags"></ul><input type="text" aria-label="Add a tag" enterkeyhint="done">`;
this.#list = this.querySelector("ul")!;
this.#field = this.querySelector("input")!;
this.#field.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === ",") { e.preventDefault(); this.#add(this.#field.value); }
});
this.#render();
}
attributeChangedCallback(): void {
if (this.#field) this.#validate(); // `required` toggled from outside
}
// Public API mirroring native controls
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
/** Lets form-level code set a business-rule error, like setCustomValidity on native inputs. */
setCustomValidity(message: string) { this.#custom = message; this.#validate(); }
#custom = "";
#add(raw: string): void {
const tag = raw.trim();
if (tag && !this.#tags.includes(tag)) this.#tags.push(tag);
this.#field.value = "";
this.#render();
}
#render(): void {
this.#list.replaceChildren(...this.#tags.map((t) => Object.assign(document.createElement("li"), { textContent: t })));
const data = new FormData();
for (const t of this.#tags) data.append(this.getAttribute("name") ?? "tags", t);
this.#internals.setFormValue(data, JSON.stringify(this.#tags));
this.#validate();
this.dispatchEvent(new Event("input", { bubbles: true }));
}
#validate(): void {
const tags = this.#tags;
const tooLong = tags.find((t) => [...t].length > MAX_LEN);
const badFormat = tags.find((t) => !TAG_RE.test(t));
if (this.#custom) {
this.#internals.setValidity({ customError: true }, this.#custom, this.#field);
} else if (this.hasAttribute("required") && tags.length === 0) {
this.#internals.setValidity({ valueMissing: true }, "Add at least one tag.", this.#field);
} else if (tags.length > MAX_TAGS) {
this.#internals.setValidity({ rangeOverflow: true }, `You can add up to ${MAX_TAGS} tags.`, this.#field);
} else if (tooLong) {
this.#internals.setValidity({ tooLong: true }, `“${tooLong}” is too long. Tags can be ${MAX_LEN} characters or fewer.`, this.#field);
} else if (badFormat) {
this.#internals.setValidity({ patternMismatch: true }, `“${badFormat}” can only use letters, numbers and hyphens.`, this.#field);
} else {
this.#internals.setValidity({}); // no flags = valid; message and anchor ignored
}
}
}
customElements.define("tag-input", TagInput);
Three details matter more than the rest. Flags must be truthy to count: setValidity({}) clears all flags and makes the element valid; there is no separate “clear” call. A message is required whenever any flag is true: passing an empty message with a true flag throws a TypeError. The anchor must be a descendant (in light or shadow DOM) of the element; it is where reportValidity() moves focus and where the browser points its bubble. Passing the internal text field means a failed submit lands the user exactly where they can add a tag.
setValidity Parameter Reference
| Parameter | Type | Required | Notes |
|---|---|---|---|
flags |
ValidityStateFlags |
yes | Any of valueMissing, typeMismatch, patternMismatch, tooLong, tooShort, rangeUnderflow, rangeOverflow, stepMismatch, badInput, customError |
message |
string |
when any flag is true | Becomes validationMessage; shown by reportValidity() |
anchor |
HTMLElement |
recommended | Descendant to focus and point at; defaults to the host |
| Clearing | setValidity({}) |
— | All flags false = valid |
| Precedence | your code | — | Report one problem at a time, most actionable first |
Setting the flag that matches the native equivalent is not pedantry. Generic code — an error summary that groups “missing” fields, analytics that count valueMissing, a message catalogue keyed by flag — reads validity the same way for native and custom controls, as in reading ValidityState flags for granular errors.
Verification Steps
import { test, expect } from "@playwright/test";
test("tag-input reports through native validation", async ({ page }) => {
await page.goto("/profile");
const tags = page.locator("tag-input");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByLabel("Add a tag")).toBeFocused();
expect(await tags.evaluate((el: any) => el.validity.valueMissing)).toBe(true);
for (const t of ["a", "b", "c", "d", "e", "f"]) {
await page.getByLabel("Add a tag").fill(t);
await page.keyboard.press("Enter");
}
expect(await tags.evaluate((el: any) => el.validity.rangeOverflow)).toBe(true);
});
Edge Cases and Failure Modes
Empty message with a flag. setValidity({ valueMissing: true }) without a message throws. Always pass a message when setting a flag; pass nothing when clearing.
Anchor outside the element. Passing an element that is not a descendant throws a NotFoundError. If the component re-renders its internals, make sure the anchor reference points at the current element, or re-query it before calling setValidity.
Validity computed before connection. In the constructor, the anchor does not exist yet. Compute validity in connectedCallback and whenever the value or constraints change.
Stale custom errors. A form-level business error set through setCustomValidity must be cleared by the form code when the condition no longer holds, exactly like native setCustomValidity; the component cannot know when a server-side rule is satisfied again. Clearing on the component’s next input event, as in clearing custom validity on input, is the usual pattern.
Choosing Which Error to Report First
A tag list can be too long and contain a malformed tag at the same time, but a field has only one validationMessage. Report the problem the user can fix most directly, in a fixed order, and let the next one surface after the first is fixed. For most components the order is: custom (business) errors set from outside, then missing value, then count or range limits, then per-item problems such as length and format. That mirrors how native inputs behave — an empty required field reports valueMissing rather than a pattern problem — and it keeps messages stable as the user edits, instead of flickering between different complaints. If users benefit from seeing every problem at once, render a list of item-level issues in the component’s own error area while validationMessage carries the single most important one for reportValidity().
Showing the Error Inline as Well as in the Bubble
The browser’s validation bubble appears only during reportValidity() and differs across browsers, so most designs also render the message inline. Read validationMessage after each validation and write it into an error element inside the component, referenced by the anchor’s aria-describedby. Show it only after the user has interacted with the component or attempted to submit — the rule :user-invalid implements for native inputs — so an empty required tag list is not red on page load. Set internals.ariaInvalid in step with the visible error so assistive technology reports the state together with the message.
// inside TagInput
#touched = false;
#paintError(): void {
const show = (this.#touched || this.closest("form")?.hasAttribute("data-submitted")) && !this.#internals.validity.valid;
const err = this.querySelector<HTMLElement>(".tag-error")!;
err.textContent = show ? this.#internals.validationMessage : "";
err.hidden = !show;
this.#internals.ariaInvalid = show ? "true" : "false";
}
Frequently Asked Questions
How do I mark a custom element as invalid?
In a form-associated element, call this.internals.setValidity with a flags object such as { valueMissing: true }, a message, and an anchor element inside the component. Call setValidity({}) to mark it valid again.
What is the anchor argument of setValidity?
A descendant element that the browser focuses and points its validation message at when reportValidity runs. Without it, focus goes to the host, which may not be focusable.
Which ValidityState flag should my custom input use?
The one that matches the native equivalent: valueMissing for required and empty, rangeOverflow or rangeUnderflow for counts or values out of range, tooLong or tooShort for length, patternMismatch for format, and customError for anything else.
Why does setValidity throw a TypeError?
Usually because a flag is true and the message is empty. A message is required whenever any flag is set.
Related Guides
- Form-Associated Custom Elements — setting up ElementInternals.
- Validating Inputs Inside Shadow DOM — anchors and delegation with shadow roots.
- How to Use setCustomValidity Correctly — the native counterpart.
- Styling Invalid Inputs with :user-invalid — styling after interaction.