Submit Events and composed: false in Shadow DOM

Aug 26, 2026·24 min read

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 submit event bubbles from its form but has composed: 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: true lets the event travel from the form to ancestors in its propagation path.
  • cancelable: true lets a listener call preventDefault() to stop submission.
  • composed: false prevents 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.

shadow treeform: submitstartsshadow-rootlistenerbubbles: truecomposed: falseboundary stays closedhost → document
Bubbling carries submit to the shadow root, but composed: false closes the exit.

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.

1. click2. submitbutton targetshadow roothostdocumenthears itcomposedform targetshadow rootSTOPnot the same event
One activation creates two events with different starting points and different paths.

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 formExternal formcomponent hostshadow treeformsubmit stays insidedocumentformcomponentsubmit reaches document
Move the form, and the submit event receives a different ancestor path.

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 eventForm listenerShadow-root listenerHost listenerDocument listener
Internal Form clickYesYesYesYes
Internal Form submitYesYesNoNo
External Form clickYesNoNoYes
External Form submitYesNoNoYes

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.

Open rootClosed rootoutside composedPath()buttonshadow roothost →documentoutside composedPath()internal nodeshidden outsidehost →documentBoth crossing clicks still reach document.
Open and closed roots change what an outside listener can inspect, not whether this click crosses.

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.

Privateformsubmithandle and saveinside componentNo publiceventPubliceventsubmitcomponent-submitconvert at thecomponent hostPublic contractExternalformpageformcustomcontrolElementInternalssupplies valueChoose by ownership, not by how to force submitacross a boundary.
Three component boundaries answer three different ownership needs.

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.

requestSubmit()fields valid?check firstnono submiteventyesfires submitsubmit()skipsbothsubmit formdataNo event means there is no propagation path todebug.
The two similarly named methods enter different parts of the form lifecycle.

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.

light DOM form #account-formcustom-element hostshadow boundaryinternal inputform=account-formID lookupcannot crossElementInternalsjoins host to formAssociation happens at the light DOM host, notthrough the internal input.
An internal input cannot name a form in another tree; the host must participate instead.

When a listener stays silent, check the path in this order:

  1. Confirm that a submit event was fired rather than observing only the earlier composed click.
  2. Read event.target; native submit starts at the form.
  3. Read event.submitter; this identifies the triggering submit button.
  4. Inspect bubbles, cancelable, and composed separately.
  5. Log the submit event’s composedPath() from an internal listener, then compare it with the earlier composed click path from an outside listener.
  6. Find every shadow boundary above the form.
  7. Check constraint validation and whether code called submit() instead of requestSubmit().
  8. Check whether another listener called stopPropagation() or stopImmediatePropagation().
  9. 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.

document treecomponent hostslotlight DOMformcontinuesoutwardThe form isstill owned bythis tree.Slotted placement is not the same as movingthe form into shadow DOM.
A slot changes the route through the composed tree, but not the form’s document root.

Frequently asked questions

Does the submit event cross a shadow DOM boundary?
A native submit event has bubbles set to true but composed set to false. It bubbles inside the tree containing its form, but it does not cross a shadow boundary above a form inside that shadow root.
Why does click reach document when submit does not?
The click and submit are separate events with separate paths. A native click is composed and can cross the boundary, while the later submit event starts at the form with composed set to false.
Does a closed shadow root change submit.composed?
No. Open and closed shadow roots produce the same false value for submit.composed and stop the same internal submit event at the boundary. Closed mode changes which internal nodes outside listeners can see through composedPath().
Should I call requestSubmit() or submit()?
Use requestSubmit() when you want normal form behavior, including constraint validation and the submit event. HTMLFormElement.submit() skips interactive validation and does not fire submit.
Can an input inside shadow DOM use form to join a light DOM form?
Its form attribute resolves an ID within the input's own tree, so it cannot find a light DOM form across the shadow boundary. Use a form-associated custom element with ElementInternals when the host must participate in an outer form.