Web Components

Custom elements, Shadow DOM encapsulation, and a complete working custom element example.

What "web components" actually means

Web Components isn't one API — it's a name for three separate browser standards that combine to let you define a genuinely new, reusable HTML element, with its own encapsulated markup, styling, and behavior, usable in any project regardless of framework:

  • Custom Elements — the ability to define a new tag name (<user-card>) backed by a JavaScript class, with its own lifecycle callbacks.
  • Shadow DOM — an encapsulated, isolated DOM subtree attached to an element, whose internal markup and CSS can't leak out and can't be reached in by ordinary page-level selectors.
  • HTML Templates — the <template> element (already covered on the HTML5 Features page in this track) for declaring inert, reusable markup that's cloned into place on demand.

Together, these let you ship a component — say, a rating widget or a custom date picker — as a single <my-widget> tag that works identically whether it's dropped into a plain HTML page, a Laravel Blade view, a React app, or a Vue app, since it's built entirely on browser-native APIs rather than a specific framework's component model.

Defining a custom element

Every custom element is a class extending HTMLElement, registered with customElements.define(). Its tag name is required to contain a hyphen — this is a deliberate spec rule, guaranteeing a custom element's name can never collide with a future standard HTML element:

Javascript
class GreetingBanner extends HTMLElement {
    connectedCallback() {
        // Runs once this element is actually inserted into the document
        this.textContent = `Hello, ${this.getAttribute('name') || 'there'}!`;
    }
}

customElements.define('greeting-banner', GreetingBanner);
HTML
<greeting-banner name="Ada"></greeting-banner>

The most important lifecycle callbacks:

Callback Runs when
connectedCallback The element is inserted into the document — the standard place to do initial rendering.
disconnectedCallback The element is removed from the document — the place to clean up event listeners, timers, or subscriptions.
attributeChangedCallback An attribute listed in observedAttributes changes — lets the element react live to attribute updates.

Shadow DOM, conceptually

Attaching a shadow root gives an element its own private DOM tree, rendered as part of the page but invisible to ordinary document.querySelector() calls from outside, and — critically — with its own isolated style scope:

Javascript
class Example extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                p { color: red; } /* only affects <p> elements inside THIS shadow root */
            </style>
            <p>Styled only inside the shadow root.</p>
        `;
    }
}

A <p> anywhere else on the page is completely unaffected by that color: red rule, and the page's own global CSS can't reach in and restyle the <p> inside the shadow root either (short of a few deliberately-designed CSS custom-property and ::part() escape hatches). This is the core promise of Shadow DOM: a component's internal styles and internal markup structure are genuinely sealed off, in both directions — which is exactly the isolation problem CSS alone can't solve, no matter how disciplined a naming convention (like BEM) you apply to plain classes.

{ mode: 'open' } allows JavaScript outside the component to still reach in via element.shadowRoot (mainly useful for testing/debugging); { mode: 'closed' } blocks even that, fully sealing the internal DOM.

A complete minimal example: <user-card>

Javascript
class UserCard extends HTMLElement {
    static get observedAttributes() {
        return ['name', 'role'];
    }

    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }

    connectedCallback() {
        this.render();
    }

    attributeChangedCallback() {
        this.render();
    }

    render() {
        const name = this.getAttribute('name') || 'Unknown';
        const role = this.getAttribute('role') || 'Member';

        this.shadowRoot.innerHTML = `
            <style>
                .card {
                    border: 1px solid #e5e7eb;
                    border-radius: 8px;
                    padding: 1rem;
                    font-family: sans-serif;
                    max-width: 220px;
                }
                .name { font-weight: bold; }
                .role { color: #6b7280; font-size: 0.875rem; }
            </style>
            <div class="card">
                <div class="name">${name}</div>
                <div class="role">${role}</div>
            </div>
        `;
    }
}

customElements.define('user-card', UserCard);
HTML
<user-card name="Jane Doe" role="Engineer"></user-card>
<user-card name="Sam Rivera" role="Designer"></user-card>

Each <user-card> renders independently, with its own sealed .card/.name/.role styles that can't clash with a .card class already used elsewhere on the same page — including, crucially, another different .card class defined by whatever CSS framework the surrounding page happens to use. observedAttributes plus attributeChangedCallback means changing name or role on an existing <user-card> from JavaScript re-renders it automatically, the same way a framework component re-renders on a prop change.

When to reach for this vs. a framework

Web Components are a genuinely good fit for a self-contained, visually isolated widget meant to be dropped into many different unrelated contexts — a third-party embeddable widget, a design-system primitive shared across several separate frontends built with different frameworks, or a piece of a legacy multi-page app that isn't otherwise using any framework at all. They're a weaker fit for building an entire application's UI: there's no built-in state management, no templating syntax as expressive as JSX or Vue's templates, and Shadow DOM's style isolation can actively fight against a project standardized on a single global design system (a utility framework's classes, for instance, don't cross a shadow boundary). Most teams building a full app still reach for React, Vue, or a similar framework, and use Web Components selectively for the handful of components that genuinely need framework-agnostic portability.

Common mistakes

  • Forgetting the required hyphen in a custom element's tag name (<usercard> instead of <user-card>) — the spec requires it, and customElements.define() throws if the name doesn't contain one.
  • Doing DOM setup in the constructor instead of connectedCallback — the constructor runs before the element is guaranteed to be in the document, and some operations (like reading attributes reliably) are safer deferred to connectedCallback.
  • Expecting page-level CSS (a framework's utility classes, a global stylesheet) to style the inside of an open shadow root — it won't, by design; only :host, CSS custom properties, and ::part() cross that boundary.
  • Reaching for a hand-rolled custom element to solve a problem a framework you're already using solves natively (state, reactivity, templating) — Web Components shine for portable, standalone widgets, not as a full replacement for an app's component framework.