If you left web development before 2018, you might remember the browser wars over component standards— Polymer, X-Tag, and various polyfills fighting for dominance. Good news: the dust has settled. Web Components are a mature, widely-supported set of APIs that let you create reusable, encapsulated HTML elements.

This very blog uses 10 custom elements: <site-header>, <theme-toggle>, <blog-post-card>, <code-block>, and more. No framework required—just the platform. Let's dive deep into how they work.

What Are Web Components?

Web Components is an umbrella term for three main browser APIs:

  • Custom Elements — Define your own HTML tags with custom behavior
  • Shadow DOM — Encapsulate styles and markup so they don't leak out
  • HTML Templates — Declare reusable markup that isn't rendered until needed

Together, these APIs let you build self-contained components that work anywhere HTML works— React apps, Vue projects, plain HTML pages, or even WordPress themes.

Your First Custom Element

Creating a custom element requires just two things: a class that extends HTMLElement and a call to customElements.define():

Now you can use it in HTML:

Naming rule: Custom element names must contain a hyphen (-). This distinguishes them from built-in HTML elements and prevents future naming collisions. <my-button> is valid; <mybutton> is not.

The Custom Element Lifecycle

Custom elements have four lifecycle callbacks that fire at specific moments. Understanding these is crucial for building components that work correctly:

constructor()

Called when the element is created—either by the HTML parser or by document.createElement('my-element'). Use it for:

  • Attaching Shadow DOM
  • Setting up initial state
  • Binding event handler methods to this

Important: Don't access attributes, child elements, or DOM here. The element hasn't been added to the document yet, and attributes might not be parsed.

connectedCallback()

Called when the element is added to the document. This is your primary setup method—render content, add event listeners, fetch data, start animations:

Note: connectedCallback() can fire multiple times if an element is moved in the DOM (removed and re-added). Design your setup logic to handle this.

disconnectedCallback()

Called when the element is removed from the document. Clean up here—remove event listeners, cancel timers, close connections:

attributeChangedCallback(name, oldValue, newValue)

Called when an observed attribute changes. You must declare which attributes to observe using the static observedAttributes property:

Important: attributeChangedCallback fires before connectedCallback for attributes present in the initial HTML. Guard against accessing uninitialized DOM:

Shadow DOM: Style Encapsulation

Shadow DOM is what makes Web Components truly self-contained. It creates a separate DOM tree that:

  • Isolates styles—CSS from outside doesn't affect the shadow tree, and vice versa
  • Hides implementation details—querySelector won't find shadow content
  • Provides scoped element IDs—no more worrying about ID collisions

Attaching Shadow DOM

The mode: 'open' option means JavaScript outside the component can access the shadow DOM via element.shadowRoot. Use mode: 'closed' for stricter encapsulation (though it's rarely needed).

The :host Selector

Inside shadow DOM, the :host selector targets the custom element itself:

CSS Custom Properties Cross the Shadow Boundary

While regular CSS is encapsulated, CSS custom properties (variables) inherit into shadow DOM. This is the recommended way to make components themeable:

This blog's <theme-toggle> component uses exactly this pattern—it reads --color-text, --color-surface, and --color-accent from the document's CSS variables.

Slots: Composing Content

The <slot> element lets users pass content into your component. It's the Web Components equivalent of React's children prop:

Named slots (slot="header") go to their matching <slot name="header">. Unnamed content fills the default slot. If no content is provided, the slot's fallback content shows.

Real-World Patterns

Let's look at patterns from production components on this very site.

Pattern 1: Reactive Attributes (blog-post-card)

This blog's <blog-post-card> component re-renders when any attribute changes:

Pattern 2: External State Communication (theme-toggle)

The <theme-toggle> component communicates with a global ThemeManager and listens for custom events:

Pattern 3: Progressive Enhancement (reading-progress)

The <reading-progress> component uses scroll-driven animations when available, with a JavaScript fallback:

Best Practices

1. Always Call super() First

2. Keep Constructor Light

Don't render, access attributes, or add event listeners in the constructor. Wait for connectedCallback():

3. Clean Up in disconnectedCallback

Any global event listeners, intervals, or connections should be cleaned up:

4. Use CSS Custom Properties for Theming

Don't hardcode colors. Use variables with sensible fallbacks:

5. Dispatch Custom Events for Communication

Use CustomEvent to communicate with the outside world:

When to Use Web Components

Web Components shine when you need:

  • Framework-agnostic components — Ship a design system that works in React, Vue, Angular, or plain HTML
  • Style isolation — Widgets embedded in third-party sites (embeds, plugins)
  • Zero dependencies — No build step, no framework, just the platform
  • Future-proofing — Standards don't have major version breaking changes

Consider other options when:

  • Your entire app is already in React/Vue—use their component model instead
  • You need server-side rendering (SSR) — Web Components are client-side only
  • SEO is critical and content must be in the initial HTML

Browser DevTools Tips

Debugging Web Components is straightforward with modern DevTools:

  • Elements panel: Shadow DOM appears as a #shadow-root node you can expand and inspect
  • Styles panel: Shows shadow DOM styles separately, with the :host selector clearly labeled
  • Console: Use $0.shadowRoot to access the shadow DOM of a selected element
  • Break on: Right-click a custom element and use "Break on > subtree modifications" to debug rendering issues

Further Reading