Web Components Cheat Sheet

Foundations

Core Structure

 1class MyComponent extends HTMLElement {
 2  constructor() {
 3    super();
 4    this.attachShadow({ mode: 'open' });
 5  }
 6
 7  connectedCallback(): void {
 8    this.render();
 9  }
10
11  private render(): void {
12    const sheet = new CSSStyleSheet();
13    sheet.replaceSync(this.getStyles());
14
15    const template = document.createElement('template');
16    template.innerHTML = this.getTemplate();
17
18    if (this.shadowRoot) {
19      this.shadowRoot.innerHTML = '';
20      this.shadowRoot.adoptedStyleSheets = [sheet];
21      this.shadowRoot.appendChild(template.content.cloneNode(true));
22    }
23  }
24
25  private getStyles(): string {
26    return `:host { display: block; }`;
27  }
28
29  private getTemplate(): string {
30    return `<slot></slot>`;
31  }
32}

Lifecycle Methods

MethodWhen CalledUse For
constructor()Element createdAttach shadow DOM, initialize state
connectedCallback()Added to DOMRender, add event listeners, fetch data
disconnectedCallback()Removed from DOMCleanup listeners, abort requests
attributeChangedCallback()Observed attribute changesRe-render or update specific parts

Registration

1// Always guard against duplicate registration
2if (!customElements.get('my-component')) {
3  customElements.define('my-component', MyComponent);
4}

Element names must contain a hyphen: my-component, app-button, data-table.


Attributes & State

Observed Attributes

 1class MyComponent extends HTMLElement {
 2  static get observedAttributes(): string[] {
 3    return ['variant', 'size', 'disabled'];
 4  }
 5
 6  attributeChangedCallback(
 7    name: string,
 8    oldValue: string | null,
 9    newValue: string | null
10  ): void {
11    if (oldValue !== newValue && this.shadowRoot?.children.length) {
12      this.render();
13    }
14  }
15}

String Attributes

1get variant(): string {
2  return this.getAttribute('variant') || 'default';
3}
4
5get size(): 'sm' | 'md' | 'lg' {
6  return (this.getAttribute('size') as 'sm' | 'md' | 'lg') || 'md';
7}

Boolean Attributes

1get disabled(): boolean {
2  return this.hasAttribute('disabled');
3}
4
5set disabled(value: boolean) {
6  value ? this.setAttribute('disabled', '') : this.removeAttribute('disabled');
7}

Internal State

 1class MyComponent extends HTMLElement {
 2  private isOpen = false;
 3  private data: Item[] = [];
 4
 5  // State lives in the class, not in attributes
 6  open(): void {
 7    this.isOpen = true;
 8    this.updatePanel();
 9  }
10}

Events

Event Cleanup with AbortController

 1class MyComponent extends HTMLElement {
 2  private abortController = new AbortController();
 3
 4  connectedCallback(): void {
 5    this.render();
 6    this.setupEventListeners();
 7  }
 8
 9  disconnectedCallback(): void {
10    this.abortController.abort();
11  }
12
13  private setupEventListeners(): void {
14    this.abortController.abort();
15    this.abortController = new AbortController();
16    const signal = this.abortController.signal;
17
18    this.shadowRoot?.querySelector('button')?.addEventListener(
19      'click',
20      () => this.handleClick(),
21      { signal }
22    );
23
24    // Window/document listeners also get cleaned up
25    window.addEventListener('keydown', this.handleKeyDown, { signal });
26  }
27}

Custom Events

 1// Dispatch - use composed: true to cross shadow boundary
 2this.dispatchEvent(new CustomEvent('item-selected', {
 3  bubbles: true,
 4  composed: true,
 5  detail: { id: itemId, label: itemLabel }
 6}));
 7
 8// Listen
 9element.addEventListener('item-selected', (e: CustomEvent) => {
10  console.log(e.detail.id, e.detail.label);
11});

