The definitive guide to Web Components and Custom Elements

By

Learn how to build Web Components with Custom Elements, Shadow DOM, templates, slots, lifecycle callbacks, events, and form integration.

~~~

Web Components let you create reusable HTML elements using browser APIs.

You can define a tag like <user-card>, give it behavior with JavaScript, and decide whether its HTML and CSS should be isolated from the rest of the page.

No framework is required. A Custom Element works in a plain HTML page, and you can also use it inside React, Vue, Astro, or another framework.

Web Components are not one API. The name groups together a few browser features:

You can use Custom Elements without Shadow DOM. You can also use templates without creating a component.

Let’s build one component and use it to explore the complete model.

Define a Custom Element

A Custom Element is a JavaScript class that extends HTMLElement:

class UserCard extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Flavio'
  }
}

customElements.define('user-card', UserCard)

After registering it, you can use the element in HTML:

<user-card></user-card>

The name must contain a hyphen. This keeps future standard HTML elements separate from names created by applications.

Calling customElements.define() twice with the same name throws an error. You can check whether a definition already exists:

if (!customElements.get('user-card')) {
  customElements.define('user-card', UserCard)
}

The browser also provides customElements.whenDefined(). It returns a promise that resolves when a particular element has been registered:

await customElements.whenDefined('user-card')

This is useful when the component is loaded by a separate JavaScript file.

Use the lifecycle callbacks

Custom Elements have lifecycle callbacks. The browser calls them when something important happens to the element.

The most useful callback is connectedCallback(). It runs when the element is connected to a document:

class UserCard extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Flavio'
  }
}

Do setup that needs the element’s children or document connection here, not in the constructor.

The constructor is still useful for initial internal state and for attaching a Shadow Root. Keep it small:

class UserCard extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }
}

The other lifecycle callbacks are:

Use disconnectedCallback() to remove global event listeners, observers, timers, or other work that should not survive the component:

class ClockDisplay extends HTMLElement {
  connectedCallback() {
    this.timer = setInterval(() => this.render(), 1000)
    this.render()
  }

  disconnectedCallback() {
    clearInterval(this.timer)
  }

  render() {
    this.textContent = new Date().toLocaleTimeString()
  }
}

An element can connect more than once. Make setup and cleanup symmetrical so reconnection does not create duplicate listeners or timers.

Work with attributes and properties

HTML attributes are strings. JavaScript properties can hold any value.

This distinction matters. An attribute is useful for initial declarative configuration:

<user-card name="Flavio"></user-card>

List the attributes you want to observe:

class UserCard extends HTMLElement {
  static observedAttributes = ['name']

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'name' && oldValue !== newValue) {
      this.render()
    }
  }

  render() {
    this.textContent = this.getAttribute('name') || 'Unknown user'
  }
}

It is convenient to expose a matching property:

get name() {
  return this.getAttribute('name') || ''
}

set name(value) {
  this.setAttribute('name', value)
}

For a boolean attribute, its presence means true. The string value does not matter. Even disabled="false" means disabled because the attribute exists.

Use toggleAttribute() for boolean properties:

get disabled() {
  return this.hasAttribute('disabled')
}

set disabled(value) {
  this.toggleAttribute('disabled', Boolean(value))
}

Do not reflect every property to an attribute. Large objects, functions, and frequently changing values belong in JavaScript properties.

Add Shadow DOM

A Shadow Root gives the component its own DOM tree:

class UserCard extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        article {
          border: 1px solid currentColor;
          padding: 1rem;
        }
      </style>
      <article>User profile</article>
    `
  }
}

Styles inside that Shadow Root do not normally affect the page. Page selectors also do not reach into it.

This encapsulation is useful for a component you want to place in many projects. It can also make global styling, testing, and debugging less direct. Do not attach a Shadow Root only because you can.

An open Shadow Root is available through element.shadowRoot. A closed root is not. Closed mode is not a security boundary, so I normally use open mode.

Use templates instead of long strings

An HTML <template> holds inert markup. Its scripts do not run, images do not load, and its content is not displayed until you clone it.

<template id="user-card-template">
  <style>
    article {
      border: 1px solid currentColor;
      padding: 1rem;
    }
  </style>
  <article>
    <strong></strong>
  </article>
</template>

Clone the template into the Shadow Root:

class UserCard extends HTMLElement {
  constructor() {
    super()

    const template = document.querySelector('#user-card-template')
    const shadow = this.attachShadow({ mode: 'open' })

    shadow.append(template.content.cloneNode(true))
  }

  connectedCallback() {
    this.shadowRoot.querySelector('strong').textContent = this.name
  }

  get name() {
    return this.getAttribute('name') || 'Unknown user'
  }
}

For a small component, a string is fine. A template becomes helpful when the structure is larger or is easier to understand as HTML.

Accept content with slots

Without a slot, children written inside a shadow host are not displayed inside its Shadow DOM.

Add a default slot:

<template id="notice-box-template">
  <aside>
    <slot></slot>
  </aside>
</template>

Now the page can provide content:

<notice-box>
  Your profile was saved.
</notice-box>

Named slots let the page fill specific positions:

<template id="user-card-template">
  <article>
    <header><slot name="heading"></slot></header>
    <slot></slot>
  </article>
</template>
<user-card>
  <h2 slot="heading">Flavio</h2>
  <p>Web developer</p>
</user-card>

Slots preserve the page’s original nodes. This is good for semantics and lets the page keep control of its content.

Let the page style the component

Shadow DOM blocks normal selectors, but a component can deliberately expose styling hooks.

Use :host to style the custom element itself:

:host {
  display: block;
}

:host([hidden]) {
  display: none;
}

CSS custom properties cross the Shadow DOM boundary. They make useful configuration points:

article {
  border-color: var(--user-card-border, currentColor);
}
user-card {
  --user-card-border: orange;
}

You can expose selected internal elements with part:

<article part="card">...</article>

The page can then use ::part():

user-card::part(card) {
  border-radius: 0.5rem;
}

Prefer a small, intentional styling API. Exposing every internal element makes refactoring difficult.

Dispatch events from the component

Properties pass data into a component. Events are the cleanest way to announce something that happened inside it.

this.dispatchEvent(
  new CustomEvent('user-select', {
    bubbles: true,
    composed: true,
    detail: { id: this.userId },
  }),
)

bubbles: true lets an ancestor listen for the event. composed: true lets it cross the Shadow DOM boundary.

The page can listen like it would for a native event:

document.addEventListener('user-select', (event) => {
  console.log(event.detail.id)
})

Use an event name that describes what happened. Avoid reaching into a component’s Shadow Root from application code.

Keep Custom Elements accessible

A new tag has no built-in behavior or semantics. A <user-card> is not automatically a button, link, or form control.

Use native HTML inside the component whenever possible:

<button type="button">Save profile</button>

That button already supports focus, keyboard activation, disabled state, and accessibility semantics. Rebuilding all of that on a div creates unnecessary work.

Slots are also useful because headings, links, and other meaningful content stay in the page’s light DOM.

Test the finished component with a keyboard. Inspect its accessibility tree. Check zoom, forced colors, reduced motion, and long content. Shadow DOM does not make accessibility automatic.

Build form-associated Custom Elements

Most components should use a native input rather than imitate one. If you truly need a custom form control, the platform provides ElementInternals.

class RatingInput extends HTMLElement {
  static formAssociated = true

  constructor() {
    super()
    this.internals = this.attachInternals()
  }

  set value(value) {
    this.setAttribute('value', value)
    this.internals.setFormValue(value)
  }

  get value() {
    return this.getAttribute('value') || ''
  }
}

This lets the element participate in form submission. ElementInternals also provides form ownership, labels, validity, and callbacks for reset, disabled state, and restored form state.

This is an advanced use case. Start with a native control and enhance it unless your component has a strong reason to become a control itself. My HTML forms guide explains the native pieces you would otherwise need to reproduce.

Handle loading and undefined elements

The browser can parse a Custom Element before its class is registered. It initially behaves like an unknown HTML element, then upgrades when the definition becomes available.

You can target that state with :defined:

user-card:not(:defined) {
  visibility: hidden;
}

Be careful when hiding content. A large blank area can cause layout shifts. Server-rendered or light-DOM fallback content is often a better experience.

Custom Element classes are registered per page. Load the definition once, preferably as a JavaScript module:

<script type="module" src="/components/user-card.js"></script>

When I would use Web Components

I would use a Web Component when one UI element must work across several stacks.

A video player, code editor, date picker, or small embedded tool can be a good fit. The page only needs to load one module and use an HTML tag. The component can expose properties, events, slots, and a small styling API without depending on the host framework.

I would not build an entire application as hundreds of tiny Custom Elements by default. Application state, routing, data loading, and server rendering may be easier in the framework already used by the project.

I also avoid Shadow DOM for simple site-specific elements. A small class that enhances existing HTML is often easier to style and debug.

The useful question is not whether Web Components can replace a framework. It is whether a component needs a stable browser-native boundary.

Light DOM or Shadow DOM?

This is one of the most important design choices.

Light DOM means the component works with its normal children. The page can style those children, query them, and include them naturally in document-wide behavior.

Shadow DOM creates a private implementation tree. It protects internal structure and styles, but also creates a boundary that consumers must cross through properties, events, slots, CSS custom properties, and parts.

I would choose light DOM for a site-specific disclosure, tabs widget, or navigation enhancement. Those elements benefit from the site’s existing CSS and from remaining easy to inspect.

I would choose Shadow DOM for a portable component whose internal structure must survive very different host pages. An embedded player or complex input is a better example.

Encapsulation is a tradeoff, not a quality score. A Shadow Root can prevent accidental CSS conflicts. It can also stop a site’s typography, form styles, and testing selectors from reaching the component.

Start with the public contract:

Then choose the DOM model that makes that contract easiest to maintain.

A practical component checklist

Before shipping a Custom Element, check these points:

The HTML Standard Custom Elements section is the authoritative reference for lifecycle behavior and form-associated elements. For the DOM fundamentals behind the examples, see my DOM guide.

~~~

Related posts about platform: