Submit Events and composed: false in Shadow DOM
You click a submit button inside a web component. A listener on document sees the click, but its submit listener stays silent, even though both events came from the same action.
A native
submitevent bubbles from its form but hascomposed: false, so it cannot cross a shadow boundary when that form is inside a shadow root.
The Short Answer: submit Does Not Cross the Boundary
The browser fires submit at the form, not at the button you clicked. The button is available separately as event.submitter.
The HTML form-submission algorithm creates a SubmitEvent with bubbles: true and cancelable: true. It does not enable composed, and the DOM event defaults leave that flag set to false.
Those three flags answer different questions:
bubbles: truelets the event travel from the form to ancestors in its propagation path.cancelable: truelets a listener callpreventDefault()to stop submission.composed: falseprevents an event targeted inside a shadow tree from continuing past that target tree’s shadow root.
Bubbling does not imply boundary crossing.
If the form is inside a shadow root, submit reaches listeners on the form and that shadow root. It does not reach the shadow host or document. If the form is in ordinary, unslotted light DOM, there is no shadow boundary above it, so the same composed: false event can bubble to document.
That last case matters. composed: false does not make an event private everywhere. It keeps an event targeted inside a shadow tree from escaping that target tree. A light DOM form assigned to a slot is an exception to the simpler boundary rule: its path can pass through the slot’s shadow tree and continue to the host and document because the form’s root is still the document.
bubbles and composed Control Different Parts of the Path
A shadow root is the root of a component’s internal node tree. The shadow host is the light DOM element to which that root is attached. The edge between those trees is the shadow boundary, and nodes outside the shadow tree remain in the light DOM.
Shadow DOM provides the component structure. The event system then builds a propagation path from the event target through its ancestors.
The event target is the object where dispatch begins. During native form submission, that target is the <form>. event.currentTarget is different: it is the object whose listener is running at that moment.
Say a component contains this structure:
document
signup-panel
#shadow-root
form
button
The native click starts at the button. A browser-generated click is composed, so it can cross the boundary and continue through signup-panel to document.
The resulting submit starts later, at the form:
form -> #shadow-root -> stop
Its bubbles flag moves it upward within the shadow tree. Its composed flag prevents the shadow root from contributing the host as the next event parent. Bubbling and capturing explains the phases within a propagation path; Shadow DOM and events covers the extra boundary rules.
For an event that does cross, retargeting protects the component’s internal structure. A listener outside the shadow root commonly sees the host as event.target, while an internal listener sees the original internal target.
event.composedPath() exposes the invocation targets available from the listener’s position. It answers a better debugging question than target alone: which objects did this event actually travel through?
The name composed is not a label for all native events. Each event-firing algorithm chooses its flags. A CustomEvent follows the options you pass, and all three inherited event flags default to false:
const event = new CustomEvent('component-submit', {
detail: { email: '[email protected]' },
bubbles: true,
cancelable: true,
composed: true,
});
That event bubbles, can be canceled, and can cross a shadow boundary. Omitting composed: true produces a different path.
The Form’s Location Determines Who Receives submit
The decisive node is the form because submit begins there. The location of the clicked button matters to the earlier click, but it does not move the later event’s target away from the form.
Internal Form
In the Internal Form layout, the component owns both the form and its submit button:
document
checkout-panel
#shadow-root
form
button
The button’s click can reach the host and document. Its activation then starts form submission, and the browser fires submit at the internal form. That event bubbles to the shadow root and stops.
A listener on the host sees the click, usually with the host retargeted as event.target. It never receives the submit, so there is nothing to retarget for that listener.
External Form
In the External Form layout, the native form belongs to the light DOM:
document
form
account-field
button
Here the button and form are both outside the component’s shadow root. The submit event starts at the light DOM form, bubbles through its light DOM ancestors, and reaches document. It has not crossed a shadow boundary because no such boundary lies above that form.
The custom-element host is a child of the form, not an ancestor of it. Events do not bubble downward, so a listener on account-field does not receive the form’s submit.
The two layouts produce this listener matrix:
| Layout and event | Form listener | Shadow-root listener | Host listener | Document listener |
|---|---|---|---|---|
Internal Form click | Yes | Yes | Yes | Yes |
Internal Form submit | Yes | Yes | No | No |
External Form click | Yes | No | No | Yes |
External Form submit | Yes | No | No | Yes |
The Internal Form rows trace two events from one activation. The composed click escapes; the non-composed submit does not.
The External Form rows have no shadow root in either path. composed: false never becomes a barrier because the path does not meet a boundary.
Forms: event and method submit takes the native form lifecycle further, including cancellation and programmatic submission.
A Runnable Test That Exposes the Event Path
The next file isolates the internal-form case with open and closed instances. It logs type, target, currentTarget, submitter, bubbles, cancelable, composed, and composedPath(). The external-form page later in the article uses the same fields for the light-DOM comparison.
This is a complete standalone diagnostic page:
<!doctype html>
<meta charset="utf-8">
<title>Shadow DOM submit paths</title>
<submit-probe id="open-probe" mode="open"></submit-probe>
<submit-probe id="closed-probe" mode="closed"></submit-probe>
<script>
function nodeName(node) {
if (node instanceof ShadowRoot) return '#shadow-root';
if (node === document) return '#document';
if (node === window) return 'window';
return node.localName ?? node.constructor.name;
}
function logEvent(listener, event) {
console.log({
listener,
type: event.type,
target: nodeName(event.target),
currentTarget: nodeName(event.currentTarget),
submitter: event.submitter ? nodeName(event.submitter) : null,
bubbles: event.bubbles,
cancelable: event.cancelable,
composed: event.composed,
composedPath: event.composedPath().map(nodeName),
});
}
class SubmitProbe extends HTMLElement {
connectedCallback() {
if (this.dataset.ready) return;
this.dataset.ready = 'true';
const mode = this.getAttribute('mode') === 'closed' ? 'closed' : 'open';
const root = this.attachShadow({ mode });
root.innerHTML = `
<form>
<label>
Email
<input name="email" type="email" value="[email protected]">
</label>
<button type="submit">Submit ${mode} form</button>
</form>
`;
const form = root.querySelector('form');
for (const target of [form, root, this]) {
for (const type of ['click', 'submit']) {
target.addEventListener(type, event => {
logEvent(`${mode}:${nodeName(target)}`, event);
});
}
}
form.addEventListener('submit', event => {
event.preventDefault();
});
}
}
customElements.define('submit-probe', SubmitProbe);
for (const type of ['click', 'submit']) {
document.addEventListener(type, event => {
logEvent('document', event);
});
}
</script>
Click either button and keep the two event types separate while reading the console. The click records continue through the host to document, while the submit records end after the shadow-root listener.
For submit, target is form, submitter is button, bubbles and cancelable are true, and composed is false. Calling preventDefault() works because the event is cancelable and keeps the diagnostic page from navigating.
Open and closed mode do not change those flags. They also do not change where the internal submit stops.
The difference appears in the outside listener’s composedPath() for the crossing click. An outside listener can see internal open-root nodes in the returned path, while nodes hidden inside a closed root are omitted when they are not reachable from that listener’s currentTarget. The click target is retargeted to the host in both cases.
Done? Not quite. Seeing the path explains the event, but a component still needs an explicit form contract.
Three Correct Form Component Patterns
The three patterns below are standalone alternatives. Do not concatenate their class definitions into the diagnostic page.
Private Internal Form
Use a private internal form when submission is an implementation detail. Handle submit inside the shadow root, call preventDefault(), and perform the component’s action there.
This complete component keeps its native event internal:
class PrivateSignup extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<form>
<label>
Email
<input name="email" type="email" required>
</label>
<button type="submit">Create account</button>
</form>
`;
root.querySelector('form').addEventListener('submit', event => {
event.preventDefault();
const data = new FormData(event.currentTarget);
this.saveAccount({
email: data.get('email'),
submitter: event.submitter,
});
});
}
saveAccount(data) {
// send data through the component's private workflow
}
}
customElements.define('private-signup', PrivateSignup);
The form listener receives the event because it sits at the target. Code outside the component receives no submit, and the component makes no claim that it should.
FormData JavaScript: Forms, Files, and Fetch covers reading repeated fields and files when the internal form grows beyond one value.
Public Component Event
Use a distinct public event when outside code needs to react to an internal submission. Handle the native event internally, then dispatch component-submit from the host.
This complete replacement gives the component an explicit public contract:
class PublicSignup extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<form>
<label>
Email
<input name="email" type="email" required>
</label>
<button type="submit">Create account</button>
</form>
`;
root.querySelector('form').addEventListener('submit', event => {
event.preventDefault();
const detail = {
email: new FormData(event.currentTarget).get('email'),
};
const accepted = this.dispatchEvent(new CustomEvent('component-submit', {
detail,
bubbles: true,
cancelable: true,
composed: true,
}));
if (accepted) {
this.saveAccount(detail);
}
});
}
saveAccount(data) {
// continue only when the public event was not canceled
}
}
customElements.define('public-signup', PublicSignup);
The native submit remains internal. The separately named event starts at the host, carries a documented value in detail, crosses boundaries because composed is enabled, and gives consumers a cancellation point.
Do not synthesize another event named submit. That blurs a component notification together with the browser’s native form algorithm. Dispatching custom events covers the constructor options and cancellation return value.
External Native Form with ElementInternals
Use an external native form when the page owns submission and the component represents one control. A form-associated custom element lets its light DOM host contribute a value without making an internal native form’s submit escape.
This complete page defines one form-associated field and logs the external form’s submit at the form, custom-element host, and document. The form and document listeners run; the host listener stays silent because the host is a control inside the form, not an ancestor of the event target:
<!doctype html>
<meta charset="utf-8">
<title>Form-associated account field</title>
<form id="account-form">
<account-handle name="handle"></account-handle>
<button type="submit">Create account</button>
</form>
<script>
function nodeName(node) {
if (node === document) return '#document';
if (node === window) return 'window';
return node.localName ?? node.constructor.name;
}
function logSubmit(listener, event) {
console.log({
listener,
type: event.type,
target: nodeName(event.target),
currentTarget: nodeName(event.currentTarget),
submitter: event.submitter ? nodeName(event.submitter) : null,
bubbles: event.bubbles,
cancelable: event.cancelable,
composed: event.composed,
path: event.composedPath().map(nodeName),
});
}
const accountForm = document.querySelector('#account-form');
const accountField = document.querySelector('account-handle');
accountForm.addEventListener('submit', event => logSubmit('form', event));
accountField.addEventListener('submit', event => logSubmit('host', event));
document.addEventListener('submit', event => logSubmit('document', event));
class AccountHandle extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<label>
Account handle
<input type="text" autocomplete="username">
</label>
`;
const input = root.querySelector('input');
const updateValue = () => {
this.internals.setFormValue(input.value);
};
input.addEventListener('input', updateValue);
updateValue();
}
}
customElements.define('account-handle', AccountHandle);
document.querySelector('#account-form').addEventListener('submit', event => {
event.preventDefault();
const data = new FormData(event.currentTarget);
console.log(data.get('handle'));
});
</script>
static formAssociated = true makes the host a form-associated custom element. attachInternals() supplies its ElementInternals, and setFormValue() provides the value associated with the host’s name.
The native form remains in the light DOM. Its submit starts at that form and reaches document through the ordinary light DOM path. ElementInternals does not change the composed flag of an internal form event.
This is the component boundary used throughout The Browser Platform: browser behavior stays native where the page owns it, while the component exposes the smallest contract its consumer needs.
Common Traps and a Debugging Checklist
A host-dispatched event explains one result that often looks contradictory. If a light DOM host dispatches a bubbling CustomEvent with composed: false, document can still receive it because the event starts outside the shadow root:
component.dispatchEvent(new CustomEvent('component-submit', {
bubbles: true,
composed: false,
}));
There is no shadow boundary between that host and document. If the same host is itself inside another shadow root, the event stops at that outer boundary.
Stencil-style emitters follow the same observable rules once they produce a DOM event. Check the actual dispatch target, event.bubbles, and event.composed rather than inferring the path from the framework method’s name.
Programmatic submission has another split. form.requestSubmit() follows normal submission behavior: it performs interactive constraint validation and fires submit when validation succeeds. form.submit() bypasses that validation block and does not fire the event.
Failed validation can therefore make a propagation test look broken. If an invalid required field stops submission, no submit exists to reach any listener. The browser returns before firing it.
A native control inside a shadow tree also cannot use form="account-form" to find a light DOM form. The ID lookup for form stays within the control’s tree. Put the custom-element host in the external form and use ElementInternals, as the third pattern does.
When a listener stays silent, check the path in this order:
- Confirm that a
submitevent was fired rather than observing only the earlier composedclick. - Read
event.target; nativesubmitstarts at the form. - Read
event.submitter; this identifies the triggering submit button. - Inspect
bubbles,cancelable, andcomposedseparately. - Log the
submitevent’scomposedPath()from an internal listener, then compare it with the earlier composedclickpath from an outside listener. - Find every shadow boundary above the form.
- Check constraint validation and whether code called
submit()instead ofrequestSubmit(). - Check whether another listener called
stopPropagation()orstopImmediatePropagation(). - If submission was observed but navigation did not occur, check whether a listener called
preventDefault().
The path settles the question. For an internal form it ends at the target tree’s shadow root; for an unslotted light DOM form it continues to document without crossing one. A slotted light DOM form can pass through a shadow tree and continue outward because its root is still the document.