Composing Components
Nesting custom elements
Section titled “Nesting custom elements”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 firstclass InnerWidget extends HTMLElement { connectedCallback() { this.textContent = 'I am the inner widget'; }}customElements.define('inner-widget', InnerWidget);
// Outer element — creates the inner one programmaticallyclass 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>Communicating via attributes
Section titled “Communicating via attributes”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 attributevar child = document.createElement('name-tag');child.setAttribute('name', this.getAttribute('name') || 'friend');this.appendChild(child);
// Child reads itNameTag.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).
Communicating via events
Section titled “Communicating via events”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 childcomposed: true— the event crosses shadow-DOM boundaries
// Child dispatches an eventthis.addEventListener('click', function() { self.dispatchEvent(new CustomEvent('item-selected', { bubbles: true, composed: true, detail: { value: self.getAttribute('value') } }));});
// Parent listens in connectedCallbackOuterWidget.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.
Runnable demo
Section titled “Runnable demo”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.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Communicate via attribute (parent sets an attribute on the child) | Easy to write, values are directly inspectable via DOM inspector | Attributes 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 implementation | Must set bubbles/composed correctly, or the event never reaches where it needs to go |
Common mistakes
Section titled “Common mistakes”- Forgetting
composed: true— an event dispatched from inside shadow DOM withoutcomposednever 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/parseround-trips, when a property orevent.detailshould be used instead. - A parent reaching into a child’s internals via
shadowRootdirectly — 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 customionChangeevent 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)