Tools Of The Browser

Web Components: Custom Elements, Shadow DOM, and Slots

Web Components are browser APIs for custom HTML elements. Learn where Custom Elements, Shadow DOM, templates, and slots fit, with a full copy-code build.

14 min read

Illustration of a browser window displaying a custom <copy-code> component, shown alongside a simplified HTML structure to represent how web components work.

Web Components are a set of browser APIs for building custom HTML elements. The core pieces are Custom Elements, Shadow DOM, templates, and slots.

A Web Component can look like this in HTML:

<copy-code>npm run build</copy-code>

The page author uses one tag. The component class supplies the behavior, internal markup, styling, and events.

Custom Elements define new tags

A Custom Element connects a hyphenated tag name to a JavaScript class.

class CopyCode extends HTMLElement {
  connectedCallback() {
    this.textContent = this.textContent.trim();
  }
}

customElements.define("copy-code", CopyCode);

The hyphen is required. Names such as copy-code, date-picker, and user-card are valid. Names such as copycode are not custom element names.

When the browser sees <copy-code>, it upgrades that element to an instance of the registered class. Calling customElements.define() twice with the same name throws NotSupportedError, so each tag name must be unique.

The lifecycle

Custom Elements expose callbacks for key moments:

CallbackRuns when
constructor()the element instance is created
connectedCallback()the element enters the document
disconnectedCallback()the element leaves the document
attributeChangedCallback()an observed attribute changes
adoptedCallback()the element moves to another document

Use the constructor for internal setup. Use connectedCallback() for DOM work, event listeners, and rendering that depends on being attached to the page.

class TogglePanel extends HTMLElement {
  connectedCallback() {
    this.querySelector("button")?.addEventListener("click", this.toggle);
  }

  disconnectedCallback() {
    this.querySelector("button")?.removeEventListener("click", this.toggle);
  }

  toggle = () => {
    this.toggleAttribute("open");
  };
}

Cleanup matters because components can be added and removed without a full page reload. A timer started in connectedCallback() and never cleared in disconnectedCallback() leaks for the life of the tab.

Attributes and properties

Attributes live in HTML and are always strings:

<price-badge amount="19.99" currency="EUR"></price-badge>

Properties live on the JavaScript object and can hold any value:

const badge = document.querySelector("price-badge");
badge.amount = 19.99;
badge.formatter = new Intl.NumberFormat("en", { style: "currency", currency: "EUR" });

They do not sync automatically. Native elements like <input> bridge the two internally. In a Custom Element you build that bridge yourself.

static get observedAttributes() {
  return ["amount"];
}

attributeChangedCallback(name, oldValue, newValue) {
  if (name === "amount") this.amount = Number(newValue);
}

Use attributes for declarative configuration. Use properties for state, objects, and values set by scripts.

Attribute reflection

Reflection is the reverse direction: a property writes its value back to the matching attribute so the two stay aligned.

set open(value) {
  value ? this.setAttribute("open", "") : this.removeAttribute("open");
}

get open() {
  return this.hasAttribute("open");
}

Reflect a value when it represents state that affects styling or accessibility, the way <details open> does. Do not reflect large objects or values that have no meaning in HTML, because each write touches the DOM.

Shadow DOM encapsulates internals

Shadow DOM lets a component attach a separate DOM tree to itself.

class CopyCode extends HTMLElement {
  connectedCallback() {
    const root = this.attachShadow({ mode: "open" });
    root.innerHTML = `
      <style>
        button { font: inherit; }
      </style>
      <pre><slot></slot></pre>
      <button type="button">Copy</button>
    `;
  }
}

Styles inside the shadow root do not leak out to the page. Page styles do not casually leak in. That boundary is why a widget keeps its look on a site running Tailwind, Bootstrap, or an aggressive CSS reset.

The boundary is not a security boundary. Scripts can still reach into an open shadow root, and a closed root is an encapsulation choice, not a protection model.

Open vs closed shadow roots

The mode you pass to attachShadow() decides whether outside code can read the root.

this.attachShadow({ mode: "open" });   // element.shadowRoot returns the root
this.attachShadow({ mode: "closed" }); // element.shadowRoot returns null

An open root is inspectable in DevTools and reachable through element.shadowRoot, which makes debugging and testing easier. A closed root hides internals from page scripts, but it also blocks your own tooling and most testing helpers. Most components use open.

Slots project page content

A slot is a placeholder inside Shadow DOM where outside content appears.

<notice-box>
  <strong>Heads up:</strong> the export is lossy.
</notice-box>

Inside the component:

<div class="notice">
  <slot></slot>
</div>

The component controls the wrapper and styling. The page author provides the content.

Named slots allow more structure:

<article-card>
  <h2 slot="title">Progressive JPEGs</h2>
  <p slot="summary">A low-detail scan appears before the final image.</p>
</article-card>

A bare <slot>No title</slot> shows fallback content when the page passes nothing.

Slotted content stays in the light DOM, so the page can style it and your shadow CSS cannot, except through ::slotted():

::slotted(strong) {
  font-weight: 700;
}

::slotted() only matches the top-level slotted node, not its descendants. That limit is one of the sharper edges of Shadow DOM styling.

Templates avoid repeated parsing

The <template> element holds inert markup. The browser parses it but does not render it until you clone it.

<template id="copy-code-template">
  <pre><slot></slot></pre>
  <button type="button">Copy</button>
</template>
const template = document.querySelector("#copy-code-template");
const root = this.attachShadow({ mode: "open" });
root.append(template.content.cloneNode(true));

Cloning a parsed template is cheaper than re-parsing an HTML string for every instance, which matters when many instances share the same internal markup.

Events should cross the boundary on purpose

A component can emit normal DOM events:

this.dispatchEvent(new CustomEvent("copy", {
  bubbles: true,
  composed: true,
  detail: { value }
}));

bubbles: true lets the event move up through ancestors. composed: true lets it cross the shadow boundary. Without composed, the page may never see the event.

As an event leaves the shadow root, the browser retargets it: inside the component event.target is the real inner node, but on the page event.target becomes the host element. That keeps internal structure private. When you need the true path, read event.composedPath().

Use clear event names and put structured data in detail.

Styling the host with :host

The host is the element itself, the <copy-code> tag that still lives in the page. You style it from inside the shadow root with :host.

:host {
  display: inline-block;
}

:host([disabled]) {
  opacity: 0.5;
  pointer-events: none;
}

:host(.compact) .label {
  font-size: 0.8rem;
}

:host sets the element’s outward layout and default state. :host([attr]) reacts to attributes the way <button disabled> does. :host(.class) adapts when the page adds a class. There is also :host-context(.dark), which reaches outside the component to match an ancestor, but it couples the component to its surroundings, so reach for it only for theming.

Styling hooks for the page

Shadow DOM should not make a component impossible to style. Expose the parts the page is meant to control through three hooks:

  • CSS custom properties, such as --copy-code-border, which cross the boundary freely
  • ::part() for named internal parts
  • attributes on the host, such as [variant="warning"]
copy-code {
  --copy-code-border: 1px solid currentColor;
}

copy-code::part(button) {
  border: var(--copy-code-border);
}

Inside the component:

<button part="button">Copy</button>

Expose hooks for the parts the page should own. Keep the rest internal.

Constructable stylesheets

For larger components or a shared design system, a single CSSStyleSheet can be built once and adopted by many shadow roots instead of inlining a <style> string in each instance.

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  button { font: inherit; padding: 0.4rem 0.7rem; }
`);

this.attachShadow({ mode: "open" }).adoptedStyleSheets = [sheet];

The sheet is a real object, so it parses once and every component that adopts it shares the result.

A worked example: a copy-code component

The pieces fit together in a small, complete element. This copy-code component shows a snippet, adds a Copy button, writes the text to the clipboard, and gives feedback.

class CopyCode extends HTMLElement {
  connectedCallback() {
    const root = this.attachShadow({ mode: "open" });
    root.innerHTML = `
      <style>
        pre { margin: 0 0 0.5rem; overflow-x: auto; }
        button { font: inherit; cursor: pointer; }
      </style>
      <pre><slot></slot></pre>
      <button type="button">Copy</button>
    `;

    this.button = root.querySelector("button");
    this.button.addEventListener("click", this.copy);
  }

  disconnectedCallback() {
    this.button.removeEventListener("click", this.copy);
  }

  copy = async () => {
    await navigator.clipboard.writeText(this.textContent.trim());
    this.button.textContent = "Copied";
    setTimeout(() => (this.button.textContent = "Copy"), 800);

    this.dispatchEvent(new CustomEvent("copy", { bubbles: true, composed: true }));
  };
}

customElements.define("copy-code", CopyCode);

Used in a page:

<copy-code>const total = items.reduce((a, b) => a + b, 0);</copy-code>

The example uses every core piece: the lifecycle attaches and cleans up the listener, the shadow root isolates the styles, the slot projects the snippet, and a composed event lets the page react to a copy.

Accessibility still belongs to the component

A Custom Element does not become accessible because it has a custom tag name. It still needs the right semantics.

If the component acts like a button, use a real <button> inside it, as the example does, so keyboard activation, focus, and the button role come for free. If you cannot use a native control, implement keyboard handling, focus states, and ARIA yourself:

this.setAttribute("role", "switch");
this.setAttribute("aria-checked", String(this.checked));
this.tabIndex = 0;

Shadow DOM can hide relationships when an id and its label sit on opposite sides of the boundary, because aria-labelledby does not cross it. Keep accessible names and the controls they describe in the same tree whenever possible.

Debugging in DevTools

Shadow DOM adds a layer, but the browser exposes clear entry points.

  • An open shadow root shows up as a #shadow-root node in the Elements panel. Expand it to see internal markup, scoped styles, and slotted content. A closed root will not appear, which is another reason to develop with open.
  • Right-click any node and choose Reveal in Elements panel to jump to its real position inside the tree.
  • When an event seems to vanish, log event.composedPath() to see the full path through the shadow boundary, and confirm the event was dispatched with composed: true.
  • Call node.getRootNode() to check where a node lives. It returns document for the light DOM and the shadow root for shadow content.

A quick checklist when a component misbehaves: is the shadow root attached, is the internal HTML rendering, is the slot receiving content, are the right attributes observed, and does the event that should escape have composed: true.

Where Web Components fall short

Web Components give you elements, not an application framework. They do not provide state management, a diffing renderer, routing, or hydration. For an interactive dashboard, a framework usually carries that weight better.

A few other limits are worth knowing before you commit:

  • Server-side rendering needs extra work. A plain Custom Element renders only after its JavaScript runs. Declarative Shadow DOM addresses this, but it is not automatic.
  • Styling slotted content is restricted to ::slotted() on the top-level node.
  • Inside a framework that already owns rendering and state, a second component model can add coordination cost rather than removing it.

When Web Components fit

Web Components fit shared widgets that need to work outside one framework:

  • copy buttons, tab panels, and accordions
  • media controls and embeddable widgets
  • design-system primitives
  • CMS-embedded and documentation components

The decision rule: use Web Components when the element needs to travel between environments. Use the app’s native component system when it will only ever live inside that app.

Read More From Our Blog

Read the blog →

Explore Our Tools

Browse all tools