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

MethodWhen CalledUse For
constructor()Element createdAttach shadow DOM, init state
connectedCallback()Added to DOMRender, add listeners, fetch data
disconnectedCallback()Removed from DOMCleanup (abort listeners)
attributeChangedCallback()Observed attr changesReact 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}
Tags: Web-Components, Javascript, Typescript, Custom-Elements, Shadow-Dom, Frontend