Global Event Communication

 1// Component A: dispatch global event
 2window.dispatchEvent(new CustomEvent('app-refresh', {
 3  detail: { target: 'data-list' }
 4}));
 5
 6// Component B: listen with cleanup
 7connectedCallback(): void {
 8  window.addEventListener('app-refresh', this.handleRefresh, {
 9    signal: this.abortController.signal
10  });
11}
12
13private handleRefresh = (e: CustomEvent): void => {
14  if (e.detail.target === 'data-list') {
15    this.loadData();
16  }
17};

Slots & Composition

Named and Default Slots

1private getTemplate(): string {
2  return `
3    <div class="card">
4      <header><slot name="header"></slot></header>
5      <main><slot></slot></main>
6      <footer><slot name="footer"></slot></footer>
7    </div>
8  `;
9}
1<my-card>
2  <h3 slot="header">Card Title</h3>
3  <p>This goes in the default slot</p>
4  <button slot="footer">Action</button>
5</my-card>

Styling Slotted Content

 1/* Style the slot container */
 2::slotted(*) {
 3  margin: 0;
 4}
 5
 6/* Target specific slotted elements */
 7::slotted(h3) {
 8  font-size: 18px;
 9  font-weight: 600;
10}
11
12::slotted([slot="footer"]) {
13  margin-top: auto;
14}

Slot Limitations

::slotted() only styles direct children:

1/* Works */
2::slotted(p) { color: red; }
3
4/* Fails - no descendant selectors */
5::slotted(div p) { color: red; }
6
7/* Fails - no combinators */
8::slotted(*) + ::slotted(*) { margin-top: 8px; }

Styling

CSS Encapsulation

Styles inside Shadow DOM don’t leak out, external styles don’t leak in:

 1private getStyles(): string {
 2  return `
 3    :host {
 4      display: block;
 5    }
 6
 7    /* These styles are scoped to this component */
 8    button {
 9      padding: 8px 16px;
10    }
11  `;
12}

CSS Custom Properties

Custom properties pierce the shadow boundary for theming:

 1private getStyles(): string {
 2  return `
 3    :host {
 4      /* Map external vars to internal with fallbacks */
 5      --component-bg: var(--bg, #ffffff);
 6      --component-text: var(--text, #1c1c1e);
 7      --component-border: var(--border, #e5e7eb);
 8    }
 9
10    .container {
11      background: var(--component-bg);
12      color: var(--component-text);
13      border: 1px solid var(--component-border);
14    }
15  `;
16}

Override from outside:

1my-component {
2  --bg: #1a1a2e;
3  --text: #eaeaea;
4}

