What Are Web Components

Template, custom elements, shadow DOM, and ES modules: the four native browser APIs that let you define your own HTML elements without any framework.

June 10, 20266 min read1 / 3

The first time I saw <my-counter> in an HTML file with no framework, no build step, and no bundler -- just a plain .js file included at the bottom -- I didn't understand how it worked. It looked like an element that shouldn't exist. Yet there it was: rendering UI, handling clicks, managing its own state.

That's what web components are. Not a library. Four native browser APIs that let you define your own HTML tags, the same way the browser defines <video> or <details>.

Web components are part of the same browser platform surface that includes geolocation, sensors, and dozens of other APIs most front-end developers haven't touched. The difference is that web components aren't read-only -- they let you extend the platform.

The four browser APIs that make up web components ExpandThe four browser APIs that make up web components

Template and Slot

The <template> element has been in HTML longer than most developers realize. The browser parses it fully and knows what's inside. It just never renders it to the page. Think of it as a ghost element: present in the DOM, invisible to the user.

HTML
<template id="user-card"> <div class="card"> <img class="avatar"> <span class="name"></span> </div> </template>

Nothing above appears on screen. When you want it, you clone it:

JavaScript
const template = document.getElementById('user-card'); const instance = template.content.cloneNode(true); document.body.appendChild(instance);

<slot> is template's companion. It's a named placeholder inside a template that reveals content written between the custom element's tags in your HTML -- content you don't control inside the component.

HTML
<!-- You write this --> <user-card>James Nguyen</user-card> <!-- Inside the template --> <template> <div class="card"> <slot></slot> </div> </template>

The text "James Nguyen" is light DOM content -- it lives in your HTML. The <slot> is shadow DOM content -- it lives inside the component. The slot is the point where they meet.

You can have multiple named slots. When the slotted content changes at runtime, the slot fires a slotchange event so the component can react.

Custom Elements

This is the defining feature of the web components spec. One rule: the tag name must include a hyphen. That's how the browser tells your elements from native ones. <button> is native. <my-button> is yours. The dash is a reservation fence.

To register a custom element, extend HTMLElement and call customElements.define:

JavaScript
class UserCard extends HTMLElement { constructor() { super(); } connectedCallback() { this.render(); } disconnectedCallback() { // cleanup: remove event listeners, clear timers } static get observedAttributes() { return ['name', 'variant']; } attributeChangedCallback(name, oldVal, newVal) { this.render(); } render() { this.textContent = this.getAttribute('name') ?? ''; } } customElements.define('user-card', UserCard);

The lifecycle methods map to what you already know from framework components:

  • connectedCallback is like componentDidMount or useEffect(() => {}, [])
  • disconnectedCallback is the cleanup return from useEffect
  • attributeChangedCallback fires reactively whenever a listed attribute changes

The difference is that these callbacks are browser-native. No virtual DOM, no reconciler. The browser itself calls them.

Shadow DOM

The light DOM and shadow DOM boundary in a web component ExpandThe light DOM and shadow DOM boundary in a web component

Shadow DOM is an encapsulation boundary. Styles written inside it do not leak out. External styles do not leak in. It's what makes a web component truly self-contained rather than just a class with some HTML.

The analogy that made it click for me: an egg. The egg keeps its contents inside. The outside doesn't get in. The shadow DOM is the egg.

JavaScript
class UserCard extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> .card { padding: 1rem; border: 1px solid currentColor; } </style> <div class="card"> <slot></slot> </div> `; } }

The mode option matters more than it looks:

  • 'open' -- JavaScript outside the component can reach element.shadowRoot and traverse inside
  • 'closed' -- element.shadowRoot returns null. External code cannot get in.

Shadow DOM is the feature that trips people up most. If your CSS stops working inside a component, or querySelector returns null for something you know is there, this boundary is the reason. Once you expect the fence, it stops being a surprise.

There's also delegatesFocus: true -- an accessibility option worth knowing. When a user clicks on a custom element that wraps an input, focus should pass through to that input automatically. Without this flag, focus lands on the outer element and the input inside the shadow root stays unreachable by keyboard navigation.

ES Modules

Before ES modules, sharing code between files meant <script> tags in the right order, global namespace collisions, and fragile load-order dependencies. ES modules give you import and export at the language level.

JavaScript
// card.js export class UserCard extends HTMLElement { ... } // main.js import { UserCard } from './card.js'; customElements.define('user-card', UserCard);

To load a module directly in HTML without a bundler:

HTML
<script type="module" src="./main.js"></script>

Scripts with type="module" get a dedicated fast-path in modern browser JS engines. Legacy scripts use an older parser. Switching to module syntax is a free performance improvement regardless of whether you're using web components at all.

ES modules also support extends for subclassing. You can import a web component from a library and wrap it with a new tag name and different defaults, without touching the source:

JavaScript
import { PrimaryButton } from './ui-library.js'; class LargeButton extends PrimaryButton { connectedCallback() { super.connectedCallback(); this.setAttribute('size', 'large'); } } customElements.define('large-button', LargeButton);

The Four Together

Each pillar solves a distinct problem:

  • <template> gives you parsed, clonable HTML that stays out of the page until you need it
  • Custom elements give you the hook to register a tag and attach lifecycle behavior
  • Shadow DOM gives you the encapsulation so that behavior and styles stay contained
  • ES modules give you the import system to distribute and compose it

None of them require a framework. None require a bundler. They are in the browser -- and have been in all major browsers since 2020, when the final holdout landed support.

The four pillars tell you what web components are. The adoption history tells you something different: why a technology proposed in 2011 took until 2020 to land everywhere, and what that gap means for teams evaluating them now.

The Essentials

  1. A web component is a custom HTML element built on four native browser APIs: <template>, custom elements, shadow DOM, and ES modules.
  2. Every custom element name must contain a hyphen. This separates custom tags from native HTML elements, which are reserved without one.
  3. customElements.define('my-tag', MyClass) links an HTML tag name to a JavaScript class that extends HTMLElement.
  4. Shadow DOM is a scoped subtree inside a custom element. Styles defined inside do not leak out, and external stylesheets do not leak in by default.
  5. mode: 'open' lets external JavaScript access element.shadowRoot. mode: 'closed' returns null -- the internals are unreachable.
  6. A <slot> inside a shadow DOM template is a placeholder that reveals content written between the custom element's tags in your HTML (the light DOM).
  7. Scripts with type="module" get a separate fast-path in modern JS engines. Switching to ES modules is a free performance improvement.

Further Reading and Watching