Skip to content

Shadow DOM and Events in JavaScript

How events work in Shadow DOM, including event retargeting, the composed property, composedPath(), and focus/custom event behaviors for building reliable web components.

Shadow DOM provides powerful encapsulation for web components, keeping internal structure and styles isolated from the rest of the page. But this encapsulation fundamentally changes how events behave. Events that originate inside a shadow tree do not simply bubble up like regular DOM events. Instead, they are retargeted, some are blocked at shadow boundaries, and their propagation paths are altered.

Understanding how events interact with Shadow DOM is essential for building reliable, interactive web components. In this guide, you will learn how event retargeting works, which events can cross shadow boundaries, how to trace the full event path with composedPath(), and how focus events and custom events behave inside shadow trees.

Event Retargeting in Shadow DOM

When an event originates from an element inside a shadow tree and bubbles up past the shadow root, the browser automatically changes the event's target property. This mechanism is called event retargeting.

Why Retargeting Exists

The purpose of Shadow DOM is encapsulation. Outside code should not know (or care) about the internal structure of a component. If a user clicks a <span> inside a component's shadow tree, the outside world should only see a click on the host element itself. The internal <span> is an implementation detail.

How It Works

When an event bubbles out of a shadow root, the browser replaces event.target with the shadow host element. This makes it look like the event originated from the host, not from the internal element.

class MyButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        button {
          background: #3b82f6;
          color: white;
          border: none;
          padding: 10px 20px;
          border-radius: 6px;
          cursor: pointer;
          font-size: 16px;
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}

customElements.define('my-button', MyButton);
<my-button id="btn">Click Me</my-button>

<script>
  document.getElementById('btn').addEventListener('click', (event) => {
    console.log('event.target:', event.target);
    console.log('Tag name:', event.target.tagName);
  });
</script>

Output when you click the button:

event.target: <my-button id="btn">Click Me</my-button>
Tag name: MY-BUTTON

Even though the actual click happened on the internal <button> element inside the shadow tree, the event's target is reported as the <my-button> host element. The internal structure stays hidden.

Retargeting Happens at Each Shadow Boundary

If you have nested shadow DOMs (a shadow tree inside another shadow tree), retargeting occurs at each shadow boundary as the event propagates upward.

class InnerWidget extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<span class="inner-text">Hello from inner</span>`;
  }
}

class OuterWidget extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<inner-widget></inner-widget>`;
  }
}

customElements.define('inner-widget', InnerWidget);
customElements.define('outer-widget', OuterWidget);
<outer-widget id="outer"></outer-widget>

<script>
  document.getElementById('outer').addEventListener('click', (event) => {
    console.log('event.target:', event.target.tagName);
    // Output: "OUTER-WIDGET"
  });
</script>

The click on the <span> inside inner-widget's shadow tree gets retargeted to <inner-widget> at the first boundary, and then retargeted again to <outer-widget> at the second boundary.

Retargeting Within the Same Shadow Tree

Inside the shadow tree itself, retargeting does not apply. Listeners attached within the same shadow root see the original event.target.

class MyCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <div class="card">
        <h2 class="title">Card Title</h2>
        <p class="body">Card content goes here.</p>
      </div>
    `;

    // Listener INSIDE the shadow tree
    shadow.addEventListener('click', (event) => {
      console.log('Inside shadow - target:', event.target.className);
    });
  }
}

customElements.define('my-card', MyCard);
<my-card id="card"></my-card>

<script>
  // Listener OUTSIDE the shadow tree
  document.getElementById('card').addEventListener('click', (event) => {
    console.log('Outside shadow - target:', event.target.tagName);
  });
</script>

Output when clicking the title:

Inside shadow - target: title
Outside shadow - target: MY-CARD

The internal listener sees the real target (<h2 class="title">), while the external listener sees the retargeted host element (<my-card>).

composed: true: Events That Cross Shadow Boundaries

Not all events escape the shadow tree. Whether an event can cross a shadow boundary depends on its composed property.

The composed Property

Every event has a boolean composed property:

  • composed: true means the event can cross shadow DOM boundaries and propagate into the light DOM.
  • composed: false means the event stops at the shadow root and does not propagate outside.

Which Native Events Are Composed?

Most UI events that represent direct user interaction are composed. Events that are more specific to internal DOM behavior are typically not composed.

Composed events (composed: true):

Category Events
Mouse click, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave, mouseover, mouseout, contextmenu
Pointer pointerdown, pointerup, pointermove, pointerover, pointerout, pointerenter, pointerleave, gotpointercapture, lostpointercapture
Touch touchstart, touchend, touchmove, touchcancel
Keyboard keydown, keyup
Focus focus, blur, focusin, focusout
Input input, beforeinput, compositionstart, compositionupdate, compositionend
Wheel wheel
Other scroll, select, DOMContentLoaded

Non-composed events (composed: false):

Event Description
change Does not cross shadow boundaries
load Does not cross shadow boundaries
error Does not cross shadow boundaries
abort Does not cross shadow boundaries
reset Does not cross shadow boundaries
submit Does not cross shadow boundaries
resize Does not cross shadow boundaries
slotchange Does not cross shadow boundaries

Demonstrating Non-Composed Events

class MyInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<input type="text" placeholder="Type something..." />`;
  }
}

customElements.define('my-input', MyInput);
<my-input id="myInput"></my-input>

<script>
  const el = document.getElementById('myInput');

  // This will NEVER fire: 'change' is not composed
  el.addEventListener('change', () => {
    console.log('change event received outside');
  });

  // This WILL fire: 'input' is composed
  el.addEventListener('input', (event) => {
    console.log('input event received outside');
  });
</script>

Output after typing in the field and pressing Tab:

input event received outside

The change event is silently consumed inside the shadow tree.

Re-Dispatching Non-Composed Events

To make non-composed events available outside the shadow tree, you can listen for them internally and re-dispatch a new composed event:

class MyInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<input type="text" placeholder="Type something..." />`;

    const input = shadow.querySelector('input');

    // Listen internally, then re-dispatch as composed
    input.addEventListener('change', (event) => {
      this.dispatchEvent(new Event('change', {
        bubbles: true,
        composed: true
      }));
    });
  }
}

customElements.define('my-input', MyInput);

Now external listeners can catch the change event:

<my-input id="myInput"></my-input>

<script>
  document.getElementById('myInput').addEventListener('change', () => {
    console.log('change event received outside!');
  });
</script>

Output after changing the value and blurring:

change event received outside!

The Relationship Between bubbles and composed

These are two independent properties, but they interact:

  • bubbles: true, composed: true: The event bubbles through the shadow boundary and continues bubbling through ancestor elements.
  • bubbles: true, composed: false: The event bubbles inside the shadow tree but stops at the shadow root.
  • bubbles: false, composed: true: The event crosses the shadow boundary but does not bubble. It fires on ancestors only during the capture phase.
  • bubbles: false, composed: false: The event stays entirely within the shadow tree and does not propagate at all.
// Example: bubbles + composed combinations
const event1 = new Event('my-event', { bubbles: true, composed: true });
// Crosses shadow boundary AND bubbles up

const event2 = new Event('my-event', { bubbles: true, composed: false });
// Bubbles inside shadow tree only

const event3 = new Event('my-event', { bubbles: false, composed: true });
// Crosses shadow boundary but does not bubble

const event4 = new Event('my-event', { bubbles: false, composed: false });
// Stays put: fires only on the target

event.composedPath()

While event retargeting hides the internal structure from outside listeners, sometimes you do need to know the full propagation path, including elements inside shadow trees. The composedPath() method provides exactly that.

What composedPath() Returns

event.composedPath() returns an array of all the elements the event will pass through, from the original target all the way up to the Window object, including elements inside shadow trees.

class MyAlert extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <div class="alert-box">
        <span class="message">
          <strong>Important:</strong> <slot></slot>
        </span>
      </div>
    `;

    shadow.addEventListener('click', (event) => {
      const path = event.composedPath();
      console.log('Full composed path:');
      path.forEach((node, index) => {
        if (node.tagName) {
          console.log(`  [${index}] <${node.tagName.toLowerCase()}> class="${node.className || ''}"` );
        } else if (node === shadow) {
          console.log(`  [${index}] #shadow-root`);
        } else if (node === document) {
          console.log(`  [${index}] #document`);
        } else if (node === window) {
          console.log(`  [${index}] Window`);
        }
      });
    });
  }
}

customElements.define('my-alert', MyAlert);
<div id="container">
  <my-alert>Something happened!</my-alert>
</div>

Output when clicking the "Important:" text:

Full composed path:
  [0] <strong> class=""
  [1] <span> class="message"
  [2] <div> class="alert-box"
  [3] #shadow-root
  [4] <my-alert> class=""
  [5] <div> class=""
  [6] <body> class=""
  [7] <html> class=""
  [8] #document
  [9] Window

The path includes every element from the innermost <strong> (inside the shadow tree) all the way through the shadow root, host element, and up to the Window.

composedPath() vs. event.target

Consider the difference between what outside listeners see:

<div id="wrapper">
  <my-alert>Click here</my-alert>
</div>

<script>
  document.getElementById('wrapper').addEventListener('click', (event) => {
    console.log('event.target:', event.target.tagName);
    // "MY-ALERT": retargeted

    const path = event.composedPath();
    console.log('Real origin:', path[0].tagName);
    // Could be "STRONG", "SPAN", etc. (the real element clicked=
  });
</script>

composedPath() With Closed Shadow DOM

When a shadow root is created with mode: 'closed', composedPath() behaves differently depending on where the listener is:

class ClosedWidget extends HTMLElement {
  constructor() {
    super();
    // Closed shadow root
    const shadow = this.attachShadow({ mode: 'closed' });
    shadow.innerHTML = `<button>Click inside closed shadow</button>`;

    // Listener INSIDE the closed shadow tree
    shadow.addEventListener('click', (event) => {
      console.log('Inside - path length:', event.composedPath().length);
      console.log('Inside - first element:', event.composedPath()[0].tagName);
    });
  }
}

customElements.define('closed-widget', ClosedWidget);
<closed-widget id="cw"></closed-widget>

<script>
  document.getElementById('cw').addEventListener('click', (event) => {
    console.log('Outside - path length:', event.composedPath().length);
    console.log('Outside - first element:', event.composedPath()[0].tagName);
  });
</script>

Output when clicking the button:

Inside - path length: 7
Inside - first element: BUTTON
Outside - path length: 5
Outside - first element: CLOSED-WIDGET

For closed shadow roots, composedPath() from outside the shadow tree truncates the path at the host element. The internal elements are hidden. Listeners inside the shadow tree still see the full path.

Practical Use: Detecting Clicks Outside a Component

composedPath() is invaluable for building "click outside to close" patterns with web components:

class MyDropdown extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        .menu { display: none; background: white; border: 1px solid #ccc; padding: 8px; }
        .menu.open { display: block; }
        button { padding: 8px 16px; }
      </style>
      <button class="trigger">Toggle Menu</button>
      <div class="menu">
        <p>Menu Item 1</p>
        <p>Menu Item 2</p>
        <p>Menu Item 3</p>
      </div>
    `;

    const trigger = shadow.querySelector('.trigger');
    const menu = shadow.querySelector('.menu');

    trigger.addEventListener('click', () => {
      menu.classList.toggle('open');
    });

    // Close when clicking outside
    document.addEventListener('click', (event) => {
      const path = event.composedPath();
      // If this component is NOT in the event path, close the menu
      if (!path.includes(this)) {
        menu.classList.remove('open');
      }
    });
  }
}

customElements.define('my-dropdown', MyDropdown);

By checking whether this (the host element) appears in the composed path, you can reliably determine if the click happened inside or outside the component, even with shadow DOM encapsulation in place.

Focus and Custom Events in Shadow DOM

Focus events and custom events have unique behaviors in Shadow DOM that require special attention.

Focus Events and Retargeting

Focus events (focus, blur, focusin, focusout) are composed, meaning they cross shadow boundaries. They are also retargeted, so outside listeners see the host element as the target.

class FocusInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <label>Name: <input type="text" class="inner-input" /></label>
    `;

    const input = shadow.querySelector('.inner-input');

    // Listen inside shadow
    input.addEventListener('focus', (event) => {
      console.log('Inside shadow - focus target:', event.target.className);
    });
  }
}

customElements.define('focus-input', FocusInput);
<focus-input id="fi"></focus-input>

<script>
  document.getElementById('fi').addEventListener('focus', (event) => {
    console.log('Outside shadow - focus target:', event.target.tagName);
  }, true); // Use capture phase since focus doesn't bubble
</script>

Output when clicking the input field:

Inside shadow - focus target: inner-input
Outside shadow - focus target: FOCUS-INPUT

delegatesFocus Option

When attaching a shadow root, you can pass delegatesFocus: true to change how focus behaves for the host element:

class DelegatingInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({
      mode: 'open',
      delegatesFocus: true
    });
    shadow.innerHTML = `
      <style>
        :host(:focus-within) {
          outline: 2px solid #3b82f6;
          border-radius: 4px;
        }
        input {
          border: 1px solid #ccc;
          padding: 8px;
          outline: none;
        }
      </style>
      <input type="text" placeholder="Focus delegates to me" />
    `;
  }
}

customElements.define('delegating-input', DelegatingInput);
<delegating-input tabindex="0"></delegating-input>

<script>
  const el = document.querySelector('delegating-input');

  // Without delegatesFocus: clicking the host focuses the host itself
  // With delegatesFocus: clicking the host focuses the first focusable element inside
  el.addEventListener('focus', () => {
    console.log('Host received focus');
    console.log('Active element:', document.activeElement.tagName);
  });
</script>

With delegatesFocus: true:

  • Clicking anywhere on the host element (even non-focusable areas) automatically focuses the first focusable element inside the shadow tree.
  • document.activeElement returns the host element, but host.shadowRoot.activeElement returns the focused inner element.
  • The :focus pseudo-class applies to the host when any inner element has focus.
// Checking active element with delegatesFocus
const host = document.querySelector('delegating-input');

// After focusing the inner input:
console.log(document.activeElement);
// <delegating-input> (the host)

console.log(host.shadowRoot.activeElement);
// <input> (the actual focused element inside)

The :focus-within Pseudo-Class

Even without delegatesFocus, you can use :host(:focus-within) to style the host when any element inside the shadow tree has focus:

class StyledInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: inline-block;
          padding: 4px;
          border: 2px solid transparent;
          border-radius: 6px;
          transition: border-color 0.2s;
        }
        :host(:focus-within) {
          border-color: #3b82f6;
        }
        input {
          border: 1px solid #ccc;
          padding: 8px;
          border-radius: 4px;
          outline: none;
        }
      </style>
      <input type="text" placeholder="Focus me" />
    `;
  }
}

customElements.define('styled-input', StyledInput);

Custom Events in Shadow DOM

Custom events created with new CustomEvent() are not composed by default. If you want a custom event to cross shadow boundaries, you must explicitly set composed: true.

Common mistake: Forgetting composed: true

class NotificationBanner extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <div class="banner">
        <span>New notification</span>
        <button class="dismiss">Dismiss</button>
      </div>
    `;

    shadow.querySelector('.dismiss').addEventListener('click', () => {
      // This event will NOT reach outside listeners
      this.dispatchEvent(new CustomEvent('notification-dismiss', {
        bubbles: true
        // composed is false by default!
      }));
    });
  }
}

customElements.define('notification-banner', NotificationBanner);
<notification-banner id="banner"></notification-banner>

<script>
  document.getElementById('banner').addEventListener('notification-dismiss', () => {
    console.log('Notification dismissed!');
    // This will NEVER fire because composed is false
  });
</script>

The fix: Add composed: true

shadow.querySelector('.dismiss').addEventListener('click', () => {
  this.dispatchEvent(new CustomEvent('notification-dismiss', {
    bubbles: true,
    composed: true  // Now the event crosses shadow boundaries
  }));
});

Passing Data with Custom Events

Use the detail property of CustomEvent to send data along with the event:

class UserCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        .card { border: 1px solid #ddd; padding: 16px; border-radius: 8px; }
        button { margin-top: 8px; padding: 6px 12px; cursor: pointer; }
      </style>
      <div class="card">
        <h3><slot name="name">Unknown User</slot></h3>
        <button class="select-btn">Select User</button>
      </div>
    `;

    shadow.querySelector('.select-btn').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('user-selected', {
        bubbles: true,
        composed: true,
        detail: {
          userId: this.getAttribute('user-id'),
          userName: this.querySelector('[slot="name"]')?.textContent || 'Unknown',
          timestamp: Date.now()
        }
      }));
    });
  }
}

customElements.define('user-card', UserCard);
<div id="user-list">
  <user-card user-id="101">
    <span slot="name">Alice</span>
  </user-card>
  <user-card user-id="102">
    <span slot="name">Bob</span>
  </user-card>
</div>

<script>
  // Event delegation on the parent: works across shadow boundaries
  document.getElementById('user-list').addEventListener('user-selected', (event) => {
    console.log('User selected:', event.detail);
  });
</script>

Output when clicking "Select User" on Alice's card:

User selected: { userId: "101", userName: "Alice", timestamp: 1700000000000 }

Where to Dispatch Custom Events

A key decision when dispatching custom events from web components is where to dispatch them. You have two options:

Option 1: Dispatch on the host element (this)

this.dispatchEvent(new CustomEvent('my-event', {
  bubbles: true,
  composed: true
}));

This is the recommended approach. The host element is the public API surface of your component. Outside code attaches listeners to the host, so dispatching from it is natural and consistent.

Option 2: Dispatch on an internal element

shadow.querySelector('.internal-btn').dispatchEvent(new CustomEvent('my-event', {
  bubbles: true,
  composed: true
}));

This works, but the event must bubble up through the shadow tree and cross the boundary. With composed: true, it will reach outside listeners, but event.target will be retargeted to the host. Dispatching directly on this is cleaner and avoids unnecessary propagation.

Preventing Default on Custom Events

Custom events support cancelable: true, allowing consumers to prevent default behavior:

class ConfirmDialog extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        .dialog { border: 2px solid #e5e7eb; padding: 20px; border-radius: 8px; }
        .actions { margin-top: 12px; display: flex; gap: 8px; }
        button { padding: 8px 16px; cursor: pointer; border-radius: 4px; border: 1px solid #ccc; }
        .confirm { background: #22c55e; color: white; border: none; }
        .cancel { background: #ef4444; color: white; border: none; }
      </style>
      <div class="dialog">
        <p><slot>Are you sure?</slot></p>
        <div class="actions">
          <button class="confirm">Confirm</button>
          <button class="cancel">Cancel</button>
        </div>
      </div>
    `;

    shadow.querySelector('.confirm').addEventListener('click', () => {
      const event = new CustomEvent('confirm-action', {
        bubbles: true,
        composed: true,
        cancelable: true,  // Allow consumers to prevent it
        detail: { action: 'confirm' }
      });

      const wasNotPrevented = this.dispatchEvent(event);

      if (wasNotPrevented) {
        console.log('Action confirmed - proceeding.');
        // Perform the action
      } else {
        console.log('Action was prevented by a listener.');
        // Do not proceed
      }
    });
  }
}

customElements.define('confirm-dialog', ConfirmDialog);
<confirm-dialog id="dialog">Delete all files?</confirm-dialog>

<script>
  document.getElementById('dialog').addEventListener('confirm-action', (event) => {
    // Prevent the action if some condition is not met
    const userHasPermission = false;

    if (!userHasPermission) {
      event.preventDefault();
      console.log('Permission denied - action prevented');
    }
  });
</script>

Output when clicking "Confirm":

Permission denied - action prevented
Action was prevented by a listener.

Summary Table: Event Behavior in Shadow DOM

Aspect Behavior
Retargeting event.target is changed to the host element for outside listeners
Composed events Cross shadow boundaries (click, input, focus, keydown, etc.)
Non-composed events Stop at shadow root (change, load, submit, reset, etc.)
composedPath() Returns full path including shadow internals (for open mode)
Closed shadow + composedPath() Path is truncated for outside listeners
Custom events Not composed by default; set composed: true explicitly
Focus delegation Use delegatesFocus: true for automatic inner focusing
:focus-within Works on :host when any inner element has focus
Navigation