TL;DR: Web Components are a set of browser-native features that let you create your own custom HTML tags — <my-card>, <product-modal>, <star-rating> — that any browser understands without a framework. They're made of three pieces: Custom Elements (your new tag), Shadow DOM (isolated styles and markup), and HTML Templates (reusable markup patterns). AI generates them when you need something portable and self-contained. They're not a replacement for React — they're for when you want something that works everywhere, forever.

What Are Web Components?

You already know that HTML has built-in elements: <button>, <input>, <video>, <form>. The browser knows what to do with all of these because they're part of the HTML standard.

Web Components let you invent your own elements with that same level of browser support. No library required. No npm package. Just native browser APIs.

So instead of scattering the same card layout across ten different pages, you write it once as a Web Component — call it <info-card> — and drop that tag anywhere on your site. The browser figures out what to render.

Think of it like this: if you worked in construction and could invent your own prefab panel — one you design once, that snaps into any building, with its own weatherproofing built in — that's what Web Components are for the web. A self-contained unit that just works wherever you put it.

Web Components aren't new. They've been brewing in browser standards since around 2011. By now, every major browser supports them fully: Chrome, Firefox, Safari, Edge. No polyfills needed. They're just... there, waiting to be used.

Understanding them starts with understanding what HTML is and what the DOM is — because Web Components extend both. If those feel fuzzy, skim those articles first. This one will be here.

The Three Pieces: Custom Elements, Shadow DOM, and Templates

Web Components aren't one thing — they're three browser APIs that work together. You can use each piece independently, but AI usually generates all three at once when building a proper component. Here's what each one does.

1. Custom Elements

This is the core: the ability to define a new HTML tag and tell the browser exactly what it should do.

Custom element names must contain a hyphen. <my-button>, <product-card>, <nav-drawer>. The hyphen rule exists so custom elements never clash with future built-in HTML elements (which are never hyphenated).

You define one by extending HTMLElement in JavaScript:

class MyButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <button class="btn">
        ${this.getAttribute('label') || 'Click me'}
      </button>
    `;
  }
}

customElements.define('my-button', MyButton);

After that, anywhere in your HTML you write <my-button label="Submit"></my-button>, the browser renders a button. The JavaScript class is the blueprint; customElements.define registers it.

The connectedCallback is a lifecycle method — it runs automatically when your element is added to the page. There are others:

  • connectedCallback — fires when the element is added to the DOM
  • disconnectedCallback — fires when it's removed
  • attributeChangedCallback — fires when one of its HTML attributes changes
  • adoptedCallback — fires when it's moved to a different document (rare)

If you've used React, connectedCallback is roughly equivalent to useEffect(() => { ... }, []) — code that runs when the component mounts. The key difference: this is the browser running it natively, not React managing it.

2. Shadow DOM

Shadow DOM is where Web Components get their superpower: complete style isolation.

Normally, CSS is global. A style rule in one file can accidentally affect elements anywhere on the page. If you've ever imported a third-party widget and watched it scramble your layout, you've been burned by this. Shadow DOM is the fix.

When you attach a shadow root to your element, you're creating a sealed sub-document inside it. Styles defined inside the shadow DOM don't bleed out. Styles on the page don't bleed in. Your component looks exactly as designed, no matter what CSS surrounds it.

class InfoCard extends HTMLElement {
  connectedCallback() {
    // Create a sealed shadow root
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        /* This CSS only affects elements inside this component */
        .card {
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 16px;
          font-family: sans-serif;
        }
        h2 {
          font-size: 1.2rem;
          margin: 0 0 8px;
          color: #111;
        }
        p {
          color: #555;
          margin: 0;
        }
      </style>

      <div class="card">
        <h2>${this.getAttribute('title') || 'Card Title'}</h2>
        <p>${this.getAttribute('body') || ''}</p>
      </div>
    `;
  }
}

customElements.define('info-card', InfoCard);

The { mode: 'open' } option means JavaScript outside the component can still reach in with element.shadowRoot if needed. { mode: 'closed' } locks it down completely — external JavaScript can't access the internals at all.

Think of shadow DOM like a room with its own internal electrical and plumbing systems. What happens inside doesn't affect the rest of the house, and the house circuits don't power the room. Fully self-contained.

This is also why you sometimes see AI generate :host in CSS inside Web Components:

:host {
  display: block;
  width: 100%;
}

