Web Components Cheat Sheet
Reference for building framework-agnostic UI with custom elements, Shadow DOM encapsulation, and HTML templates with named slots.
Defining a Custom Element
A minimal component with a reactive attribute.
class UserCard extends HTMLElement { static get observedAttributes() { return ['name']; } constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.render(); } attributeChangedCallback(name, oldValue, newValue) { if (name === 'name') this.render(); } render() { this.shadowRoot.innerHTML = `<p>Hello, ${this.getAttribute('name')}!</p>`; }}customElements.define('user-card', UserCard);// Usage: <user-card name="Ada"></user-card>
Shadow DOM Encapsulation
Scoped styles that never leak in or out.
class StyledBadge extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> /* Styles here are scoped to this component only */ span { background: gold; padding: 2px 8px; border-radius: 4px; } </style> <span><slot></slot></span> `; }}customElements.define('styled-badge', StyledBadge);// <styled-badge>New</styled-badge>
HTML Template & Slots
Named and default slots for content projection.
<template id="card-template"> <style>.card { border: 1px solid #ccc; padding: 1rem; }</style> <div class="card"> <slot name="title">Default Title</slot> <slot></slot> </div></template><!-- Consumer usage with named + default slots --><my-card> <h2 slot="title">Custom Title</h2> <p>This goes in the default slot.</p></my-card>
Core Web Components APIs
The three specs that make up the standard.
- customElements.define()- Registers a new custom element tag name mapped to a class
- Lifecycle callbacks- connectedCallback, disconnectedCallback, attributeChangedCallback, adoptedCallback
- Shadow DOM- attachShadow({mode}) creates an encapsulated, style-scoped DOM subtree
- <template>- Inert HTML fragment parsed but not rendered until cloned into the DOM
- <slot>- Placeholder inside shadow DOM where light-DOM children get projected
- Custom Events- dispatchEvent(new CustomEvent(name, {detail, bubbles: true})) for component communication
Reflecting Properties and Attributes
Keep JS property access and HTML attribute state in sync without infinite update loops.
class Counter extends HTMLElement { static get observedAttributes() { return ['value']; } get value() { return Number(this.getAttribute('value')) || 0; } set value(v) { // setAttribute triggers attributeChangedCallback, which re-renders — // guard against redundant writes to avoid unnecessary re-renders if (v !== this.value) this.setAttribute('value', v); } attributeChangedCallback(name, oldVal, newVal) { if (oldVal === newVal) return; this.render(); } render() { this.textContent = `Count: ${this.value}`; }}customElements.define('x-counter', Counter);// el.value = 5 -> reflects to attribute, triggers render// el.setAttribute('value', 5) -> same effect, property reads it back correctly
Form-Associated Custom Elements
Make a custom element participate in native <form> submission and validation.
class RatingInput extends HTMLElement { static formAssociated = true; // opts into ElementInternals constructor() { super(); this._internals = this.attachInternals(); this.attachShadow({ mode: 'open' }).innerHTML = `<style>:host{display:inline-block}</style><slot></slot>`; } set value(v) { this._value = v; this._internals.setFormValue(String(v)); // included in FormData on submit if (!v) { this._internals.setValidity({ valueMissing: true }, 'Rating required'); } else { this._internals.setValidity({}); } } get value() { return this._value; } // Native form lifecycle hooks, called automatically by the browser formResetCallback() { this.value = null; } formDisabledCallback(disabled) { this.toggleAttribute('disabled', disabled); }}customElements.define('rating-input', RatingInput);// <form><rating-input name="stars" required></rating-input></form>// FormData(form).get('stars') now works like a native input
Declarative Shadow DOM (SSR-Friendly)
Attach shadow roots from server-rendered HTML before any JavaScript runs, avoiding flash-of-unstyled-content.
<!-- Server can stream this directly; the browser attaches the shadow root during HTML parsing, no JS execution required --><user-card> <template shadowrootmode="open"> <style>p { color: teal; }</style> <p><slot name="name">Anonymous</slot></p> </template> <span slot="name">Ada Lovelace</span></user-card><script> // Still define the class for interactivity — declarative shadow DOM only // handles the initial markup/encapsulation, not behavior customElements.define('user-card', class extends HTMLElement { connectedCallback() { // shadowRoot already exists from the declarative template — don't // call attachShadow() again or it throws console.log(this.shadowRoot.textContent); } });</script>
Styling Across the Shadow Boundary
Let consumers theme a component without breaking encapsulation.
/* Inside the component's shadow DOM: expose a themable part *//* <div part="header">...</div> inside the template *//* Consumer page CSS can target it despite the shadow boundary: */user-card::part(header) { background: navy; color: white;}/* CSS custom properties pierce the shadow boundary by default (unlike normal selectors), so components can expose a themable API: *//* inside shadow DOM stylesheet: */:host { --card-radius: var(--x-card-radius, 8px); border-radius: var(--card-radius);}/* consumer sets it from outside: */user-card { --x-card-radius: 16px; }/* :host-context() lets a component adapt to an ancestor outside its shadow root */:host-context(.dark-theme) { background: #1a1a1a;}
Advanced Web Components Concepts
APIs beyond the basic define/attachShadow/slot trio.
- Customized built-in elements- extends HTMLButtonElement with is="fancy-button" reuses native semantics/a11y instead of reinventing them
- ElementInternals- attachInternals() grants form participation, ARIA reflection, and :state() CSS pseudo-classes
- CSS ::part() / ::slotted()- ::part() styles exposed internal elements; ::slotted() styles light-DOM children from inside the shadow root
- Declarative Shadow DOM- <template shadowrootmode> attaches shadow roots during HTML parsing, enabling SSR without hydration flicker
- Constructable Stylesheets- new CSSStyleSheet() + shadowRoot.adoptedStyleSheets shares one parsed sheet across many instances instead of duplicating <style> tags
- custom-state pseudo-class- internals.states.add('checked') lets consumers style :state(checked) like a native pseudo-class
Custom element names must contain a hyphen (e.g. user-card, not usercard) — this spec requirement reserves all-lowercase tag names for future native HTML elements and avoids naming collisions.