Web components in a single HTML file

A web component is your own HTML tag, such as <click-counter>, with its own markup, styles and behaviour. The browser supports it directly, so one HTML file with one script is enough.

Web components here means the browser standard for making your own HTML tags, not the parts of a website in general. Custom elements define the tag, Shadow DOM gives it private markup and styles, and templates can hold its markup.

No framework or build step is needed. One HTML file with a class, customElements.define() and the new tag in the markup is a working component. Try it below.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A web component in one HTML file</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  /* Page CSS: this turns the page's own button orange,
     but it does not reach the buttons inside each counter. */
  button { background: #f97316; color: #fff; border: 0; border-radius: 8px; padding: 9px 14px; font: inherit; cursor: pointer; }
  #list { display: grid; gap: 10px; margin-bottom: 14px; }
</style>
</head>
<body>
<div id="list">
  <click-counter label="Coffees"></click-counter>
  <click-counter label="Glasses of water" start="2"></click-counter>
</div>
<button id="add">Add another counter</button>

<script>
  class ClickCounter extends HTMLElement {
    constructor() {
      super();                              // always first
      this.attachShadow({ mode: 'open' });  // a private DOM tree with its own CSS
    }

    // Runs when the element is put on the page: read attributes here
    connectedCallback() {
      if (this.shadowRoot.childElementCount) return;  // already built
      let count = Number(this.getAttribute('start')) || 0;

      this.shadowRoot.innerHTML = `
        <style>
          :host { display: flex; align-items: center; gap: 12px;
                  background: #fff; padding: 10px 14px; border-radius: 10px;
                  box-shadow: 0 2px 8px rgba(0,0,0,.08); }
          span { flex: 1; }
          button { background: #e8f0fe; color: #1a56db; border: 0; border-radius: 8px;
                   padding: 8px 14px; font: inherit; font-weight: 600; cursor: pointer; }
          b { min-width: 2ch; text-align: right; font-size: 1.2em; }
        </style>
        <span></span><button>+1</button><b></b>`;

      const out = this.shadowRoot.querySelector('b');
      this.shadowRoot.querySelector('span').textContent = this.getAttribute('label') || 'Clicks';
      out.textContent = count;
      this.shadowRoot.querySelector('button').addEventListener('click', () => {
        out.textContent = ++count;
      });
    }
  }

  // The name must contain a hyphen
  customElements.define('click-counter', ClickCounter);

  // Elements added later work too
  let n = 0;
  document.getElementById('add').addEventListener('click', () => {
    const c = document.createElement('click-counter');
    c.setAttribute('label', 'New counter ' + (++n));
    document.getElementById('list').append(c);
  });
</script>
</body>
</html>
A click-counter element used twice. Add more and each one keeps its own count. Edit the code and the example reruns.

The page CSS turns every button orange, yet the +1 buttons stay blue. Those buttons live in each counter's shadow root, where page selectors do not reach.

MDN describes web components as three technologies used together. You only need the first one to get a working tag. The second is what makes it self-contained.

A custom element names the tag, Shadow DOM keeps its insides private, and a template can hold the markup.
A custom element names the tag, Shadow DOM keeps its insides private, and a template can hold the markup.
  1. Custom element. A class that extends HTMLElement, registered under a tag name with customElements.define().
  2. Shadow DOM. this.attachShadow({ mode: 'open' }) attaches a private tree. Its styles do not leak out, and page styles do not leak in.
  3. Template and slot. Optional. A <template> holds markup to clone, and a <slot> shows the page's children inside the component. The HTML template tag covers both in detail.

The smallest working example, line by line

This is the core of the counter above, with the styles left out:

<click-counter label="Coffees"></click-counter>

<script>
  class ClickCounter extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
    }
    connectedCallback() {
      if (this.shadowRoot.childElementCount) return;
      let count = 0;
      this.shadowRoot.innerHTML = '<span></span> <button>+1</button> <b>0</b>';
      this.shadowRoot.querySelector('span').textContent = this.getAttribute('label');
      const out = this.shadowRoot.querySelector('b');
      this.shadowRoot.querySelector('button')
        .addEventListener('click', () => { out.textContent = ++count; });
    }
  }
  customElements.define('click-counter', ClickCounter);