:host([variant="compact"]) {
  padding: 8px;
}

:host refers to the custom element tag itself — the outer shell. You use it to control how the element behaves at its boundary, like setting it to display: block so it takes up full width by default.

3. HTML Templates

The third piece is the <template> element — a chunk of HTML that the browser parses but doesn't render. It sits in the document as inert markup, ready to be cloned and inserted when needed.

<!-- Define the template in your HTML -->
<template id="card-template">
  <style>
    .card { border: 1px solid #eee; padding: 16px; border-radius: 8px; }
    .card__title { font-weight: bold; margin: 0 0 8px; }
    .card__body { color: #666; }
  </style>
  <div class="card">
    <p class="card__title"></p>
    <p class="card__body"></p>
  </div>
</template>
class InfoCard extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('card-template');
    const clone = template.content.cloneNode(true); // deep clone

    // Populate the cloned template with data
    clone.querySelector('.card__title').textContent =
      this.getAttribute('title') || '';
    clone.querySelector('.card__body').textContent =
      this.getAttribute('body') || '';

    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(clone);
  }
}

customElements.define('info-card', InfoCard);

Templates are useful when you have complex markup that you don't want to build with string concatenation in JavaScript. You write real HTML, and JavaScript clones it when it needs a copy. It's like having a prefab form in the workshop — you manufacture one master, then stamp out copies as needed.

Templates also support <slot> elements, which let content from outside the component slip into specific places inside it — similar to how React uses children. More on that in a moment.

Why AI Generates Web Components (And When)

When AI generates a Web Component instead of a React component, it's usually not a mistake. It's the AI making a reasonable choice based on context. Here are the common triggers:

You're working in a plain HTML file. If your project has no package.json, no node_modules, no React import — just an index.html — the AI correctly infers that React isn't available. Web Components are the natural answer for reusable UI in a no-framework environment.

You asked for something "reusable" without specifying a framework. "Give me a reusable card component" is ambiguous. The AI might generate a Web Component because it's the most universally reusable answer — it works in React, Vue, Angular, or plain HTML without any modification.

You mentioned a design system or UI library. Production design systems at places like Adobe (Spectrum), Salesforce (Lightning Web Components), and Google (Material Web) are built on Web Components because teams across the company use different frameworks. If you mention building something for wide distribution, AI may lean toward Web Components.

You asked for something that needs to work in an email or CMS. Web Component syntax sometimes appears when people want to embed UI in contexts that don't run JavaScript frameworks — though it's worth noting that many email clients don't support Web Components either.

When AI generates this, here's what you're looking at:

If you see class Something extends HTMLElement followed by customElements.define(), you're looking at a Web Component. The AI built you a browser-native custom element. It will work in any HTML page without any framework. If you wanted React instead, add that to your prompt: "build me a React component, not a Web Component."

Real Example: Building a Product Card as a Web Component

Let's build something real. A product card that shows an image, title, price, and an "Add to Cart" button. This is exactly the kind of reusable UI piece that makes sense as a Web Component — you might use it on a product listing page, a search results page, and a related products section, all with different surrounding styles.

The prompt you might have used:

"Build me a reusable product card component that shows an image, title, price, and an add to cart button. It should have its own styles and work in any HTML page."

Here's what a well-structured AI response looks like, annotated so you understand each piece:

<!-- product-card.html or embedded in your page -->

<!-- 1. The template: inert markup waiting to be cloned -->
<template id="product-card-template">
  <style>
    :host {
      display: block;
      font-family: system-ui, sans-serif;
    }

    .card {
      border: 1px solid #e5e7eb;
      border-radius: 12px;
      overflow: hidden;
      background: white;
      transition: box-shadow 0.2s;
    }

    .card:hover {
      box-shadow: 0 4px 20px rgba(0,0,0,0.1);
    }

    .card__image {
      width: 100%;
      aspect-ratio: 4/3;
      object-fit: cover;
      display: block;
    }

    .card__body {
      padding: 16px;
    }

    .card__title {
      font-size: 1rem;
      font-weight: 600;
      margin: 0 0 4px;
      color: #111;
    }

    .card__price {
      font-size: 1.1rem;
      color: #16a34a;
      font-weight: 700;
      margin: 0 0 16px;
    }

    .card__btn {
      display: block;
      width: 100%;
      padding: 10px;
      background: #2563eb;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 0.95rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.15s;
    }

    .card__btn:hover {
      background: #1d4ed8;
    }
  </style>

  <div class="card">
    <img class="card__image" alt="" />
    <div class="card__body">
      <p class="card__title"></p>
      <p class="card__price"></p>
      <button class="card__btn">Add to Cart</button>
    </div>
  </div>
