Skip to content

Composing Components

The simplest form of composition is nesting: an outer custom element creates one or more inner custom elements inside connectedCallback. Define the inner elements first so the registry knows about them before the outer element tries to use them.

// Inner element — defined first
class InnerWidget extends HTMLElement {
connectedCallback() {
this.textContent = 'I am the inner widget';
}
}
customElements.define('inner-widget', InnerWidget);
// Outer element — creates the inner one programmatically
class OuterWidget extends HTMLElement {
connectedCallback() {
var inner = document.createElement('inner-widget');
this.appendChild(inner);
}
}
customElements.define('outer-widget', OuterWidget);

Place the outer element in HTML and the browser handles the rest:

<outer-widget></outer-widget>

The most straightforward way for a parent element to pass data to a child is through HTML attributes. The parent calls setAttribute on the child element it creates; the child reads the value with getAttribute in its own connectedCallback.

// Parent sets the child's attribute
var child = document.createElement('name-tag');
child.setAttribute('name', this.getAttribute('name') || 'friend');
this.appendChild(child);
// Child reads it
NameTag.prototype.connectedCallback = function() {
var name = this.getAttribute('name') || 'stranger';
this.textContent = name;
};

Attributes are always strings, so convert to the type you need (for example, parseInt for numbers).

When a child element needs to tell its parent that something happened — a button was clicked, a value was selected — it dispatches a CustomEvent. The parent listens for that event in its own connectedCallback.

Two options on the event constructor matter here:

  • bubbles: true — the event travels up the DOM tree past the child
  • composed: true — the event crosses shadow-DOM boundaries
// Child dispatches an event
this.addEventListener('click', function() {
self.dispatchEvent(new CustomEvent('item-selected', {
bubbles: true,
composed: true,
detail: { value: self.getAttribute('value') }
}));
});
// Parent listens in connectedCallback
OuterWidget.prototype.connectedCallback = function() {
var self = this;
// ... build children ...
self.addEventListener('item-selected', function(e) {
console.log('selected:', e.detail.value);
});
};

Because bubbles: true is set, the parent can attach a single listener to itself and receive events from any child nested inside it — no direct reference needed.

The demo below composes a <star-rating> from five <rating-star> elements. Each star listens for a click and dispatches a star-select CustomEvent. The outer <star-rating> listens for those events and updates a display span with the chosen rating.

OptionBenefitCost
Communicate via attribute (parent sets an attribute on the child)Easy to write, values are directly inspectable via DOM inspectorAttributes are strings only — you have to serialize/deserialize yourself once data gets more complex
Communicate via custom event (child dispatches an event up to the parent)Sends any data type via detail, and doesn’t tie the parent to the child’s internal implementationMust set bubbles/composed correctly, or the event never reaches where it needs to go
  • Forgetting composed: true — an event dispatched from inside shadow DOM without composed never crosses the shadow boundary, so a parent listening in the light DOM never receives it at all.
  • Sending complex data through an attribute instead of a property or event — attributes only hold strings, forcing unnecessary JSON.stringify/parse round-trips, when a property or event.detail should be used instead.
  • A parent reaching into a child’s internals via shadowRoot directly — this breaks encapsulation and ties the parent to the child’s internal implementation; when the child’s internal structure changes, the parent breaks along with it.

💡 Real-world examples

GitHub — Catalyst elements use custom events as their primary channel for inter-component communication, keeping encapsulation intact so a parent never reaches into a child’s internals directly. (github/catalyst)

Ionic Framework — a nested component pair like <ion-select> and <ion-select-option> dispatches a custom ionChange event up to the parent to report a state change, instead of having the parent poll the child for its value. (ionicframework.com/docs/api/select)

What property on CustomEvent options makes the event cross shadow-DOM boundaries?
In the star-rating demo, which element dispatches the star-select event?
Which method does the parent element use to subscribe to child events in connectedCallback?