</script>
  • super() comes first in the constructor, before you touch this.
  • connectedCallback runs each time the element is added to the page. The HTML standard recommends doing setup here, not in the constructor, so this is where attributes are read.
  • The guard line stops a second build when the element is moved, because moving it fires connectedCallback again.
  • textContent puts the label in as plain text. Building it into the innerHTML string would parse any markup in it. See innerHTML.

The script can sit at the end of the body. Elements already on the page are upgraded when define() runs, and elements created later work too.

Lifecycle callbacks: when your code runs

A custom element class can have a few methods with fixed names. The browser calls them at set moments.

What each lifecycle callback is for, and the one rule for the constructor.
What each lifecycle callback is for, and the one rule for the constructor.
Callback When it runs Use it to
constructor() The element is created or upgraded Call super(), attach the shadow root
connectedCallback() Each time it is added to the page Read attributes, build markup, add listeners
disconnectedCallback() Each time it is removed Stop timers, remove outside listeners
attributeChangedCallback() A listed attribute changes Update the display
adoptedCallback() It moves to another document Rarely needed

attributeChangedCallback only runs for attributes named in a static list. Without that list, nothing happens and there is no error:

static observedAttributes = ['value'];

Styles: what crosses the shadow boundary

Shadow DOM is a wall for selectors, not for inheritance. Page rules such as button { ... } do not match elements inside the component. Inherited values, such as font-family and color, still flow in from the host element.

Page selectors stop at the shadow root. Inherited values and CSS variables pass through.
Page selectors stop at the shadow root. Inherited values and CSS variables pass through.