</template>
// 2. The Custom Element class
class ProductCard extends HTMLElement {

  // Tell the browser which attributes to watch
  static get observedAttributes() {
    return ['title', 'price', 'image', 'alt'];
  }

  connectedCallback() {
    this._render();

    // 3. Dispatching a custom event when Add to Cart is clicked
    this.shadowRoot.querySelector('.card__btn')
      .addEventListener('click', () => {
        this.dispatchEvent(new CustomEvent('add-to-cart', {
          bubbles: true,   // event travels up the DOM tree
          composed: true,  // event crosses shadow DOM boundary
          detail: {
            title: this.getAttribute('title'),
            price: this.getAttribute('price')
          }
        }));
      });
  }

  // Fires when a watched attribute changes
  attributeChangedCallback() {
    if (this.shadowRoot) {
      this._render();
    }
  }

  _render() {
    // Clone the template the first time, or update existing shadow DOM
    if (!this.shadowRoot) {
      const template = document.getElementById('product-card-template');
      const shadow = this.attachShadow({ mode: 'open' });
      shadow.appendChild(template.content.cloneNode(true));
    }

    this.shadowRoot.querySelector('.card__image').src =
      this.getAttribute('image') || '';
    this.shadowRoot.querySelector('.card__image').alt =
      this.getAttribute('alt') || this.getAttribute('title') || '';
    this.shadowRoot.querySelector('.card__title').textContent =
      this.getAttribute('title') || '';
    this.shadowRoot.querySelector('.card__price').textContent =
      this.getAttribute('price') || '';
  }
}

// 4. Register the custom element with the browser
customElements.define('product-card', ProductCard);

Now you can use it in HTML like this:

<product-card
  title="Wireless Headphones"
  price="$79.99"
  image="headphones.jpg"
  alt="Black over-ear wireless headphones"
></product-card>

<product-card
  title="USB-C Hub"
  price="$34.99"
  image="hub.jpg"
  alt="Silver 7-in-1 USB-C hub"
></product-card>

And to listen for the cart button click in your parent page:

// Listen for the custom event from any product card on the page
document.addEventListener('add-to-cart', (event) => {
  console.log('Added to cart:', event.detail.title);
  console.log('Price:', event.detail.price);
  // Update your cart UI, call your API, etc.
});

A few things worth noting in this example:

observedAttributes — a static getter that tells the browser which attribute changes should trigger attributeChangedCallback. Without this list, attribute changes are silently ignored.

CustomEvent with composed: true — by default, events don't cross shadow DOM boundaries. Setting composed: true allows the event to bubble up through the shadow root and into the rest of the page. Without it, parent page code can't hear the event.

bubbles: true — lets the event travel up the DOM tree so you can listen for it at the document level instead of on each individual card.

Slots: Passing Content Into a Web Component

So far, all the content has come from attributes — strings in HTML like title="Something". But what if you want to pass in richer HTML content? A list of features, a formatted description, a custom footer?

That's what <slot> is for. It's a placeholder inside the shadow DOM that gets filled by content you put between the component's opening and closing tags.

