Templates & Web Components

HTML · Lesson 37 of 44 7 min read Updated

As a site grows, you repeat the same pieces of HTML again and again: product cards, profile boxes, price labels. Web components are a set of browser standards that let you package such a piece once and reuse it as your own HTML tag, such as <profile-card>, on any page and with any framework.

In this lesson you will use the <template> element, create custom elements with a little JavaScript, keep their styles private with shadow DOM, and pass content into them with slots. You should be comfortable with the basics of the DOM in JavaScript before you start.

The template element

A <template> holds HTML that is not shown on the page. The browser reads it but does not display it, load its images or run its scripts. It is a stamp that you can copy with JavaScript whenever you need another instance.

The content is stored in the template's content property. Call content.cloneNode(true) to make a full copy, fill in the copy, and add it to the page.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Template</title>
  <style>
    .book { border: 1px solid #ccc; border-radius: 8px; padding: 8px 12px; margin-bottom: 8px; max-width: 320px; }
  </style>
</head>
<body>
  <h1>New arrivals</h1>
  <div id="shelf"></div>

  <template id="book-template">
    <article class="book">
      <h2 class="title"></h2>
      <p>By <span class="author"></span>, <strong class="price"></strong></p>
    </article>
  </template>

  <script>
    const books = [
      { title: "The Guide", author: "R. K. Narayan", price: 299 },
      { title: "Wings of Fire", author: "A. P. J. Abdul Kalam", price: 350 },
      { title: "Train to Pakistan", author: "Khushwant Singh", price: 275 },
    ];

    const template = document.querySelector("#book-template");
    const shelf = document.querySelector("#shelf");

    for (const book of books) {
      const copy = template.content.cloneNode(true);
      copy.querySelector(".title").textContent = book.title;
      copy.querySelector(".author").textContent = book.author;
      copy.querySelector(".price").textContent = "₹" + book.price;
      shelf.append(copy);
    }
  </script>
</body>
</html>

The script fills each copy with textContent, not innerHTML. That way, even if a book title came from a user, it can never inject HTML or scripts into your page.

Custom elements

A custom element is a tag you invent and teach the browser about. You write a JavaScript class that extends HTMLElement and register it with customElements.define().

  • The name must contain a hyphen and be all lowercase, such as rupee-price or profile-card. The hyphen guarantees it can never clash with a future built-in HTML element.
  • connectedCallback() runs when the element is added to the page. Do your setup there.
  • To react when an attribute changes, list it in static observedAttributes and write an attributeChangedCallback() method.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Custom Element</title>
</head>
<body>
  <p>Cricket bat: <rupee-price amount="2499"></rupee-price></p>
  <p>Laptop: <rupee-price amount="64990"></rupee-price></p>
  <p>Scooter: <rupee-price id="scooter" amount="129900"></rupee-price></p>
  <button id="sale">Festival sale: 10% off the scooter</button>

  <script>
    const rupees = new Intl.NumberFormat("en-IN", {
      style: "currency", currency: "INR", maximumFractionDigits: 0,
    });

    class RupeePrice extends HTMLElement {
      static observedAttributes = ["amount"];

      connectedCallback() {
        this.render();
      }

      attributeChangedCallback() {
        this.render();
      }

      render() {
        this.textContent = rupees.format(Number(this.getAttribute("amount")));
      }
    }

    customElements.define("rupee-price", RupeePrice);

    document.querySelector("#sale").addEventListener("click", () => {
      const scooter = document.querySelector("#scooter");
      scooter.setAttribute("amount", Number(scooter.getAttribute("amount")) * 0.9);
    });
  </script>
</body>
</html>

Each <rupee-price> shows its amount in Indian format, such as ₹1,29,900. Clicking the button changes the amount attribute, and the element updates itself. Before define() runs, the browser treats an unknown tag like a plain inline element, so the page still works.

Shadow DOM: private markup and styles

When a component has its own internal HTML and CSS, you do not want page styles to break it, or its styles to leak onto the page. Shadow DOM solves this. Calling this.attachShadow({ mode: "open" }) gives the element a hidden, separate DOM tree called a shadow root. Styles inside it apply only inside it, and most page styles cannot reach in. Inherited properties such as color and font-family still flow in from the page, so components fit the surrounding text.

Inside the shadow root, the :host selector styles the custom element itself.

Slots: passing content in

A slot is a placeholder in the shadow DOM where the element's own children appear. A plain <slot> receives all children without a slot attribute. A named slot, <slot name="city">, receives the child with slot="city". Text between the slot tags is the fallback shown when nothing is provided.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Profile Card</title>
  <style>
    h2 { color: crimson; } /* page style: does not reach inside the card */
  </style>
</head>
<body>
  <h2>Our mentors</h2>

  <profile-card>
    <span>Priya Nair</span>
    <span slot="city">Kochi</span>
  </profile-card>

  <profile-card>
    <span>Farhan Qureshi</span>
  </profile-card>

  <script>
    class ProfileCard extends HTMLElement {
      connectedCallback() {
        if (this.shadowRoot) return; // already set up
        const shadow = this.attachShadow({ mode: "open" });
        shadow.innerHTML = `
          <style>
            :host { display: block; border: 1px solid #ccc; border-radius: 10px; padding: 12px; margin: 8px 0; max-width: 260px; }
            h2 { margin: 0; font-size: 1.1rem; color: #0d47a1; }
          </style>
          <h2><slot>Unnamed mentor</slot></h2>
          <p>City: <slot name="city">not given</slot></p>
        `;
      }
    }
    customElements.define("profile-card", ProfileCard);
  </script>
</body>
</html>

The page's h2 rule makes "Our mentors" red, but the headings inside the cards stay blue, because the shadow DOM keeps the two sets of styles apart. Farhan's card has no city, so the fallback text appears. Using innerHTML here is safe because the string is fixed in your own code; never build it from user input.

Declarative shadow DOM

You can also write a shadow root directly in HTML, with no JavaScript, by putting a <template shadowrootmode="open"> as the first child of an element. The browser turns it into a shadow root while reading the page. This lets a server send ready-made components that display before any script loads.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Declarative Shadow DOM</title>
</head>
<body>
  <notice-box>
    <template shadowrootmode="open">
      <style>
        :host { display: block; border-left: 4px solid orange; padding: 8px 12px; background: #fff8e1; }
      </style>
      <strong>Notice:</strong> <slot></slot>
    </template>
    The library will stay closed on Diwali.
  </notice-box>
</body>
</html>

When to use web components

Web components are useful for widgets you want to reuse across many pages or projects, such as a price label, a rating display or a date picker, especially when different teams use different frameworks. For a small site, plain HTML and CSS are often enough.

Remember that a custom element has no built-in meaning. A <buy-button> is not a button to a screen reader. Put real elements inside your components, such as a <button> in the shadow DOM, so they stay accessible, as covered in Accessibility Basics.

Common mistakes

  • A name without a hyphen. customElements.define("price", ...) throws an error; use price-tag.
  • Defining the same name twice. The second define() call throws an error, for example when a script is included twice.
  • Expecting page CSS to style shadow DOM content. Only inherited properties and CSS custom properties pass in. Style the inside in the component's own <style>.
  • Building component HTML from user data with innerHTML. Use textContent for anything that came from a user.

Key points

  • <template> holds inert HTML that you copy with template.content.cloneNode(true).
  • Custom elements are classes that extend HTMLElement, registered with customElements.define(), and their names must contain a hyphen.
  • connectedCallback() runs when the element is added; observedAttributes and attributeChangedCallback() react to attribute changes.
  • Shadow DOM keeps a component's markup and styles private; :host styles the element itself.
  • Slots place the element's children inside its shadow DOM, with named slots for specific parts.
  • <template shadowrootmode="open"> creates a shadow root in plain HTML without JavaScript.

Exercise

Create a custom element called <star-rating> that reads a value attribute from 0 to 5 and shows that many filled stars (★) followed by empty stars (☆), for example ★★★☆☆ for 3. It should update when the value attribute changes. Show three ratings on a page for three Jaipur restaurants.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Star Rating</title>
</head>
<body>
  <p>Laxmi Mishthan Bhandar: <star-rating value="5"></star-rating></p>
  <p>Rawat Kachori: <star-rating value="4"></star-rating></p>
  <p>Pink City Cafe: <star-rating value="3"></star-rating></p>

  <script>
    class StarRating extends HTMLElement {
      static observedAttributes = ["value"];

      connectedCallback() {
        this.setAttribute("role", "img");
        this.render();
      }

      attributeChangedCallback() { this.render(); }

      render() {
        const value = Math.min(5, Math.max(0, Number(this.getAttribute("value")) || 0));
        this.textContent = "★".repeat(value) + "☆".repeat(5 - value);
        this.setAttribute("aria-label", value + " out of 5 stars");
      }
    }
    customElements.define("star-rating", StarRating);
  </script>
</body>
</html>

Math.min and Math.max keep the value between 0 and 5, so a wrong attribute cannot break repeat(). Changing value later calls attributeChangedCallback(), which redraws the stars. The role="img" and aria-label let screen readers say "4 out of 5 stars" instead of reading each star symbol.

Quick Quiz

1. Which is a valid custom element name?

2. What happens to the content of a <template> when the page loads?

3. A page has h2 { color: red }. What colour is an <h2> inside a component's shadow DOM that has its own h2 { color: blue } rule?

4. Where does <span slot="city">Kochi</span> appear inside a component?

Learn HTML with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call