To let the page theme a component, use these doors:

  • CSS variables. Inside, write color: var(--star-color, #f59e0b). The page sets --star-color on the tag. More in CSS variables.
  • ::part(). Mark an inner element with part="label", then the page can style click-counter::part(label).
  • :host. Inside the shadow CSS, :host styles the custom element itself, for example display: block.

Custom elements are display: inline by default, like a span. Set :host { display: block; } or flex if the component should take a full row.

A library from a CDN: the same counter in Lit

Web components do not need a library, but some people prefer one to cut repeated code. Lit is one such library. Its docs list ready-made bundles on cdn.jsdelivr.net that load with <script type="module">, so they work in a single HTML file.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The same counter with Lit from a CDN</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  #list { display: grid; gap: 10px; }
  /* Until the script has loaded and defined the tag, show it faded */
  lit-counter:not(:defined) { display: block; height: 46px; border-radius: 10px; background: #e5e7eb; }
  p { font-size: 14px; color: #4b5563; }
</style>
</head>
<body>
<div id="list">
  <lit-counter label="Coffees"></lit-counter>
  <lit-counter label="Glasses of water" start="2"></lit-counter>
</div>
<p id="status">Loading Lit 3.3.3 from cdn.jsdelivr.net...</p>

<!-- type="module" is required: the file uses import -->
<script type="module">
  import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/lit/dist@3.3.3/core/lit-core.min.js';

  class LitCounter extends LitElement {
    // Reactive properties: changing one re-renders the element
    static properties = {
      label: {},
      count: { type: Number, attribute: 'start' },
    };

    // Lit puts these styles in the shadow root for you
    static styles = css`
      :host { display: flex; align-items: center; gap: 12px;
              background: #fff; padding: 10px 14px; border-radius: 10px;
              box-shadow: 0 2px 8px rgba(0,0,0,.08); }
      span { flex: 1; }
      button { background: #e8f0fe; color: #1a56db; border: 0; border-radius: 8px;
               padding: 8px 14px; font: inherit; font-weight: 600; cursor: pointer; }
      b { min-width: 2ch; text-align: right; font-size: 1.2em; }
    `;

    constructor() {
      super();
      this.label = 'Clicks';
      this.count = 0;
    }

    render() {
      return html`
        <span>${this.label}</span>
        <button @click=${() => this.count++}>+1</button>
        <b>${this.count}</b>`;
    }
  }

  customElements.define('lit-counter', LitCounter);
  document.getElementById('status').textContent = 'Lit 3.3.3 loaded. Same counter, no innerHTML or listeners to wire by hand.';
</script>
</body>
</html>
The same counter written with Lit 3.3.3, loaded from cdn.jsdelivr.net with a pinned version.
<script type="module">
  import { LitElement, html, css } from
    'https://cdn.jsdelivr.net/gh/lit/dist@3.3.3/core/lit-core.min.js';

  class LitCounter extends LitElement {
    static properties = { label: {}, count: { type: Number, attribute: 'start' } };
    constructor() { super(); this.label = 'Clicks'; this.count = 0; }
    render() {
      return html`<span>${this.label}</span>
        <button @click=${() => this.count++}>+1</button> <b>${this.count}</b>`;
    }
  }
  customElements.define('lit-counter', LitCounter);
</script>
Plain custom element Lit
Extra download None One module from a CDN
Re-rendering You update the DOM yourself Changing a reactive property re-renders
Styles A <style> in the shadow root static styles = css
Licence Browser feature BSD-3-Clause

Pin the full version, @3.3.3, rather than @3, so the page keeps using the release you tested. Lit's docs also say that if you install packages with npm, use the lit package instead of these bundles.

Two Lit details catch people. The script needs type="module", or import throws a syntax error. And Lit's docs warn that a class field with the same name as a reactive property stops it from updating, so set defaults in the constructor as above.

While a CDN script loads, the tag is still undefined. lit-counter:not(:defined) styles it during that moment, which the example uses for a grey placeholder.

A finished example: a star rating

This <star-rating> combines the pieces: an observed attribute, a custom event, and a CSS variable for its colour. The page reads ratings by listening for one event, and resets them by setting the attribute.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A star-rating web component</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
         background: #fff; padding: 8px 14px; border-radius: 10px; margin-bottom: 8px; }
  /* CSS variables pass into the shadow root, so the page can theme the stars */
  .row:nth-child(2) star-rating { --star-color: #16a34a; }
  #summary { margin: 12px 0 8px; font-weight: 600; }
  #reset { background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 14px; font: inherit; cursor: pointer; }
</style>
</head>
<body>
<div class="row">Food <star-rating name="food" value="4"></star-rating></div>
<div class="row">Service <star-rating name="service" value="3"></star-rating></div>
<div class="row">Price <star-rating name="price"></star-rating></div>
<p id="summary" aria-live="polite">Click a star.</p>
<button id="reset">Reset all</button>

<script>
  class StarRating extends HTMLElement {
    // Only attributes listed here trigger attributeChangedCallback
    static observedAttributes = ['value'];

    constructor() {
      super();
      this.attachShadow({ mode: 'open' }).innerHTML = `
        <style>
          :host { display: inline-flex; gap: 2px; }
          button { background: none; border: 0; padding: 2px; font-size: 26px; line-height: 1;
                   cursor: pointer; color: #d1d5db; }
          button.on { color: var(--star-color, #f59e0b); }
        </style>`;
    }

    connectedCallback() {
      if (this.shadowRoot.querySelector('button')) return;  // already built
      for (let i = 1; i <= 5; i++) {
        const b = document.createElement('button');
        b.textContent = '★';
        b.setAttribute('aria-label', i + ' of 5');
        b.addEventListener('click', () => {
          this.setAttribute('value', i);  // goes through attributeChangedCallback
          // bubbles + composed: the event leaves the shadow root and reaches the page
          this.dispatchEvent(new CustomEvent('rating-change', {
            detail: { name: this.getAttribute('name'), value: i },
            bubbles: true, composed: true,
          }));
        });
        this.shadowRoot.append(b);
      }
      this.paint();
    }

    attributeChangedCallback() { this.paint(); }

    paint() {
      const value = Number(this.getAttribute('value')) || 0;
      this.shadowRoot.querySelectorAll('button').forEach((b, i) => {
        b.classList.toggle('on', i < value);
        b.setAttribute('aria-pressed', i < value);
      });
    }
  }
  customElements.define('star-rating', StarRating);

  // The page listens like it would for any other event
  const summary = document.getElementById('summary');
  document.addEventListener('rating-change', (e) => {
    summary.textContent = 'You gave ' + e.detail.name + ' ' + e.detail.value + ' of 5.';
  });

  document.getElementById('reset').addEventListener('click', () => {
    document.querySelectorAll('star-rating').forEach((r) => r.setAttribute('value', 0));
    summary.textContent = 'All ratings reset.';
  });
</script>
</body>
</html>
Click a star. The component sets its value attribute and sends a rating-change event that the page listens for.

The event is what makes a component useful to the rest of the page. By default an event does not bubble and does not leave the shadow root, so set both options:

this.dispatchEvent(new CustomEvent('rating-change', {
  detail: { name: this.getAttribute('name'), value: i },
  bubbles: true,   // travels up through ancestors
  composed: true,  // crosses out of the shadow root
}));

The page then uses ordinary addEventListener on document and reads e.detail.

When it does not work

What you see Cause Fix
SyntaxError from define The name has no hyphen or has uppercase letters Use a name like star-rating
NotSupportedError from define The same name was defined twice Define once, or check customElements.get(name) first
Everything after the tag ends up inside it Written as <my-tag /> Write <my-tag></my-tag>
Attributes are null on elements made with createElement Read in the constructor, before they are set Read them in connectedCallback
Changing an attribute does nothing The name is not in observedAttributes Add it to the static list
Page CSS has no effect inside Selectors stop at the shadow root Use CSS variables or ::part()
The page never hears the event bubbles and composed are off Set both to true
A syntax error about import outside a module CDN import in a plain <script> Use <script type="module">
The tag shows nothing at all The script failed before define Check the console. See HTML JavaScript not working

The define-once check, for a script that may run twice:

if (!customElements.get('star-rating')) {
  customElements.define('star-rating', StarRating);
}

A component is easier to try than to describe. A screenshot cannot be clicked, and an .html attachment may open as plain text on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so people can click the stars themselves without an account. Scripts from cdn.jsdelivr.net, such as the pinned Lit bundle, load too.

If you change the code later, the same link shows the new version.

Questions people ask

Do web components need a framework or a build step?

No. Custom elements and Shadow DOM are browser APIs. A class that extends HTMLElement and one call to customElements.define in a normal script tag is enough, as the first example shows. Libraries such as Lit are optional and can also be loaded from a CDN without a build.

Why must a custom element name contain a hyphen?

The HTML standard requires it. A valid name starts with a lowercase ASCII letter, contains a hyphen and has no uppercase ASCII letters. customElements.define throws a SyntaxError for a name like counter or Click-Counter.

Can I write a custom element as a self-closing tag?

No. HTML ignores the slash in <click-counter />, so the element stays open and swallows the elements after it. Always write the closing tag: <click-counter></click-counter>.

Why does my page CSS not style the inside of my component?

Page selectors do not match elements inside a shadow root. Inherited values such as font and color, and CSS custom properties, still flow in. To expose a specific inner element, give it a part attribute and style it from the page with ::part().

Do I need Shadow DOM to make a custom element?

No. A custom element can put its markup straight into its own children. Shadow DOM adds the private styles and inner tree. Without it, page CSS reaches the inside and the inner markup shows up in document.querySelector results.

Keep reading