A reusable <color-picker> custom element dispatches new CustomEvent('picked', { detail }) from a button inside its shadow root, and a listener attached to the element in the outer page never fires. What is wrong, and how should a component's outgoing events be configured?
answer
- two booleans, both default off
- one governs upward, one governs outward
- the shadow root is where it stops
- native click sets both; your event does not
- retargeting hides your internals from consumers
basics
~20 sCustomEvent defaults to bubbles: false and composed: false, so an event fired inside a shadow root stops at that boundary. A component's public events must be created with bubbles: true and composed: true to reach listeners in the outer page.
solid answer
~40 sBoth flags default to `false` on `CustomEvent`, and each blocks the event separately. Without `bubbles: true` the event never leaves the button it was dispatched on. Without `composed: true` it cannot cross the shadow boundary, so even a bubbling event dies at the shadow root and never reaches the host's ancestors. A component's outward-facing events should therefore be `new CustomEvent('picked', { detail, bubbles: true, composed: true })`. Dispatching on the host itself (`this.dispatchEvent(...)`) already puts the event in the outer tree, so `bubbles` alone suffices there — but `composed: true` still matters, because the host may itself be nested inside someone else's shadow root. Keep events that are genuinely internal non-composed, use `detail` for the payload, and prefer a namespaced name so you never shadow a native event.
code
javascript · 18 linesclass ColorPicker extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<button type="button">pick</button>';
root.querySelector('button').addEventListener('click', () => {
const event = new CustomEvent('picked', {
detail: { value: '#ff0000' },
bubbles: true,
composed: true,
cancelable: true,
});
if (this.dispatchEvent(event)) {
this.setAttribute('value', '#ff0000');
}
});
}
}
customElements.define('color-picker', ColorPicker);go deeper
Remember that a CustomEvent does nothing by default: you must pass bubbles: true, and detail is where the payload goes.
Explain the two flags separately — bubbles for upward propagation, composed for crossing the shadow boundary — and say exactly where an event stops when each one is missing.
Show judgment about the component's event contract: which events are public and composed, which stay internal, when to make one cancelable, and why dispatch must be synchronous with the gesture.
Own the cross-component event vocabulary for a library — naming and namespacing, payload shape stability, cancelable semantics — and the migration cost of changing an event contract consumers already listen for.
## Two independent flags Every event carries two booleans that decide how far it travels. - `bubbles` — after reaching the target, does the event propagate up through the target's ancestors? - `composed` — may the event cross a shadow boundary into the light tree that contains the host? `new Event(type)` and `new CustomEvent(type, init)` default **both** to `false`. That is the opposite of what most people assume, because the native events they are used to — `click`, `input`, `keydown`, `pointerdown` — are created by the browser with `bubbles: true, composed: true`. So a hand-made event behaves nothing like a click unless you say so. ```js this.dispatchEvent(new CustomEvent('picked', { detail: { value: '#ff0000' }, bubbles: true, composed: true, })); ``` ## Why the failure looks mysterious Inside the component everything works: a listener on the shadow root or on an ancestor *inside* the shadow tree fires. The event is real, `detail` is intact, DevTools shows the dispatch. It simply stops at the shadow root, which is the outermost node of the shadow tree, and never continues to the host element's parent. Because the boundary is invisible in the Elements panel unless you expand `#shadow-root`, the symptom reads as "my event vanished". The two flags fail in different places, and it is worth knowing which one you are missing: - Missing `bubbles`: the event does not even reach the shadow root. A listener on the shadow root itself will not fire either (except in the capture phase, which still runs to the target). - Missing `composed`: the event bubbles fine inside the shadow tree, a listener on the shadow root fires, and then it stops. ## Dispatching from the host versus from inside Where you dispatch matters as much as the flags. If the element dispatches on itself — `this.dispatchEvent(...)` — the target is the host, which lives in the outer tree, so no boundary has to be crossed for the page's listeners to see it and `bubbles: true` is enough. This is the cleaner design: the component's public events come *from the component*, not from an implementation detail inside it. `composed: true` still earns its place, because your component may be used inside somebody else's shadow root — a `<color-picker>` slotted into a `<settings-panel>`. Then the host is itself inside a shadow tree, and a non-composed event stops at *that* boundary. Setting both flags makes the event behave the way a native `click` does at every level of nesting. ## The native precedent for non-composed events Not every native event is composed, and the exceptions are instructive. `change` and `select` are not composed — the platform's own position is that they are the internal business of the control that fired them. `slotchange` bubbles but is not composed. Use the same judgment: an event that means "something happened inside my implementation" should stay in; an event that is part of the component's contract with its consumer should be composed and bubbling. ## What a listener actually sees Once a composed event crosses the boundary, `event.target` is **retargeted** to the host, so the outer page sees `<color-picker>` rather than the internal button. That is exactly what you want: consumers must not learn your internals. The full path, including the shadow nodes, is available to code inside the component via `event.composedPath()`, whose first entry is the true innermost target. ## Designing the event contract A few rules that survive contact with real consumers: - **Namespace the name.** `picked` or `color-picker-change` rather than `change`, so a listener for the native event does not get yours, and vice versa. - **Put the payload in `detail`.** It is the only slot on `CustomEvent` for arbitrary data, and it is a plain reference — no serialization. - **Decide about `cancelable`.** If consumers may veto the action, pass `cancelable: true`, dispatch *before* you act, and honour `preventDefault()` by checking `dispatchEvent`'s return value, which is `false` when a handler cancelled. - **Dispatch synchronously from the interaction**, not after an `await`, so `preventDefault` and the user-activation state still mean something. The short version for an interview: `CustomEvent` defaults both flags to `false`, `bubbles` controls upward propagation and `composed` controls boundary crossing, and a component's public events need both.
- If the element dispatches on itself rather than on an internal node, is composed: true still necessary?Not for the immediate page — the host is already in the outer tree, so `bubbles: true` gets the event to its ancestors. But if your component is used inside another component's shadow root, the host sits in a shadow tree and a non-composed event stops at that boundary. Setting both flags makes nesting work.
- Once a composed event reaches the outer page, what does event.target point at, and how do you see the real one?It is retargeted to the host element, so consumers see `<color-picker>` and not your internal button — deliberate encapsulation. Code that needs the true innermost target calls `event.composedPath()`, whose first entry is the original target, with the shadow nodes listed before the host.
- How do you let a consumer cancel the action your component is about to take?Create the event with `cancelable: true` and dispatch it before doing the work. `dispatchEvent` returns `false` if any handler called `preventDefault()`, so branch on that return value. Dispatch synchronously inside the user gesture — after an await the cancellation contract and user activation are already gone.
saying these in an interview costs you the question
- Assuming CustomEvent bubbles by default
- Thinking bubbles: true alone escapes a shadow root
- Believing composed only affects the capture phase
- Naming a public event 'change' and colliding with the native one
- Expecting event.target to expose the inner shadow node