Dark Mode

 1:host {
 2  --component-bg: var(--bg, #ffffff);
 3  --component-text: var(--text, #1c1c1e);
 4}
 5
 6@media (prefers-color-scheme: dark) {
 7  :host {
 8    --component-bg: var(--bg, #1f2937);
 9    --component-text: var(--text, #f3f4f6);
10  }
11}

Size Variants

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}

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}

Advanced Patterns

Stateful Components

Use a state attribute for loading, error, and empty states:

 1type ComponentState = 'idle' | 'loading' | 'error' | 'empty';
 2
 3class MyDataComponent extends HTMLElement {
 4  private data: Item[] = [];
 5  private errorMessage = 'An error occurred';
 6
 7  static get observedAttributes(): string[] {
 8    return ['state'];
 9  }
10
11  get state(): ComponentState {
12    return (this.getAttribute('state') as ComponentState) || 'idle';
13  }
14
15  set state(value: ComponentState) {
16    this.setAttribute('state', value);
17  }
18
19  private getTemplate(): string {
20    switch (this.state) {
21      case 'loading': return this.getLoadingTemplate();
22      case 'error': return this.getErrorTemplate();
23      case 'empty': return this.getEmptyTemplate();
24      default: return this.getContentTemplate();
25    }
26  }
27
28  // Public API
29  setLoading(message?: string): void {
30    this.state = 'loading';
31  }
32
33  setError(message?: string): void {
34    this.errorMessage = message || 'An error occurred';
35    this.state = 'error';
36  }
37
38  setData(data: Item[]): void {
39    this.data = data;
40    this.state = data.length > 0 ? 'idle' : 'empty';
41  }
42}

Attribute Batching

Sequential setAttribute() calls trigger 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}
11
12private hasRequiredAttrs(): boolean {
13  return !!(this.getAttribute('id') && this.getAttribute('type'));
14}

Request Deduplication

Multiple components fetching the same URL share one network request:

 1const inFlight = new Map<string, Promise<Response>>();
 2
 3async function dedupedFetch<T>(url: string): Promise<T> {
 4  if (inFlight.has(url)) {
 5    return inFlight.get(url)!.then(r => r.clone().json());
 6  }
 7
 8  const promise = fetch(url);
 9  inFlight.set(url, promise);
10
11  try {
12    const response = await promise;
13    return response.json();
14  } finally {
15    setTimeout(() => inFlight.delete(url), 2000);
16  }
17}

Keyboard Navigation

 1private handleKeyDown(event: KeyboardEvent): void {
 2  const items = Array.from(this.shadowRoot!.querySelectorAll('.item'));
 3  const current = items.indexOf(document.activeElement as HTMLElement);
 4
 5  switch (event.key) {
 6    case 'ArrowDown':
 7      event.preventDefault();
 8      const next = current < items.length - 1 ? current + 1 : 0;
 9      (items[next] as HTMLElement).focus();
10      break;
11    case 'ArrowUp':
12      event.preventDefault();
13      const prev = current > 0 ? current - 1 : items.length - 1;
14      (items[prev] as HTMLElement).focus();
15      break;
16    case 'Home':
17      event.preventDefault();
18      (items[0] as HTMLElement).focus();
19      break;
20    case 'End':
21      event.preventDefault();
22      (items[items.length - 1] as HTMLElement).focus();
23      break;
24  }
25}

UI Components

Dialog

Use native <dialog> for modals:

 1class MyDialog extends HTMLElement {
 2  private dialogElement: HTMLDialogElement | null = null;
 3
 4  connectedCallback(): void {
 5    this.render();
 6    this.dialogElement = this.shadowRoot?.querySelector('dialog') ?? null;
 7    this.setupEventListeners();
 8  }
 9
10  private getTemplate(): string {
11    return `
12      <dialog>
13        <header>
14          <h2 class="title"></h2>
15          <button class="close" aria-label="Close">&times;</button>
16        </header>
17        <div class="content"><slot></slot></div>
18      </dialog>
19    `;
20  }
21
22  private setupEventListeners(): void {
23    // Close on backdrop click
24    this.dialogElement?.addEventListener('click', (e) => {
25      if (e.target === this.dialogElement) this.close();
26    });
27
28    // Handle Escape key
29    this.dialogElement?.addEventListener('cancel', (e) => {
30      e.preventDefault();
31      this.close();
32    });
33  }
34
35  // Public API
36  open(): void {
37    this.dialogElement?.showModal();
38    this.dispatchEvent(new CustomEvent('opened', { bubbles: true, composed: true }));
39  }
40
41  close(): void {
42    this.dialogElement?.close();
43    this.dispatchEvent(new CustomEvent('closed', { bubbles: true, composed: true }));
44  }
45
46  setTitle(title: string): void {
47    const el = this.shadowRoot?.querySelector('.title');
48    if (el) el.textContent = title;
49  }
50}
1dialog::backdrop {
2  background: rgba(0, 0, 0, 0.5);
3}
4
5@media (prefers-reduced-motion: no-preference) {
6  dialog[open] {
7    animation: slide-up 300ms ease-out;
8  }
9}

Tabs

 1class MyTabs extends HTMLElement {
 2  connectedCallback(): void {
 3    this.render();
 4    this.activateTab(this.getTabItems()[0]?.id);
 5  }
 6
 7  private getTabItems(): Array<{ id: string; label: string }> {
 8    return Array.from(this.querySelectorAll('[role="tabpanel"]')).map(panel => ({
 9      id: panel.id.replace('p-', ''),
10      label: panel.getAttribute('aria-label') || panel.id
11    }));
12  }
13
14  private activateTab(tabId: string): void {
15    // Update tab buttons
16    this.shadowRoot?.querySelectorAll('.tab').forEach(tab => {
17      const isActive = tab.id === `t-${tabId}`;
18      tab.setAttribute('aria-selected', String(isActive));
19      tab.setAttribute('tabindex', isActive ? '0' : '-1');
20    });
21
22    // Update panels
23    this.querySelectorAll('[role="tabpanel"]').forEach(panel => {
24      panel.setAttribute('data-active', String(panel.id === `p-${tabId}`));
25    });
26
27    this.dispatchEvent(new CustomEvent('tab-change', {
28      bubbles: true, composed: true,
29      detail: { tabId }
30    }));
31  }
32}
1<my-tabs label="Settings">
2  <div role="tabpanel" id="p-general" aria-label="General">...</div>
3  <div role="tabpanel" id="p-advanced" aria-label="Advanced">...</div>
4</my-tabs>

Troubleshooting & Gotchas

Constructor Restrictions

Never access attributes or DOM in the constructor:

 1// WRONG - attributes not available yet
 2constructor() {
 3  super();
 4  this.value = this.getAttribute('value'); // Always null!
 5  this.querySelector('div'); // Returns null!
 6}
 7
 8// CORRECT - wait for connectedCallback
 9connectedCallback() {
10  this.value = this.getAttribute('value');
11  this.render();
12}

Early Attribute Changes

attributeChangedCallback fires before connectedCallback. Guard against uninitialized DOM:

1attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
2  // Guard: only update if already rendered
3  if (!this.shadowRoot?.children.length) return;
4  this.render();
5}

