ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeForm-Associated Custom Elements: Web Components That Belong in a Form

Form-Associated Custom Elements: Web Components That Belong in a Form

Danny Holloran
Danny Holloran
Senior Developer
August 29, 2026
3 min read
Form-Associated Custom Elements: Web Components That Belong in a Form
#Accessibility#CSS#web-apis#JavaScript
๐Ÿ‘1

Custom elements have been shippable for years, but the illusion falls apart the moment you drop one inside a <form>. The value never shows up in FormData. required does nothing. Hitting reset leaves your control sitting there with stale state, and the browser's validation bubble refuses to point at it. So most of us reach for the same workaround: render a hidden <input> inside the component and keep it in sync by hand, forever.

That workaround has been unnecessary for a while now. Form-associated custom elements are Baseline โ€” Chromium, Firefox, and Safari 16.4 and up โ€” and they let a component participate in a form as a first-class control instead of a decoration sitting next to one.

Two lines make it a form control

The whole thing hinges on a static property and one method call:

class RatingInput extends HTMLElement {
  static formAssociated = true;

  #internals;
  #value = "";

  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <div role="radiogroup" aria-label="Rating">
        ${[1, 2, 3, 4, 5]
          .map(
            (n) =>
              `<button part="star" type="button" value="${n}">โ˜…</button>`,
          )
          .join("")}
      </div>
    `;
    this.shadowRoot.addEventListener("click", (e) => {
      if (e.target.matches("button")) this.value = e.target.value;
    });
  }

  get value() {
    return this.#value;
  }

  set value(v) {
    this.#value = String(v);
    this.#internals.setFormValue(this.#value);
  }
}

customElements.define("rating-input", RatingInput);

static formAssociated = true tells the browser to treat the element like a form control: it gets picked up by the owning form, it inherits name, and it becomes eligible for validation. attachInternals() hands back an ElementInternals object, which is the private channel your component uses to talk to the form. Guard it โ€” anything you can do through internals is something you probably do not want page scripts doing on your behalf, which is why it lives in a private field.

setFormValue() is the part that ends the hidden-input era. Pass it a string, a File, or a whole FormData object when one control needs to contribute several named values, and it lands in the submission:

<form id="review">
  <rating-input name="score" required></rating-input>
  <button>Submit</button>
</form>
new FormData(document.getElementById("review")).get("score"); // "4"

The lifecycle you get for free

Being form-associated also opts you into callbacks the browser fires at the right moments, so you stop wiring up listeners for things the platform already knows:

formResetCallback() {
  this.value = '';
}

formDisabledCallback(disabled) {
  this.toggleAttribute('inert', disabled);
}

formStateRestoreCallback(state) {
  this.value = state;
}

formResetCallback runs on form.reset(). formDisabledCallback fires when the element or its enclosing <fieldset> gets disabled, which is the case almost everyone forgets. formStateRestoreCallback is the one that quietly wins arguments in code review: it restores state on back-navigation and session restore, using the optional second argument to setFormValue(value, state). If your control's submission value differs from what the user actually typed โ€” a formatted currency field, say โ€” pass the raw input as that second argument and you get real state restoration instead of an empty box.

Validation the browser actually understands

setValidity() is where custom controls finally stop being second-class:

#validate() {
  const empty = this.hasAttribute('required') && !this.#value;

  this.#internals.setValidity(
    empty ? { valueMissing: true } : {},
    empty ? 'Please choose a rating.' : '',
    this.shadowRoot.querySelector('button'),
  );
}

The first argument is a ValidityStateFlags dictionary using the same flag names as native inputs (valueMissing, rangeUnderflow, customError, and so on). The second is the message. The third โ€” the anchor โ€” is the one people skip and then wonder why nothing appears: it is the element the browser points its validation bubble at. Without an anchor inside your shadow root, Chromium has nowhere to render the message and silently gives up.

Get this right and form.reportValidity(), implicit submit blocking, and the :invalid pseudo-class all work against your component exactly as they do against <input required>.

Styling states without attribute soup

The same ElementInternals object carries a states set, so internal state no longer has to leak out as a reflected attribute:

this.#internals.states.add("rated");
this.#internals.states.delete("rated");
rating-input:state(rated)::part(star) {
  color: gold;
}

:state() has been Baseline since 2024 and composes with :host() and ::part(), which means consumers can style your component's states without you publishing a contract of magic class names.

None of this is new enough to be risky anymore, and it collapses a surprising amount of glue code. Next time you are about to add a hidden input to a component, open the ElementInternals docs on MDN instead and delete it before it exists.

Comments (0)

Login to post a comment.

Danny Holloran
Danny Holloran

Senior Developer

Senior Frontend & Fullstack Developer with 14+ years building performant, scalable web applications. Passionate about architecture, mentorship, and finding the right tool for the job.

Subscribe to Danny Holloran's Newsletter

More from Danny Holloran

View profile

Async Svelte: Using await Directly in Your Components

Svelte 5.36 lets you use await at the top level of a component, inside $derived, and in your markup. Here is how synchronized updates, boundaries, and $effect.pending() fit together.

4 minAug 31

The Web Locks API: One Tab Does the Work, the Rest Wait

Your app already runs in five tabs at once, and they all think they are in charge. The Web Locks API gives the browser a real mutex so only one of them does the work.

4 minAug 29

Next.js Partial Prefetching: One Shell Per Route, Not One Per Link

Next.js 16.3 stops firing a prefetch request for every link in the viewport and caches one reusable loading shell per route instead. Here's what changes and how to turn it on.

4 minAug 24

Declarative Partial Updates: Out-of-Order HTML Streaming Without a Framework

Chrome 148 ships experimental support for filling HTML placeholders out of order and streaming markup into the DOM. Here's how the new template-for and streamHTML APIs work, and what they replace.

3 minAug 22

React's Activity Component: Hide UI Without Losing Its State

React 19.2's Activity component hides a subtree instead of unmounting it, so state, scroll position, and DOM survive the round trip. Here's how it behaves, what it does to your Effects, and where it costs you.

3 minAug 18