StackPractices
intermediate By Mathias Paulenko

Web Components — Custom Elements, Shadow DOM & Templates

A practical guide to Web Components: creating reusable custom elements, encapsulating styles with Shadow DOM, and composing with HTML templates.

Topics: frontend

Overview

Web Components are a set of browser-native APIs for creating reusable, encapsulated HTML elements. They work in any framework — or without one — and provide true style and DOM encapsulation via Shadow DOM. Here is a hands-on guide to the three core technologies: Custom Elements, Shadow DOM, and HTML Templates, with practical examples you can use today.

When to Use

  • You need reusable UI elements shared across different projects or frameworks
  • You want style encapsulation without CSS-in-JS or BEM naming conventions
  • You’re building a design system that must work in React, Vue, Angular, or vanilla JS
  • You need to extend native HTML elements with custom behavior
  • You want framework-independent components for long-term maintainability

Web Components also shine inside island-style architectures, where small interactive units hydrate independently — the islands architecture pattern pairs naturally with custom elements as the unit of encapsulation.

When NOT to Use

  • The whole app lives in one framework and ships as a single bundle — framework components are simpler, with better state management and TypeScript integration
  • You need rich accessibility for free — a <select> or <dialog> reimplemented as a custom element loses native keyboard and screen-reader behavior you then have to rebuild
  • The component must render before JavaScript runs — autonomous custom elements need JS to upgrade, unless you ship declarative Shadow DOM (server-rendered)
  • SEO-critical content lives inside the shadow root — search engines handle shadow DOM inconsistently, so keep indexable copy in the light DOM or use slots

The honest trade-off: Web Components give you portability and longevity (the API is the platform, it won’t be deprecated next year), at the cost of doing more plumbing yourself. If your team already shares a React design system, wrapping it in custom elements adds a boundary without buying interoperability.

The Three Technologies

TechnologyPurposeStandard
Custom ElementsDefine new HTML tags with JavaScriptCustom Elements v1
Shadow DOMEncapsulate DOM and styles inside a componentShadow DOM v1
HTML TemplatesDeclare reusable markup fragmentsHTML Template Element

Custom Elements

Autonomous Custom Elements

Create entirely new HTML tags.

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

  connectedCallback() {
    this.render();
  }

  render() {
    const name = this.getAttribute('name') || 'Anonymous';
    const role = this.getAttribute('role') || 'User';
    
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; }
        .name { font-weight: 600; color: #111827; }
        .role { color: #6b7280; font-size: 0.875rem; }
      </style>
      <div class="name">${name}</div>
      <div class="role">${role}</div>
    `;
  }

  static get observedAttributes() {
    return ['name', 'role'];
  }

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

customElements.define('user-card', UserCard);
<!-- Usage -->
<user-card name="Alice Chen" role="Senior Engineer"></user-card>
<user-card name="Bob Smith" role="Product Manager"></user-card>

Customized Built-in Elements

Extend existing HTML elements with new behavior.

class ConfirmButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', (e) => {
      if (!confirm(this.getAttribute('confirm-message') || 'Are you sure?')) {
        e.preventDefault();
      }
    });
  }
}

customElements.define('confirm-button', ConfirmButton, { extends: 'button' });
<!-- Usage via is="" attribute -->
<button is="confirm-button" confirm-message="Delete this file permanently?">
  Delete
</button>

Attributes vs Properties

Attributes are strings in markup; properties are JavaScript values on the element instance. Consumers will use both, so a well-behaved component keeps them in sync — the pattern is called reflecting:

class ToggleSwitch extends HTMLElement {
  static get observedAttributes() {
    return ['checked'];
  }

  // Property API — accepts real JS values
  get checked() {
    return this.hasAttribute('checked');
  }
  set checked(value) {
    if (value) this.setAttribute('checked', '');
    else this.removeAttribute('checked');
  }

  attributeChangedCallback() {
    this.shadowRoot.querySelector('input').checked = this.checked;
    this.shadowRoot.host.setAttribute('aria-checked', String(this.checked));
  }
}

Reflect state to attributes only when it’s meaningful in markup (it selects well in CSS and shows up in DevTools). Rich data — objects, callbacks, lists — belongs in properties only; stuffing JSON into attributes is a debugging trap.

Shadow DOM

Encapsulation

Shadow DOM isolates a component’s DOM and CSS from the rest of the page.

class StyledCounter extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        /* Scoped to this component only */
        button {
          background: #3b82f6;
          color: white;
          border: none;
          padding: 0.5rem 1rem;
          border-radius: 0.25rem;
          cursor: pointer;
        }
        button:hover { background: #2563eb; }
        span { margin-left: 0.5rem; font-weight: 600; }
      </style>
      <button id="inc">+</button>
      <span id="count">0</span>
    `;
    
    this.count = 0;
    shadow.getElementById('inc').addEventListener('click', () => {
      this.count++;
      shadow.getElementById('count').textContent = this.count;
    });
  }
}

