Skip to content

Shadow DOM and Forms — Complete Guide

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Shadow Dom and Forms. We cover key concepts, practical examples, and best practices to help you master this topic.

Forms and Shadow DOM interact through form association, element internals, and the ElementInternals API, enabling custom form controls with native form participation.

What You'll Learn

  • How to create custom form controls with Shadow DOM
  • What ElementInternals is and how it enables form association
  • How to handle form validation inside Shadow DOM
  • How to submit form values from custom elements

Why It Matters

Custom elements with Shadow DOM cannot participate in forms by default. The ElementInternals API bridges this gap, allowing custom elements to submit values, validate, and behave like native form controls.

flowchart LR
  A[Custom Element] --> B[attachInternals]
  B --> C[ElementInternals]
  C --> D[setFormValue]
  C --> E[setValidity]
  C --> F[Form owner access]
  D --> G[Form submission]
  E --> H[Validation messages]
  F --> I[Parent form reference]

Form Participation Without ElementInternals

Without ElementInternals, a custom element cannot submit values with a form. The form ignores the element's value.

class DumbInput extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = '<input type="text" id="input">';
    }

    get value() {
        return this.shadowRoot.getElementById('input').value;
    }
}
customElements.define('dumb-input', DumbInput);

// This element value does not get submitted with any form.
// The inner input is inside Shadow DOM and not form-associated.

Form Association with ElementInternals

ElementInternals attaches a custom element to a form. The element becomes form-associated and can submit values.

class FormInput extends HTMLElement {
    static formAssociated = true;  // Required for form association

    constructor() {
        super();
        // Must call attachInternals before attachShadow
        this._internals = this.attachInternals();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                input { border: 1px solid #ccc; padding: 8px; border-radius: 4px; }
                input:invalid { border-color: #e74c3c; }
                .error { color: #e74c3c; font-size: 0.8em; display: none; }
            </style>
            <input type="text" id="input" placeholder="Enter value">
            <div class="error" id="error"></div>
        `;

        this.shadowRoot.getElementById('input').addEventListener('input', () => {
            this._updateValue();
        });
    }

    _updateValue() {
        const value = this.shadowRoot.getElementById('input').value;
        // Set the form value (first arg: value, second: state for restore)
        this._internals.setFormValue(value);
    }

    get value() {
        return this.shadowRoot.getElementById('input').value;
    }

    set value(val) {
        this.shadowRoot.getElementById('input').value = val;
        this._updateValue();
    }
}
customElements.define('form-input', FormInput);

// Now <form-input> inside a <form> submits its value with the form.
// The input name comes from the 'name' attribute on <form-input>.

Validation with ElementInternals

Custom elements can participate in constraint validation using ElementInternals.

class ValidatedInput extends HTMLElement {
    static formAssociated = true;

    constructor() {
        super();
        this._internals = this.attachInternals();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <input type="text" id="input" placeholder="Min 3 characters">
        `;

        this.shadowRoot.getElementById('input').addEventListener('input', () => {
            this._validate();
        });
    }

    _validate() {
        const value = this.shadowRoot.getElementById('input').value;
        if (value.length < 3) {
            // Set validity with a message
            this._internals.setValidity({
                customError: true
            }, 'Value must be at least 3 characters', this.shadowRoot.getElementById('input'));
        } else {
            this._internals.setValidity({});
        }
        this._internals.setFormValue(value);
    }

    // Required for form-associated elements
    formDisabledCallback(disabled) {
        this.shadowRoot.getElementById('input').disabled = disabled;
    }

    formResetCallback() {
        this.shadowRoot.getElementById('input').value = '';
        this._internals.setFormValue('');
        this._internals.setValidity({});
    }
}
customElements.define('validated-input', ValidatedInput);

// HTML: <form><validated-input name="field"></validated-input></form>
// Form will not submit if validation fails
// Validation message shows via the browser's built-in UI

Native Form Reset and Disable

Form-associated custom elements automatically receive callbacks when the form is reset or disabled.

class ResetAwareInput extends HTMLElement {
    static formAssociated = true;

    constructor() {
        super();
        this._internals = this.attachInternals();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                input { padding: 8px; border: 2px solid #ccc; border-radius: 4px; }
                input:disabled { background: #f5f5f5; cursor: not-allowed; }
            </style>
            <input type="text" id="input" placeholder="Reset-aware input">
        `;
        this._defaultValue = '';
    }

    connectedCallback() {
        this._defaultValue = this.getAttribute('value') || '';
        this.shadowRoot.getElementById('input').value = this._defaultValue;
    }

    formResetCallback() {
        // Called when the parent form is reset
        this.shadowRoot.getElementById('input').value = this._defaultValue;
        this._internals.setFormValue(this._defaultValue);
        this._internals.setValidity({});
        console.log('Form was reset');
    }

    formDisabledCallback(disabled) {
        // Called when the parent form is disabled/enabled
        this.shadowRoot.getElementById('input').disabled = disabled;
        this.style.opacity = disabled ? '0.6' : '1';
    }
}
customElements.define('reset-aware-input', ResetAwareInput);

Accessing the Parent Form

ElementInternals provides access to the parent form element, enabling custom controls to interact with the form.

class FormAware extends HTMLElement {
    static formAssociated = true;

    constructor() {
        super();
        this._internals = this.attachInternals();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = '<button id="submitBtn">Submit Form</button>';

        this.shadowRoot.getElementById('submitBtn').addEventListener('click', () => {
            const form = this._internals.form;
            if (form) {
                // Programmatically submit the parent form
                form.requestSubmit();
                console.log('Form submitted from inside Shadow DOM');
            } else {
                console.log('Element is not inside a form');
            }
        });
    }
}
customElements.define('form-aware', FormAware);

Common Mistakes

  1. Forgetting to declare static formAssociated = true on the class, preventing form association.
  2. Calling attachInternals() after attachShadow(), which throws an error.
  3. Not implementing formDisabledCallback and formResetCallback, leaving the element in an inconsistent state.
  4. Calling setValidity without calling setFormValue, causing form submission without the value.
  5. Assuming the element is inside a form — always check this._internals.form for null.

Practice Questions

  1. What is ElementInternals? ElementInternals is an API that allows custom elements to participate in HTML forms as form-associated elements.
  2. What does static formAssociated = true do? It tells the browser the custom element can be associated with forms.
  3. How do you submit a value from a custom element? Call this._internals.setFormValue(value).
  4. What callbacks are available for form-associated elements? formDisabledCallback, formResetCallback, and formStateRestoreCallback.

Challenge

Build a custom color picker component that is form-associated. It should display a color swatch and an HSL slider interface. The value should be submitted as a hex string. Implement validation to reject invalid hex values.

FAQ

What is ElementInternals in Shadow DOM?

ElementInternals is a browser API that lets custom elements participate in HTML forms, including value submission, validation, and form lifecycle callbacks.

Can I use ElementInternals without Shadow DOM?

Yes. ElementInternals does not require Shadow DOM. It works with any custom element that sets static formAssociated = true.

How does form validation work with Shadow DOM?

Use internals.setValidity() to set custom validation state. The browser integrates this with the parent form's validation flow.

What is the order of attachInternals and attachShadow?

Always call this.attachInternals() before this.attachShadow(). Calling them in the wrong order throws an error.

Does ElementInternals work in all browsers?

ElementInternals is supported in Chrome, Firefox, and Safari (from version 16.4). Polyfills are available for older browsers.

Mini Project

Build a custom email input component with form association. It should validate email format, show a custom error message, participate in form submission, and handle form reset and disable states.

What's Next

Lesson 14: Declarative Shadow DOM

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro