← Shadow DOM · From Zero

Isolate with
Shadow DOM.

Shadow DOM gives your component a private, sealed DOM tree. Styles inside cannot leak out. Styles outside cannot leak in. No conventions needed. No build tools. Just the browser.

This tutorial builds up Shadow DOM knowledge one concept at a time, starting from the problem it solves. Every step has a live demo you can see immediately.

MDN — Using Shadow DOM · Official Documentation
Prerequisite

This tutorial assumes you know the basics of Web Components. If you haven't already, read the Web Components tutorial first.

STEP 01 The problem Shadow DOM solves

The Problem

Without Shadow DOM, your component's styles live in the global stylesheet. They can collide with other styles on the page — silently, unpredictably.

the collision problem CSS
/* Global stylesheet */
p { color: red; font-size: 24px; }

/* Your component writes a <p> tag */
/* It will be red and 24px — whether you want it or not */
/* You have no control */
Live — the collision

← This <p> is styled by the page

What's happening
  • The component above renders a <p> tag
  • The page's CSS is bleeding into the component and styling it
  • This is the problem Shadow DOM exists to prevent
STEP 02 Create the private tree

attachShadow()

attachShadow() creates a separate, private DOM tree attached to your element. It must be called in the constructor(), before the element enters the page.

attaching a shadow root JS
class MyCard extends HTMLElement {
  constructor() {
    super()  // always first

    // Create the shadow root
    // Returns a ShadowRoot object
    const shadow = this.attachShadow({ mode: "open" })

    // 'open'  → JS outside can read this.shadowRoot
    // 'closed'→ this.shadowRoot returns null from outside
    //           (rarely used — adds friction, not real security)
  }
}
What attachShadow() does
  • Creates a new, isolated DOM tree inside your element
  • Returns a ShadowRoot — accessible via this.shadowRoot
  • mode: "open" is almost always what you want
  • Can only be called once per element — calling twice throws an error
document
└─ <body>
└─ <my-card> ← your element
└─ #shadow-root ← private tree starts here
└─ <div> ← lives inside the shadow
└─ <p> <img>
STEP 03 Render inside the shadow

Writing to shadowRoot

Instead of writing to this.innerHTML (which puts content in the regular DOM), write to this.shadowRoot.innerHTML. Everything inside is now protected.

this vs this.shadowRoot JS
class MyCard extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: "open" })
  }

  connectedCallback() {

    // ✗ this.innerHTML — writes to the LIGHT DOM
    //   exposed to the page, styles can bleed in
    // this.innerHTML = '<p>Hello</p>'

    // ✓ this.shadowRoot.innerHTML — writes to the SHADOW DOM
    //   completely isolated from the page
    this.shadowRoot.innerHTML = `
      <p>Hello from the Shadow!</p>
    `
  }
}

customElements.define("my-card", MyCard)
Live — shadow root is protected

← Regular <p> — affected by page CSS

What just happened
  • The component renders a <p> inside its Shadow DOM
  • The page's red CSS does not affect it — it is isolated
  • Light DOM = the regular DOM, visible to page styles
  • Shadow DOM = the private tree, invisible to page styles
STEP 04 Styles that cannot escape

Style Isolation

A <style> tag inside Shadow DOM only affects that component. It cannot reach outside. The page cannot reach in. This is true browser-enforced encapsulation.

styles are scoped automatically JS
class StyledCard extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: "open" })
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        p {
          color: #c8f542;
          font-size: 15px;
          font-family: monospace;
          background: #111;
          padding: 12px 16px;
          border-radius: 6px;
          border-left: 2px solid #c8f542;
        }
      </style>

      <p>I am styled by Shadow DOM.</p>
      <p>These styles cannot escape this component.</p>
    `
  }
}

customElements.define("styled-card", StyledCard)

// Any <p> tags OUTSIDE this component
// are completely unaffected by the styles above
Live — two components, zero collision
What just happened
  • Both components define a p { color: ... } rule
  • Neither rule affects the other — complete isolation
  • No BEM class names. No CSS Modules. No build step. Just Shadow DOM.
Without Shadow DOM
  • CSS leaks between components
  • Need BEM / CSS Modules
  • Global resets break things
  • Order of styles matters
  • Unpredictable at scale
With Shadow DOM
  • Styles are truly scoped
  • No naming conventions needed
  • Global CSS cannot interfere
  • Order does not matter
  • Predictable always
STEP 05 Style the element itself

The :host Selector

Inside Shadow DOM, :host refers to the custom element itself — the <my-card> tag in the page. Use it to set display, layout, sizing, and other outer styles.

:host — styling the element from inside CSS
<style>

  /* :host = the <my-card> element itself */
  :host {
    display: block;          /* custom elements are inline by default! */
    width: 240px;
    border: 1px solid #1e1e1e;
    border-radius: 8px;
    overflow: hidden;
  }

  /* :host() with a selector — apply when condition is true */
  :host([theme="dark"]) {
    background: #0a0a0a;
    color: #e2e2dc;
  }

  :host([theme="light"]) {
    background: #f5f5f5;
    color: #111;
  }

  /* :host-context() — style based on parent */
  :host-context(.sidebar) {
    width: 100%;             /* full width when inside .sidebar */
  }

</style>
Live — :host in action
Key :host rules
  • Custom elements are inline by default — always set display: block or inline-block in :host
  • :host(selector) — conditional styles based on attributes or classes
  • Page styles can still override :host — external CSS has higher specificity than :host
Important

Page CSS can override :host styles. So my-card { display: none } in your stylesheet will work. Only styles targeting elements inside the shadow are blocked.

STEP 06 Let the outside theme the inside

CSS Custom Properties

Shadow DOM blocks regular CSS — but CSS Custom Properties (variables) pierce through. This is the intentional, clean way to let the outside world theme your component.

theming through CSS variables CSS + JS
/* ── Outside the component (page CSS) ── */
themed-button {
  --btn-color:      #c8f542;
  --btn-background: #111;
  --btn-radius:     4px;
}

themed-button.danger {
  --btn-color:      #fff;
  --btn-background: #cc3333;
}


/* ── Inside the component (Shadow DOM) ── */
:host {
  display: inline-block;
}

button {
  color:            var(--btn-color,      #000);  /* fallback after comma */
  background:       var(--btn-background, #c8f542);
  border-radius:    var(--btn-radius,     4px);
  border: none;
  padding: 10px 20px;
  cursor: pointer;
  font-family: monospace;
  font-size: 14px;
}
Live — same component, different themes
How CSS variables pierce Shadow DOM
  • CSS Custom Properties are inherited — they pass through shadow boundaries
  • Regular CSS properties do notcolor: red on the host stops at the boundary
  • Fallback valuesvar(--my-var, fallback) ensures the component works without theming
  • This is the official pattern for building themeable Web Components
STEP 07 Accept content from outside

Slots

A <slot> is a placeholder inside your Shadow DOM where the user's HTML content is projected. The content stays in the Light DOM but appears visually inside your component.

named and default slots JS + HTML
// ── Component definition ──
class InfoCard extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: "open" })
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; border: 1px solid #1e1e1e;
                border-radius: 8px; overflow: hidden; }
        header { background: #111; padding: 12px 16px;
                 border-bottom: 1px solid #1e1e1e; }
        main   { padding: 16px; color: #a8a8a2; }
        footer { padding: 10px 16px; border-top: 1px solid #1e1e1e;
                 font-size: 12px; color: #5a5a54; }
      </style>

      <header>
        <slot name="title">Default Title</slot>
      </header>

      <main>
        <slot></slot>
      </main>

      <footer>
        <slot name="footer">No footer provided</slot>
      </footer>
    `
  }
}
customElements.define("info-card", InfoCard)


<!-- ── Usage in HTML ── -->
<info-card>
  <span slot="title">My Card Title</span>
  <p>This is the body content of the card.</p>
  <span slot="footer">Last updated: today</span>
</info-card>
Live — slots in action
Shadow DOM

Slots project Light DOM content into the Shadow DOM visually.

Step 07 demo
No Footer

This card has no footer slot — the default text shows instead.

How slots work
  • <slot> — default slot, catches any unassigned content
  • <slot name="x"> — named slot, only catches slot="x" content
  • Fallback content — text inside <slot> shows when nothing is projected
  • Slotted content stays in the Light DOM — it is projected visually, not moved
  • You can still style slotted content with ::slotted(selector)
Flow-Arch note

Slots are powerful but add complexity to the state model. In Flow-Arch demos, we generally avoid slots and pass all data through attributes and state instead. Slots are best for layout/wrapper components, not data-driven components.

COMPLETE Everything together

Full Example

A complete component using all Shadow DOM concepts: isolation, :host, CSS variables for theming, and clean scoped styles.

flow-badge.js — complete shadow dom component JS
class FlowBadge extends HTMLElement {

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

  // Re-render when these attributes change
  static get observedAttributes() {
    return ["label", "count"]
  }

  attributeChangedCallback() {
    this.render()
  }

  connectedCallback() {
    this.render()
  }

  render() {
    const label = this.getAttribute("label") || "badge"
    const count = this.getAttribute("count") || ""

    this.shadowRoot.innerHTML = `
      <style>
        /* :host controls outer layout */
        :host {
          display: inline-flex;
          align-items: center;
          gap: 6px;
        }

        /* CSS variables let the page theme this component */
        .badge {
          background: var(--badge-bg, #1e1e1e);
          color:      var(--badge-color, #c8f542);
          border:     1px solid var(--badge-border, #2a2a2a);
          padding: 4px 10px;
          border-radius: 100px;
          font-family: monospace;
          font-size: 12px;
          letter-spacing: 0.05em;
        }

        /* Scoped — only affects .count inside THIS component */
        .count {
          background: var(--badge-color, #c8f542);
          color:      var(--badge-bg, #000);
          padding: 2px 8px;
          border-radius: 100px;
          font-family: monospace;
          font-size: 11px;
          font-weight: 500;
        }
      </style>

      <span class="badge">${label}</span>
      ${count ? `<span class="count">${count}</span>` : ""}
    `
  }
}

customElements.define("flow-badge", FlowBadge)
Live — flow-badge component
You finished the Shadow DOM tutorial. Next: learn about Pure Functions.
Continue →