Skip to main content

Shadow DOM and Events in JavaScript

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>).

info

Event retargeting only changes event.target. The event.currentTarget always refers to the element the listener is attached to, regardless of shadow boundaries.

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):

CategoryEvents
Mouseclick, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave, mouseover, mouseout, contextmenu
Pointerpointerdown, pointerup, pointermove, pointerover, pointerout, pointerenter, pointerleave, gotpointercapture, lostpointercapture
Touchtouchstart, touchend, touchmove, touchcancel
Keyboardkeydown, keyup
Focusfocus, blur, focusin, focusout
Inputinput, beforeinput, compositionstart, compositionupdate, compositionend
Wheelwheel
Otherscroll, select, DOMContentLoaded

Non-composed events (composed: false):

EventDescription
changeDoes not cross shadow boundaries
loadDoes not cross shadow boundaries
errorDoes not cross shadow boundaries
abortDoes not cross shadow boundaries
resetDoes not cross shadow boundaries
submitDoes not cross shadow boundaries
resizeDoes not cross shadow boundaries
slotchangeDoes not cross shadow boundaries
warning

The change event is not composed. This is a common source of confusion. If you have an <input> inside a shadow tree and listen for change outside the component, you will never receive it. You need to manually re-dispatch the event or use input (which is composed) instead.

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>
tip

composedPath() gives you the full truth about where the event came from. event.target gives you the encapsulated truth, respecting shadow DOM boundaries.

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.

note

With mode: 'open', composedPath() always returns the full path regardless of where the listener is attached. With mode: 'closed', the path is restricted for outside listeners.

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)
tip

Use delegatesFocus: true for components that wrap form controls. It ensures the component behaves naturally when users tab through forms or click on labels.

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.

warning

When dispatching on the host element (this), the event starts outside the shadow tree. You do not need composed: true in this case because the event never crosses a shadow boundary from inside to outside. However, it is still a good practice to include composed: true for consistency and in case the component is used inside another shadow tree.

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

AspectBehavior
Retargetingevent.target is changed to the host element for outside listeners
Composed eventsCross shadow boundaries (click, input, focus, keydown, etc.)
Non-composed eventsStop 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 eventsNot composed by default; set composed: true explicitly
Focus delegationUse delegatesFocus: true for automatic inner focusing
:focus-withinWorks on :host when any inner element has focus
info

When building web components, always think about the event contract of your component. Decide which events should be part of your public API, dispatch them on the host element, and document them. This makes your components predictable and easy to integrate with any framework or vanilla JavaScript code.