Lesson 15: Components

Web components let you build a custom HTML tag once and reuse it as many times as you want. You don’t need any complex tools for this because web browsers have this feature built right in.

A web component is made of three main parts. These are templates, custom tags, and the shadow DOM. Together, these features let you build isolated elements that work just like standard HTML tags.

We’ll build a <star-rating> element to let users rate things from one to five stars. Custom elements must use lowercase names containing a dash so the browser doesn’t confuse them with native tags.

The <template> element

The <template> tag holds HTML code that the browser doesn’t show when the page loads. Instead, you’ll use JavaScript to copy this template and insert it into the page when you need it.

<template id="star-rating-template">
  <form>
    <fieldset>
      <legend>Rate your experience:</legend>
      <rating>
        <input
          type="radio"
          name="rating"
          value="1"
          aria-label="1 star"
          required
        />
        <input type="radio" name="rating" value="2" aria-label="2 stars" />
        <input type="radio" name="rating" value="3" aria-label="3 stars" />
        <input type="radio" name="rating" value="4" aria-label="4 stars" />
        <input type="radio" name="rating" value="5" aria-label="5 stars" />
      </rating>
    </fieldset>
    <button type="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

Since the template contents are hidden, the form doesn’t show up right away. You can grab this hidden content using JavaScript and place it onto your page. This is useful when you want to repeat the same block of HTML in multiple places without rewriting it.

The <slot> element

We can use the <slot> tag as a placeholder inside our template. This creates an empty space where you can slide in custom text when you use the component.

<template id="star-rating-template">
  <form>
    <fieldset>
      <slot name="star-rating-legend">
        <legend>Rate your experience:</legend>
      </slot>
    </fieldset>
  </form>
</template>

The name attribute defines the slot. When you use the custom tag, any element with a matching slot attribute will render inside that placeholder. If you don’t provide any custom text, the default text inside the slot tag will show instead.

<star-rating>
  <legend slot="star-rating-legend">Blendan Smooth</legend>
</star-rating>
<star-rating>
  <legend slot="star-rating-legend">Hoover Sukhdeep</legend>
</star-rating>
<star-rating>
  <legend slot="star-rating-legend">Toasty McToastface</legend>
  <p>Is this text visible?</p>
</star-rating>

Undefined elements

If you use a tag that the browser doesn’t recognize, the page won’t break. The browser just treats it as a plain inline text box, similar to a <span>, with no default styles.

Until we define our custom tag using JavaScript, the browser will just show its children as plain text. We need to register the tag with the browser to make it work.

Custom elements

To register a custom tag, we use a tiny bit of JavaScript to tell the browser what our tag should do.

customElements.define(
  "star-rating",
  class extends HTMLElement {
    constructor() {
      super();
      const starRating = document.getElementById(
        "star-rating-template",
      ).content;
      const shadowRoot = this.attachShadow({
        mode: "open",
      });
      shadowRoot.appendChild(starRating.cloneNode(true));
    }
  },
);

Now, whenever the browser finds our custom tag, it will copy the template content inside it. Because this content is isolated, global CSS styles on your page won’t apply to it. You’ll need to write specific styles inside the template to style it.

Shadow DOM

The Shadow DOM is a way to keep your component styles separate from the rest of the page. Global styles won’t affect the inside of your component, and your component styles won’t leak out to affect other elements.

We can add a <style> block inside our template to style the component directly. Because the styles are isolated, we can use simple selectors like input without worrying about styling inputs elsewhere on the page.

<template id="star-rating-template">
  <style>
    rating {
      display: inline-flex;
    }
    input {
      appearance: none;
      margin: 0;
      box-shadow: none;
    }
    input::after {
      content: "\2605"; /* solid star */
      font-size: 32px;
    }
    rating:hover input:invalid::after,
    rating:focus-within input:invalid::after {
      color: #888;
    }
    input:invalid::after,
    rating:hover input:hover ~ input:invalid::after,
    input:focus ~ input:invalid::after {
      color: #ddd;
    }
    input:valid {
      color: orange;
    }
    input:checked ~ input:not(:checked)::after {
      color: #ccc;
      content: "\2606"; /* hollow star */
    }
  </style>
  <form>
    <fieldset>
      <slot name="star-rating-legend">
        <legend>Rate your experience:</legend>
      </slot>
      <rating>
        <input
          type="radio"
          name="rating"
          value="1"
          aria-label="1 star"
          required
        />
        <input type="radio" name="rating" value="2" aria-label="2 stars" />
        <input type="radio" name="rating" value="3" aria-label="3 stars" />
        <input type="radio" name="rating" value="4" aria-label="4 stars" />
        <input type="radio" name="rating" value="5" aria-label="5 stars" />
      </rating>
    </fieldset>
    <button type="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

Slotted content is not fully isolated. The legend element injected into our slot is styled by the global page styles, not by the component styles.

Styling across boundaries

You can style across the isolation boundary, but you have to do it intentionally.

Inside the component stylesheet, the :host selector targets the custom element itself.

The ::slotted() selector lets you style elements that were slid into slots from the outside.

If you want global styles to affect elements inside the component, you can add a part attribute to them. You can then target them in your main CSS file using ::part().

<template id="star-rating-template">
  <form part="formPart">
    <fieldset part="fieldsetPart"></fieldset>
  </form>
</template>
star-rating::part(formPart) {
  background-color: #f5f5f5;
}

Part attributes work like classes. Multiple elements can share the same part name, and a single element can have multiple parts.


Adapted from Learn HTML © Google and contributors, licensed under CC BY 4.0 (prose) and Apache 2.0 (code samples).