<template id="feature-card-template">
  <style>
    :host { display: block; }
    .card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; }
    .card__header { font-size: 1.1rem; font-weight: 700; margin-bottom: 12px; }
    .card__content { color: #444; }
  </style>
  <div class="card">
    <!-- Named slot: receives content with slot="header" -->
    <div class="card__header"><slot name="header">Default Title</slot></div>
    <!-- Default slot: receives everything else -->
    <div class="card__content"><slot></slot></div>
  </div>
</template>
<!-- Using the component with slotted content -->
<feature-card>
  <span slot="header">What's Included</span>
  <ul>
    <li>Free shipping on orders over $50</li>
    <li>30-day return window</li>
    <li>2-year warranty</li>
  </ul>
</feature-card>

The <ul> slides into the default slot, and the <span> goes into the named "header" slot. This is how Web Components support rich composition without requiring you to jam everything into an attribute string.

If you've used React, this is roughly equivalent to children (for the default slot) and named props that accept JSX (for named slots). The mental model is the same — the mechanism is browser-native.

Web Components vs. React Components: When to Use Which

This is the practical question. You have both available. Which do you reach for?

Use React components when:

  • You're already building in a React app
  • Your component needs to interact with React state or context
  • You're using a component library like shadcn/ui, Radix, or Chakra
  • You need complex reactive updates where many pieces of data change the UI
  • Your team already knows React and consistency matters

Use Web Components when:

  • You're building something that needs to work in multiple frameworks or plain HTML
  • You're building a design system for a company with multiple tech stacks
  • You need bullet-proof style isolation — the component must look identical no matter where it's used
  • You're building an embeddable widget (like a chat widget or payment form) that gets dropped into customer sites
  • You're in a no-framework environment (plain HTML, a CMS, a legacy site)
  • You want zero dependencies — no npm, no build step, just browser APIs

The honest answer for most vibe coders building their own projects: you probably don't need Web Components. If you're building a React app, use React. If you're building a plain HTML site, honestly, you might not need components at all — just use regular HTML and CSS.

Web Components shine when the portability is the point. When you need to build something once and deploy it everywhere, regardless of the tech stack it lands in. That's a specific problem, and Web Components are its specific solution.

Real-world adoption: Adobe's Spectrum design system, Salesforce Lightning Web Components, Google's Material Web, and Microsoft's Fluent UI all use Web Components. The pattern works at scale. It's just overkill for your personal project's button component.

Why Shadow DOM Really Matters (The Style Isolation Payoff)

Let's get concrete about shadow DOM because it solves a pain point every web developer eventually hits.

Imagine you've built a beautiful form. You embed a third-party chat widget on the page. Suddenly your form labels are a different font. Your button colors changed. Your spacing collapsed. The widget's CSS leaked into your page, or your page's CSS leaked into the widget.

This happens because CSS, by default, is global. There's no built-in fence between your styles and anyone else's.

Shadow DOM builds that fence. Here's a concrete before-and-after:

<!-- WITHOUT shadow DOM: styles bleed everywhere -->
<style>
  /* This h2 rule targets ALL h2s on the page, including ones inside components */
  h2 { color: red; font-size: 14px; }
</style>

<div class="my-card">
  <h2>This h2 turns red</h2>
</div>

<third-party-widget>
  <!-- If this widget renders an h2 without shadow DOM, it also turns red -->
  <h2>This h2 also turns red, breaking the widget</h2>
</third-party-widget>
// WITH shadow DOM: each component is isolated
class ThirdPartyWidget extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        /* This h2 rule ONLY affects h2s inside this shadow root */
        h2 { color: blue; font-size: 18px; }
      </style>
      <h2>This h2 stays blue, no matter what the page CSS says</h2>
    `;
  }
}
customElements.define('third-party-widget', ThirdPartyWidget);

The page's h2 { color: red } rule has zero effect on the h2 inside the shadow root. The widget's internal h2 { color: blue } rule has zero effect on h2s outside it. The boundary is total.

This is why embeddable widgets — Stripe's payment form, Intercom's chat bubble, HubSpot's forms — all use shadow DOM (or iframes, which have similar isolation properties). They need to look exactly right no matter what styles the customer's website uses.

For your own components, shadow DOM means you can write CSS without worrying about naming collisions. No BEM methodology. No scoped CSS modules. No !important arms race. Just write styles that work inside the component, and they stay inside the component.

Understanding this connects directly to how the DOM works — shadow DOM is literally a separate DOM tree attached to your element, with its own root. The browser renders both trees, but treats them as separate scoping contexts for CSS.

Reading AI-Generated Web Components Without Panic

When AI hands you a Web Component you didn't expect, here's how to orient yourself fast.

Step 1: Find the class definition. Look for class SomeName extends HTMLElement. That's your component. Everything inside the class is its behavior.

Step 2: Find what tag name it registers as. Look for customElements.define('tag-name', ClassName) at the bottom. That tells you what HTML tag renders this component.

Step 3: Find connectedCallback. This is the "startup code" — what runs when the element appears on the page. The rendering usually happens here.

Step 4: Check for attachShadow. If it's there, the component uses shadow DOM. Styles inside it are isolated. Don't try to override them with external CSS — override the :host or CSS custom properties instead.

Step 5: Check observedAttributes. This lists which HTML attributes the component responds to. These are the "inputs" — like React props, but you set them as HTML attributes.

Useful follow-up prompts when AI generates a Web Component:

"Walk me through this Web Component line by line. What does each lifecycle method do?"

"How do I pass data into this component from a React parent component?"

"Add support for a CSS custom property so I can override the primary color from outside."

"Rewrite this as a React component instead — I'm already using React in my project."

CSS Custom Properties: How to Style Through the Shadow DOM

Just because shadow DOM isolates styles doesn't mean your component has to be completely rigid. CSS custom properties (variables) cross the shadow DOM boundary. You can expose "theming hooks" that let users customize the look from outside:

// Inside the shadow DOM
shadow.innerHTML = `
  <style>
    .card {
      background: var(--card-bg, white);
      border-color: var(--card-border, #e5e7eb);
      border-radius: var(--card-radius, 12px);
    }
    .card__btn {
      background: var(--card-accent, #2563eb);
    }
  </style>
  <div class="card">...</div>
`;
/* From anywhere in the page CSS — this crosses the shadow boundary */
product-card {
  --card-bg: #f0f9ff;
  --card-border: #bae6fd;
  --card-accent: #0284c7;
}

CSS custom properties are the official way to let outside code style the internals of a shadow DOM component. You define what's customizable by using variables with fallback values (var(--property, fallback)). Users override those variables from their own CSS. Neat, intentional, and no specificity wars.

What to Learn Next

Web Components are built on top of several things worth understanding better:

  • What Is HTML? — Web Components extend HTML's element system. The deeper your HTML intuition, the more natural custom elements feel.
  • What Is JavaScript? — Custom Elements are JavaScript classes. The class, extends, and lifecycle method syntax all come from modern JavaScript.
  • What Is the DOM? — Shadow DOM is literally a separate DOM tree. Understanding the DOM makes the shadow part click into place.
  • What Is a Component? — The broader idea of UI components — self-contained, reusable pieces — applies whether you're in React, Vue, or building with Web Components.
  • What Are Event Listeners? — Custom events from Web Components use the same event system as native browser events. Understanding event bubbling and composed makes component communication easier.

Frequently Asked Questions

Do Web Components work without any framework like React or Vue?

Yes — that's the whole point. Web Components are a set of browser-native APIs built into every modern browser. You write vanilla JavaScript and HTML. No npm install, no build step required. The browser understands your custom element tags directly, the same way it understands built-in tags like div and button. This is what makes them portable: a Web Component you write today works in React apps, plain HTML pages, WordPress themes, and anywhere else HTML runs.

What is the shadow DOM and why does it matter?

Shadow DOM is a scoped chunk of HTML and CSS that lives inside your custom element — completely isolated from the rest of the page. Styles you define inside the shadow DOM don't leak out to the page, and outside styles don't bleed in. This solves one of the oldest problems in web development: CSS specificity wars. If you've ever had a third-party widget's styles break your layout, shadow DOM is the fix for that problem. It's like a hermetically sealed room inside your page — what happens inside stays inside.

Should I use Web Components or React components?

It depends on your context. If you're building within a React app, use React components — they compose better with React's data flow and state management. If you're building something that needs to work across multiple frameworks, in plain HTML pages, or as a distributable UI library, Web Components are a better fit. Many large companies (Adobe, Salesforce, Google) build their design systems as Web Components precisely because they need them to work everywhere regardless of which framework each team uses.

Why does AI generate Web Components sometimes instead of React components?

AI tools pick Web Components when the prompt doesn't specify a framework, when you're working in a plain HTML file, or when you ask for something that needs to be reusable and self-contained. If you ask for "a reusable card component" without mentioning React, the AI might reasonably generate a Web Component since it's the most universal answer. It's not a mistake — it's the AI making an educated guess about what you need. If you wanted React, be explicit: "build me a React component, not a Web Component."

Are Web Components good for beginners?

Web Components are powerful but they involve more raw JavaScript than most beginner tutorials cover. You're working with browser APIs directly — class syntax, lifecycle callbacks, DOM manipulation. If you're just starting out, you're better off understanding plain HTML and JavaScript first, then React or another framework, and treating Web Components as a tool you reach for when you specifically need cross-framework portability or bullet-proof style isolation. The good news: if AI generates one and you need to understand or tweak it, this article gives you everything you need.