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
| Method | When Called | Use For |
|---|---|---|
constructor() | Element created | Attach shadow DOM, initialize state |
connectedCallback() | Added to DOM | Render, add event listeners, fetch data |
disconnectedCallback() | Removed from DOM | Cleanup listeners, abort requests |
attributeChangedCallback() | Observed attribute changes | Re-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">×</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:
| Valid | Invalid |
|---|---|
my-component | mycomponent |
app-button | button |
x-data | data |
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}