Shadow DOM and Forms — Complete Guide
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
- Forgetting to declare
static formAssociated = trueon the class, preventing form association. - Calling
attachInternals()afterattachShadow(), which throws an error. - Not implementing
formDisabledCallbackandformResetCallback, leaving the element in an inconsistent state. - Calling
setValiditywithout callingsetFormValue, causing form submission without the value. - Assuming the element is inside a form — always check
this._internals.formfor null.
Practice Questions
- What is ElementInternals? ElementInternals is an API that allows custom elements to participate in HTML forms as form-associated elements.
- What does
static formAssociated = truedo? It tells the browser the custom element can be associated with forms. - How do you submit a value from a custom element? Call
this._internals.setFormValue(value). - 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
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