Registration Errors

“CustomElementRegistry already contains an entry” happens with HMR or duplicate scripts:

1// Always guard registration
2if (!customElements.get('my-component')) {
3  customElements.define('my-component', MyComponent);
4}

Naming Requirements

Custom element names must contain a hyphen:

ValidInvalid
my-componentmycomponent
app-buttonbutton
x-datadata

Multiple Attribute Updates

Setting multiple attributes triggers multiple callbacks:

1// This triggers 3 separate attributeChangedCallback calls
2element.setAttribute('id', '123');
3element.setAttribute('type', 'user');
4element.setAttribute('status', 'active');

Fix with microtask batching (see Attribute Batching in Advanced Patterns).

Slotted Content Styling

::slotted() limitations that catch developers:

1/* Only matches direct children, not descendants */
2::slotted(p) { }        /* Works: <p slot="x"> */
3::slotted(div p) { }    /* Fails: can't select nested elements */
4
5/* No sibling combinators */
6::slotted(*) + ::slotted(*) { }  /* Fails */

Event Bubbling Through Shadow DOM

Events don’t cross shadow boundaries by default:

1// Won't be caught by listeners outside the shadow DOM
2this.dispatchEvent(new CustomEvent('change', { bubbles: true }));
3
4// Will cross the shadow boundary
5this.dispatchEvent(new CustomEvent('change', {
6  bubbles: true,
7  composed: true  // Required to escape shadow DOM
8}));

Quick Reference

Component Checklist

  • Shadow DOM with mode: 'open'
  • static get observedAttributes() for reactive attrs
  • Registration guard with customElements.get()
  • AbortController for event cleanup
  • CSS custom properties for theming
  • @media (prefers-color-scheme: dark) support
  • @media (prefers-reduced-motion: reduce) support
  • 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