customElements.define('styled-counter', StyledCounter);

Slots

Slots let users inject content into a component’s shadow DOM.

class AlertBox extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: block; padding: 1rem; border-radius: 0.5rem; }
        :host([type="error"]) { background: #fef2f2; border: 1px solid #fca5a5; }
        :host([type="warning"]) { background: #fffbeb; border: 1px solid #fcd34d; }
        :host([type="success"]) { background: #f0fdf4; border: 1px solid #86efac; }
        ::slotted(h3) { margin: 0 0 0.5rem; font-size: 1rem; }
        ::slotted(p) { margin: 0; color: #4b5563; }
      </style>
      <slot name="title"></slot>
      <slot></slot>
    `;
  }
}

customElements.define('alert-box', AlertBox);
<!-- Usage with named slots -->
<alert-box type="error">
  <h3 slot="title">Connection Failed</h3>
  <p>Unable to reach the server. Please check your network and try again.</p>
</alert-box>

Declarative Shadow DOM

Attach a shadow root without JavaScript by putting a <template shadowrootmode> inside the element’s HTML. The browser upgrades it into a real shadow root before the element’s class even runs — which means the markup is already there for SSR, no-JS fallbacks, and faster first paint:

<alert-box type="error">
  <template shadowrootmode="open">
    <style>
      :host { display: block; padding: 1rem; }
      ::slotted(h3) { margin: 0 0 0.5rem; }
    </style>
    <slot name="title"></slot>
    <slot></slot>
  </template>
  <h3 slot="title">Connection Failed</h3>
  <p>Unable to reach the server.</p>
</alert-box>

Declarative Shadow DOM is supported in Chrome/Edge 111+, Safari 16.4+, and Firefox 123+. In your class, check this.shadowRoot before calling attachShadow — the root already exists when the element was declaratively defined.

HTML Templates

Templates declare reusable markup that is not rendered until cloned.

<template id="user-row-template">
  <tr>
    <td class="name"></td>
    <td class="email"></td>
    <td><button class="delete">Remove</button></td>
  </tr>
</template>
function createUserRow(user) {
  const template = document.getElementById('user-row-template');
  const clone = template.content.cloneNode(true);
  
  clone.querySelector('.name').textContent = user.name;
  clone.querySelector('.email').textContent = user.email;
  clone.querySelector('.delete').addEventListener('click', () => removeUser(user.id));
  
  return clone;
}

// Append to table
document.querySelector('#users tbody').appendChild(createUserRow({
  name: 'Alice Chen',
  email: 'alice@example.com'
}));

Communicating Between Components

Attributes pass data down; custom events pass it up. The detail most people miss: an event dispatched inside a shadow root stops at the shadow boundary unless you set composed: true.

class UserCard extends HTMLElement {
  select() {
    this.dispatchEvent(new CustomEvent('user-selected', {
      detail: { userId: this.getAttribute('user-id') },
      bubbles: true,    // let it travel up the tree
      composed: true    // REQUIRED to escape the shadow root
    }));
  }
}

// Any ancestor — outside the shadow DOM — can listen:
document.addEventListener('user-selected', (e) => {
  console.log('Selected user:', e.detail.userId);
});

The convention is DOM events for parent-to-child-ish communication and attributes/properties for the rest. Avoid a shared global event bus for component internals — it leaks the component’s API surface across the whole app.

Form-Associated Custom Elements

A plain custom element can’t participate in a <form> — it won’t submit a value or validate. ElementInternals fixes that. Opt in with static formAssociated = true, then use attachInternals() to set the value and validity the form sees:

class RatingInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <div id="stars">★★★★★</div>
    `;
    this.value = 0;
  }

  connectedCallback() {
    const stars = [...this.shadowRoot.querySelector('#stars').childNodes];
    this.shadowRoot.getElementById('stars').addEventListener('click', (e) => {
      this.value = Math.ceil(e.offsetX / (e.target.offsetWidth / 5));
      this.internals.setFormValue(String(this.value));
      this.internals.setValidity(
        this.value > 0 ? {} : { customError: true },
        'Pick a rating'
      );
    });
  }
}
customElements.define('rating-input', RatingInput);
<form>
  <rating-input name="rating" required></rating-input>
  <button type="submit">Send</button>
</form>

Now <rating-input> behaves like a native control: its value travels with the form submission, form.reportValidity() shows your message, and form.reset() calls the formResetCallback() lifecycle hook if you implement it.

Theming with CSS Custom Properties

Shadow DOM blocks external styles — but custom properties pierce the boundary. That’s the intended theming API: expose a small set of variables and let consumers override them from the outside.

// inside the component's shadow styles
`
  :host {
    display: block;
    padding: var(--card-padding, 1rem);
    border: 1px solid var(--card-border, #e5e7eb);
    border-radius: 0.5rem;
  }
  .title { color: var(--card-title-color, #111827); }
`
/* consumer page — applies through the shadow boundary */
user-card {
  --card-title-color: #1d4ed8;
}

.dark-section user-card {
  --card-title-color: #e5e7eb;
  --card-border: #374151;
}

Publish the variable names as part of the component’s API, the same way you’d document props. Resist the urge to expose ::part() for everything — variables scale better for design-token systems.

Component Lifecycle

CallbackWhen It FiresCommon Use
constructor()Element createdInitialize state, attach shadow root
connectedCallback()Inserted into DOMRender, fetch data, add event listeners
disconnectedCallback()Removed from DOMClean up timers, event listeners, subscriptions
attributeChangedCallback()Observed attribute changesRe-render, validate, update internal state
adoptedCallback()Moved to new documentRarely used; cleanup and re-initialize
Mermaid flowchart TD diagram

Two traps around the lifecycle: connectedCallback fires every time the element is (re)inserted — moving a node in the DOM triggers it — so don’t assume it runs once; and attributeChangedCallback can fire before connectedCallback for attributes present in the initial markup, so guard rendering code against a not-yet-connected element.

Framework Integration

Web Components work in any framework.

// React
import 'my-ui-library';

function App() {
  return (
    <div>
      <user-card name="Alice" role="Engineer" />
      <alert-box type="success">
        <h3 slot="title">Saved</h3>
        <p>Your changes have been saved.</p>
      </alert-box>
    </div>
  );
}
<!-- Vue -->
<template>
  <user-card :name="user.name" :role="user.role" />
</template>

<script>
import 'my-ui-library';
export default { props: ['user'] };
</script>

Two integration details bite in practice. React < 19 passes everything to custom elements as attributes — objects, arrays, and function props get stringified — and doesn’t bind onXyz handlers to CustomEvents. React 19 fixed both behaviors; on older versions you either set properties via a ref or wrap the element in a thin React component. The second issue is slots: frameworks that render children into your element (React, Vue) work fine because the slotted nodes are light DOM, but the wrapper must preserve the slot="name" attribute on them.

SSR and Hydration

Server-rendering custom elements is the story’s weakest chapter. The element’s shadow DOM exists only after customElements.define runs — there’s no client-side “hydration” of shadow roots the way React hydrates a DOM tree. The two workable paths:

  1. Declarative Shadow DOM: your SSR pipeline serializes each element with a <template shadowrootmode> child (Lit SSR, @lit-labs/ssr, and Eleventy’s @11ty/is-land do this). The browser upgrades the markup into real shadow roots before scripts run, so nothing flashes and nothing “hydrates” — the element just wakes up already rendered.
  2. Client-only islands: render a placeholder server-side and let the element upgrade on the client. Simple, but you get a flash of unupgraded markup and lost crawlability.

Framework SSR (Next.js, Nuxt) doesn’t emit declarative shadow DOM for custom elements today, so components inside framework pages follow path 2 unless you post-process the HTML. If SSR correctness matters, render the components’ markup yourself or pick a library (Lit) with first-class DSD support.

Accessibility Considerations

Shadow DOM changes the accessibility rules in two places that are easy to miss. First, ARIA references can’t cross the shadow boundary — an aria-labelledby inside the shadow root can’t point to an element in the light DOM, and vice versa. If a label must live outside, mirror it with aria-label on the host or use ElementInternals’ ariaLabel/role properties (the form-associated example shows how internals is the bridge for semantics too).

Second, focus doesn’t traverse the boundary by default. attachShadow({ mode: 'open', delegatesFocus: true }) forwards focus into the shadow tree so keyboard users can reach the interactive parts; without it, tabbing skips straight past your component. Test tab order manually — it’s the fastest way to catch a component that’s focus-invisible.

Everything else is the usual discipline: give the host a meaningful role if it behaves like a widget, keep visible labels instead of title tooltips, and check contrast for slotted content your styles don’t control. The WCAG guide covers the baseline criteria your components still have to meet — shadow DOM doesn’t exempt anyone.

Testing Web Components

Unit-test custom elements like any other class — but render them in a real browser, because customElements.define and attachShadow don’t exist in jsdom’s default setup. Web Test Runner runs the tests in an actual browser:

import { fixture, expect } from '@open-wc/testing';
import '../user-card.js';

describe('user-card', () => {
  it('renders the name attribute', async () => {
    const el = await fixture('<user-card name="Ada"></user-card>');
    const name = el.shadowRoot.querySelector('.name').textContent;
    expect(name).to.equal('Ada');
  });

  it('re-renders when an observed attribute changes', async () => {
    const el = await fixture('<user-card name="Ada"></user-card>');
    el.setAttribute('name', 'Grace');
    expect(el.shadowRoot.querySelector('.name').textContent).to.equal('Grace');
  });
});

For E2E, Playwright locators pierce open shadow roots automatically — page.getByRole('button') finds buttons inside your component without any special syntax, which is one of the nicest surprises when migrating tests from framework components.

All the components from this guide — user-card, alert-box, and a form-associated rating-input, plus a demo page and runnable @open-wc tests — live in the companion repo. Clone it and serve the folder statically; there’s no build step.

Browser Support and Polyfills

Everything in this guide is baseline-available in all evergreen browsers. The sharp edges are: customized built-in elements (is=""), which Safari refuses to implement — treat them as progressive enhancement, never as your primary API — and declarative Shadow DOM, which reached full support only in 2024 (Firefox 123+).

For real legacy support the @webcomponents/polyfills package covers Custom Elements, Shadow DOM, and templates back to IE11-era browsers, but shipping the polyfill costs ~60KB and is rarely worth it today — check your actual browser matrix first.

Common Mistakes

  • Forgetting to handle attribute changes — observed attributes must trigger re-render
  • Using innerHTML on the host element — always use Shadow DOM for component markup
  • Not cleaning up in disconnectedCallback() — memory leaks from orphaned event listeners
  • Assuming constructor runs after DOM insertion — it runs at creation; DOM may not exist yet
  • Missing polyfills for older browsers — Edge 18 and IE11 need the webcomponentsjs polyfill

Troubleshooting

  • attributeChangedCallback never fires: the attribute isn’t in observedAttributes, or you’re setting a property instead of an attribute. Only listed attributes trigger the callback, and el.prop = x never does — use el.setAttribute('prop', x) or implement a setter that reflects to an attribute.
  • Events inside the component never reach the parent: the listener sits outside the shadow boundary and the event wasn’t dispatched with composed: true. bubbles alone isn’t enough — check the event flags in the dispatch call.
  • ::slotted() styles don’t apply: ::slotted() only matches the element assigned directly to the slot, not its descendants — ::slotted(h3) works, ::slotted(h3 span) never does. Style deeper nodes with CSS custom properties instead.
  • Component renders twice or loses state on re-render: the render method rebuilds innerHTML, which destroys child listeners and focus. Diff and patch only what changed, or keep a light render-once + update-nodes pattern like the examples above.
  • Page styles leak into the component (or don’t): inheritance crosses the shadow boundary for inherited CSS properties (font, color) but not for element selectors. If a global button { ... } styles your shadow button, you didn’t attach a shadow root; if a CSS variable doesn’t apply, it’s being set on the wrong ancestor.
  • <form> ignores the custom element: you forgot static formAssociated = true — without it, attachInternals() throws and the element submits nothing.

Key Takeaways

  • Attributes in, composed events out — that’s the whole public API surface of a well-built custom element.
  • Shadow DOM is what makes the component portable: style encapsulation is the feature, not an annoyance to bypass.
  • Use CSS custom properties as the theming contract between the component and the page; document them like props.
  • Skip customized built-in elements for anything user-facing — Safari won’t implement is="", so autonomous elements plus declarative Shadow DOM cover more ground.
  • Test in a real browser (Web Test Runner or Playwright); jsdom’s shadow DOM support has too many gaps to be trustworthy.

Further Reading

Frequently Asked Questions

Do Web Components replace React/Vue/Angular?

No — they complement them. Use Web Components for design-system elements shared across frameworks or for embeddable widgets; keep framework components for app-specific, state-heavy UI.

Can Web Components use external CSS frameworks?

Global styles don't enter the shadow root. Load the framework's CSS inside each shadow root (costly), or expose a styling API with CSS custom properties and ::part() — the second option is what design systems do.

How do I test Web Components?

Unit-test in a real browser with Web Test Runner and @open-wc/testing. For E2E, Playwright locators pierce open shadow roots, so getByRole and getByText just work.

Do search engines index content inside the shadow DOM?

Google renders JavaScript and flattens open shadow roots, but relying on that for indexable content is risky — keep SEO-critical markup in the light DOM or ship declarative Shadow DOM server-side.

What's the difference between an attribute and a property on a custom element?

Attributes are strings in the HTML (name="Ada"); properties are JS values on the instance (el.user = {...}). Rich data should arrive via properties; attributes are for configuration that belongs in markup.

Should I use closed shadow roots to protect internals?

No — mode: 'closed' hides element.shadowRoot, which breaks testing tools and accessibility workarounds without adding real security. Open roots are the ecosystem default.