Web Components
Custom Elements
class MyButton extends HTMLElement {
static observedAttributes = ['disabled', 'variant'];
connectedCallback() {
this.render();
this.shadowRoot.querySelector('button').addEventListener('click', this.#onClick);
}
disconnectedCallback() {
this.shadowRoot.querySelector('button')?.removeEventListener('click', this.#onClick);
}
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) this.render();
}
#onClick = (e) => {
this.dispatchEvent(new CustomEvent('my-click', { bubbles: true, composed: true }));
};
render() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button { padding: 0.5em 1em; border-radius: 4px; cursor: pointer; }
:host([disabled]) button { opacity: 0.5; pointer-events: none; }
</style>
<button ${this.hasAttribute('disabled') ? 'disabled' : ''}>
<slot></slot>
</button>
`;
}
}
customElements.define('my-button', MyButton);
Shadow DOM & Slots
<!-- Definition -->
<template id="card-tmpl">
<style>
:host { display: block; border-radius: 8px; overflow: hidden; }
.body { padding: 1rem; }
</style>
<slot name="image"></slot>
<div class="body">
<slot name="title"></slot>
<slot><!-- default slot --></slot>
</div>
</template>
<!-- Usage -->
<my-card>
<img slot="image" src="/photo.jpg" alt="">
<h2 slot="title">Card Title</h2>
<p>Card body content goes in the default slot.</p>
</my-card>
class MyCard extends HTMLElement {
connectedCallback() {
const tmpl = document.getElementById('card-tmpl');
this.attachShadow({ mode: 'open' }).append(tmpl.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
Lit Element
import { LitElement, html, css } from 'lit';
import { property, state } from 'lit/decorators.js';
class CounterEl extends LitElement {
static styles = css`
:host { display: flex; gap: 0.5rem; align-items: center; }
button { min-width: 2rem; }
`;
@property({ type: Number }) initial = 0;
@state() count = 0;
connectedCallback() {
super.connectedCallback();
this.count = this.initial;
}
render() {
return html`
<button @click=${() => this.count--}>-</button>
<span>${this.count}</span>
<button @click=${() => this.count++}>+</button>
`;
}
}
customElements.define('counter-el', CounterEl);
adoptedStyleSheets (Shared Styles)
const sheet = new CSSStyleSheet();
sheet.replaceSync(':host { box-sizing: border-box; }');
class MyEl extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet]; // shared, not cloned
}
}
Custom Events Pattern
// Emit from component
this.dispatchEvent(new CustomEvent('value-change', {
detail: { value: this.value },
bubbles: true,
composed: true, // crosses shadow boundary
}));
// Listen from outside
document.querySelector('my-input').addEventListener('value-change', e => {
console.log(e.detail.value);
});
Form Association
class MyInput extends HTMLElement {
static formAssociated = true;
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
set value(v) {
this.#internals.setFormValue(v);
}
}
customElements.define('my-input', MyInput);