Web Components Cheat Sheet
Core Structure
1class MyComponent extends HTMLElement {
2 private abortController = new AbortController();
3
4 static get observedAttributes(): string[] {
5 return ['attribute-name'];
6 }
7
8 constructor() {
9 super();
10 this.attachShadow({ mode: 'open' });
11 // State only - never access attributes here
12 }
13
14 connectedCallback(): void {
15 this.render();
16 this.setupEventListeners();
17 }
18
19 disconnectedCallback(): void {
20 this.abortController.abort();
21 }
22
23 attributeChangedCallback(
24 name: string,
25 oldValue: string | null,
26 newValue: string | null
27 ): void {
28 if (oldValue !== newValue && this.shadowRoot?.children.length) {
29 this.render();
30 }
31 }
32
33 private render(): void {
34 const sheet = new CSSStyleSheet();
35 sheet.replaceSync(this.getStyles());
36
37 const template = document.createElement('template');
38 template.innerHTML = this.getTemplate();
39
40 if (this.shadowRoot) {
41 this.shadowRoot.innerHTML = '';
42 this.shadowRoot.adoptedStyleSheets = [sheet];
43 this.shadowRoot.appendChild(template.content.cloneNode(true));
44 }
45 }
46
47 private getStyles(): string {
48 return `:host { display: block; }`;
49 }
50
51 private getTemplate(): string {
52 return `<slot></slot>`;
53 }
54
55 private setupEventListeners(): void {
56 const signal = this.abortController.signal;
57 // Add listeners with { signal }
58 }
59}
60
61if (!customElements.get('my-component')) {
62 customElements.define('my-component', MyComponent);
63}
Lifecycle
| Method | When Called | Use For |
|---|---|---|
constructor() | Element created | Attach shadow DOM, init state |
connectedCallback() | Added to DOM | Render, add listeners, fetch data |
disconnectedCallback() | Removed from DOM | Cleanup (abort listeners) |
attributeChangedCallback() | Observed attr changes | React to attribute updates |
Attributes & Properties
1// String attribute with default
2get variant(): string {
3 return this.getAttribute('variant') || 'default';
4}
5
6// Boolean attribute
7get disabled(): boolean {
8 return this.hasAttribute('disabled');
9}
10
11set disabled(value: boolean) {
12 value ? this.setAttribute('disabled', '') : this.removeAttribute('disabled');
13}
Event Cleanup with AbortController
1private abortController = new AbortController();
2
3connectedCallback(): void {
4 this.setupEventListeners();
5}
6
7disconnectedCallback(): void {
8 this.abortController.abort();
9}
10
11private setupEventListeners(): void {
12 this.abortController.abort();
13 this.abortController = new AbortController();
14 const signal = this.abortController.signal;
15
16 this.shadowRoot?.querySelector('button')?.addEventListener(
17 'click',
18 () => this.handleClick(),
19 { signal }
20 );
21}
Custom Events
1// Dispatch with bubbling through shadow DOM
2this.dispatchEvent(new CustomEvent('event-name', {
3 bubbles: true,
4 composed: true, // crosses shadow boundary
5 detail: { key: value }
6}));
7
8// Listen
9element.addEventListener('event-name', (e) => {
10 console.log(e.detail.key);
11});
CSS Custom Properties for Theming
1private getStyles(): string {
2 return `
3 :host {
4 --component-bg: var(--bg, #ffffff);
5 --component-text: var(--text, #1c1c1e);
6 }
7
8 @media (prefers-color-scheme: dark) {
9 :host {
10 --component-bg: var(--bg, #1f2937);
11 --component-text: var(--text, #f3f4f6);
12 }
13 }
14
15 .container {
16 background: var(--component-bg);
17 color: var(--component-text);
18 }
19 `;
20}
Override from outside:
1my-component {
2 --bg: #1a1a2e;
3 --text: #eaeaea;
4}
Reduced Motion
1@media (prefers-reduced-motion: no-preference) {
2 .element {
3 transition: transform 200ms ease;
4 }
5}
6
7@media (prefers-reduced-motion: reduce) {
8 .element {
9 transition: none;
10 }
11}
Size Variants
1static get observedAttributes(): string[] {
2 return ['size'];
3}
4
5get size(): 'sm' | 'md' | 'lg' {
6 return (this.getAttribute('size') as any) || 'md';
7}
1:host([size="sm"]) { --height: 32px; --font-size: 13px; }
2:host([size="md"]) { --height: 40px; --font-size: 14px; }
3:host([size="lg"]) { --height: 48px; --font-size: 16px; }
4
5.element {
6 height: var(--height);
7 font-size: var(--font-size);
8}
Stateful Components (Loading/Error/Empty)
1type ComponentState = 'idle' | 'loading' | 'error' | 'empty';
2
3get state(): ComponentState {
4 return (this.getAttribute('state') as ComponentState) || 'idle';
5}
6
7private getTemplate(): string {
8 switch (this.state) {
9 case 'loading': return this.getLoadingTemplate();
10 case 'error': return this.getErrorTemplate();
11 case 'empty': return this.getEmptyTemplate();
12 default: return this.getContentTemplate();
13 }
14}
15
16// Public API
17setLoading(message?: string): void { this.state = 'loading'; }
18setError(message?: string): void { this.state = 'error'; }
19setEmpty(message?: string): void { this.state = 'empty'; }
20setData(data: T[]): void {
21 this.data = data;
22 this.state = data.length > 0 ? 'idle' : 'empty';
23}
Attribute Batching
Sequential setAttribute() calls fire multiple callbacks. Batch with microtasks:
1private pending = false;
2
3attributeChangedCallback(): void {
4 if (this.pending) return;
5 this.pending = true;
6 queueMicrotask(() => {
7 this.pending = false;
8 if (this.hasRequiredAttrs()) this.loadData();
9 });
10}
Slots
1private getTemplate(): string {
2 return `
3 <div class="card">
4 <slot name="header"></slot>
5 <slot></slot>
6 <slot name="footer"></slot>
7 </div>
8 `;
9}
1<my-card>
2 <h3 slot="header">Title</h3>
3 <p>Default slot content</p>
4 <button slot="footer">Action</button>
5</my-card>
Dialog with Native <dialog>
1private dialogElement: HTMLDialogElement | null = null;
2
3open(): void {
4 this.dialogElement?.showModal();
5 this.dispatchEvent(new CustomEvent('opened', {
6 bubbles: true, composed: true
7 }));
8}
9
10close(): void {
11 this.dialogElement?.close();
12 this.dispatchEvent(new CustomEvent('closed', {
13 bubbles: true, composed: true
14 }));
15}
1dialog::backdrop {
2 background: rgba(0, 0, 0, 0.5);
3}
Tabs with Keyboard Navigation
1private handleKeyDown(event: KeyboardEvent): void {
2 const tabs = Array.from(this.shadowRoot.querySelectorAll('.tab'));
3 const currentIndex = tabs.indexOf(event.currentTarget as HTMLElement);
4 let nextIndex: number;
5
6 switch (event.key) {
7 case 'ArrowLeft':
8 event.preventDefault();
9 nextIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
10 (tabs[nextIndex] as HTMLElement).focus();
11 break;
12 case 'ArrowRight':
13 event.preventDefault();
14 nextIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
15 (tabs[nextIndex] as HTMLElement).focus();
16 break;
17 case 'Home':
18 event.preventDefault();
19 (tabs[0] as HTMLElement).focus();
20 break;
21 case 'End':
22 event.preventDefault();
23 (tabs[tabs.length - 1] as HTMLElement).focus();
24 break;
25 }
26}
Request Deduplication
1const inFlight = new Map<string, Promise<Result<unknown>>>();
2
3export async function clientFetch<T>(url: string): Promise<Result<T>> {
4 if (inFlight.has(url)) {
5 return inFlight.get(url) as Promise<Result<T>>;
6 }
7
8 const promise = fetch(url).then(r => r.json());
9 inFlight.set(url, promise);
10
11 promise.finally(() => {
12 setTimeout(() => inFlight.delete(url), 2000);
13 });
14
15 return promise;
16}
Global Event Communication
1// Dispatch from component
2window.dispatchEvent(new CustomEvent('app-action', {
3 detail: { type: 'refresh', target: 'data-list' }
4}));
5
6// Listen in another component
7window.addEventListener('app-action', ((e: CustomEvent) => {
8 if (e.detail.target === 'data-list') {
9 this.refresh();
10 }
11}) as EventListener, { signal: this.abortController.signal });
Critical Gotchas
Constructor Restrictions
1// WRONG - attributes not available
2constructor() {
3 super();
4 this.value = this.getAttribute('value'); // null!
5}
6
7// CORRECT
8connectedCallback() {
9 this.value = this.getAttribute('value');
10}
Guard Against Early Attribute Changes
1attributeChangedCallback(): void {
2 if (!this.shadowRoot?.children.length) return;
3 this.render();
4}
Registration Guard
1if (!customElements.get('my-component')) {
2 customElements.define('my-component', MyComponent);
3}
::slotted() Limitations
- Only styles direct slotted children
- No descendant selectors:
::slotted(div p)fails - No combinators:
::slotted(*) + ::slotted(*)fails
Naming Requirements
Names must contain a hyphen:
- Valid:
my-component,app-button - Invalid:
mycomponent,button
Component Checklist
- Shadow DOM with
mode: 'open' -
static get observedAttributes() - Registration guard:
if (!customElements.get()) - AbortController for event cleanup
- CSS custom properties for theming
- Dark mode via
@media (prefers-color-scheme: dark) - Reduced motion via
@media (prefers-reduced-motion: reduce) - Keyboard navigation where applicable
- ARIA attributes for accessibility
Common CSS Variables
1:root {
2 /* Colors */
3 --bg: #ffffff;
4 --text: #1c1c1e;
5 --muted: #6b7280;
6 --primary: #e0524d;
7 --border: #e5e7eb;
8 --surface: #f4f4f5;
9 --hover: rgba(0, 0, 0, 0.04);
10 --danger: #dc2626;
11
12 /* Spacing */
13 --radius: 8px;
14 --radius-md: 12px;
15
16 /* Heights */
17 --height-sm: 32px;
18 --height-md: 40px;
19 --height-lg: 48px;
20}
21
22@media (prefers-color-scheme: dark) {
23 :root {
24 --bg: #0b0b0c;
25 --text: #ffffff;
26 --muted: #9ca3af;
27 --border: #374151;
28 --surface: #1f2937;
29 --hover: rgba(255, 255, 255, 0.04);
30 }
31}