logo

- 6 min read

Web Components: Build once use anywhere

Web components are a way to create custom, reusable HTML tags. They are part of the browser’s native functionality, which means they can be used without any additional libraries or frameworks. Web Components are based on four main specifications:

  • Custom Elements: The foundation for creating new HTML tags with custom behaviors and properties.
  • Shadow DOM: Encapsulates internal structure and styling of web components.
  • ES Modules: A standard way to include and reuse functionality across JavaScript documents.
  • HTML Templates: Reusable HTML chunks that are rendered when instantiated.

Why I use Web Components

My work involves many different types of projects, from static sites and PWA apps to e-commerce stores and Go + HTMX SaaS projects. So for me building a component once and using it across multiple projects saves me time and keeps things consistent across languages and frameworks. No matter what technology I’m using for my current work or what the new front-end trend is my web components just work.

A simple “Hello World” example

As any other tech blog out there, let’s start with basic “Hello World” to demonstrate the fundamental structure:

class HelloWorld extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>
        p { color: blue; font-weight: bold; }
      </style>
      <p>Hello, World!</p>
    `;
  }
}

customElements.define("hello-world", HelloWorld);

This component creates a blue, bold “Hello, World!” message, and isolates it’s styles from the rest of the page, and it can be used in your HTML like this:

<hello-world></hello-world>

Creating a Theme Switcher Web Component

Now, let’s dive into a more complex and practical example: the theme toggle I use on this very blog. This component allows users to switch between light and dark themes. While keeping in mind their browser preference and setting and setting it upon their initial visit.

Let’s break down the key parts of this component:

  • It checks for a stored preference or system preference on load.
  • It provides a UI element to switch themes.
  • It listens for system theme changes.
  • It updates the theme across the entire document.
  • It includes ARIA attributes for better accessibility.
  • It uses the Shadow DOM for style encapsulation, ensuring the component’s styles don’t interfere with the rest of the page and that it looks the same in any project.

The current theme toggle was inspired by this article on web.dev and they have a brilliant video explaining the process of creating it. I took it a step further and modified it so that it’s now a reusable web component. I am not an expert in animations so i will keep that part out of the provided steps below and instead rely on a simpler emoji version.

Step 1: Component Structure

Let’s start with the basic structure of our web component:

class ThemeToggle extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
  }
}

customElements.define("theme-toggle", ThemeToggle);

This creates a bare-bones web component with a shadow DOM.

Step 2: Add HTML Structure

Next, let’s add the HTML structure for our toggle button, keeping in mind to add proper aria labels so we keep the component accessible:

class ThemeToggle extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });

    shadowRoot.innerHTML = `
      <button id="theme-toggle" title="Toggle light & dark theme" aria-label="auto" aria-live="polite">
        🌞
      </button>
    `;
  }
}

customElements.define("theme-toggle", ThemeToggle);

Step 3: Add Basic Styles

As mentioned before, I wont go in detail on how to make your component animate nicely or look stunning, for now a simple rounded button will do the trick

class ThemeToggle extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });

    shadowRoot.innerHTML = `
      <style>
        button {
          background: none;
          border: none;
          cursor: pointer;
          font-size: 24px;
          padding: 0 5px;
          border-radius: 50%;
          transition: background-color 0.3s ease;
          aspect-ratio:1;
        }
        button:hover {
          background-color: rgba(0, 0, 0, 0.1);
        }
      </style>
      <button id="theme-toggle" title="Toggle light & dark theme" aria-label="auto" aria-live="polite">
        🌞
      </button>
    `;
  }
}

customElements.define("theme-toggle", ThemeToggle);

Step 4: Implement Basic Functionality

For the website theme itself, it is very straight forward, if we see the data-theme="dark" on the <html></html> tag we know that the user prefers dark mode.

The CSS variable setup look like this:

:root {
  --background: #fffffa;
  --foreground: #05192d;
}
[data-theme="dark"] {
  --background: #05192d;
  --foreground: #fffffa;
}

Let’s add the basic theme toggling functionality, which changes the buttons emoji and adds a data-theme="dark" or data-theme="light" to the html tag which we can use to change the colors of our website the CSS variables.

class ThemeToggle extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });

    // ...

    this.button = shadowRoot.getElementById("theme-toggle");
    this.theme = { value: "light" };

    this.button.addEventListener("click", () => this.onClick());
  }

  onClick() {
    this.theme.value = this.theme.value === "light" ? "dark" : "light";
    this.savePreference();
  }

  savePreference() {
    document.firstElementChild.setAttribute("data-theme", this.theme.value);
    this.button.setAttribute("aria-label", this.theme.value);
    this.button.textContent = this.theme.value === "light" ? "🌞" : "🌙";
  }
}

customElements.define("theme-toggle", ThemeToggle);

Step 5: Implement Preference Storage

To further extend the UX and functionality we listen and pre-set the users browser preference as the default for our website as well.

class ThemeToggle extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });

    // ...

    this.button = shadowRoot.getElementById("theme-toggle");
    this.storageKey = "theme-preference";
    this.theme = { value: this.getPreference() };

    this.button.addEventListener("click", () => this.onClick());

    this.setPreference();
  }

  onClick() {
    this.theme.value = this.theme.value === "light" ? "dark" : "light";
    this.setPreference();
  }

  getPreference() {
    if (localStorage.getItem(this.storageKey)) return localStorage.getItem(this.storageKey);
    else return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }

  setPreference() {
    localStorage.setItem(this.storageKey, this.theme.value);
    this.savePreference();
  }

  savePreference() {
    document.firstElementChild.setAttribute("data-theme", this.theme.value);
    this.button.setAttribute("aria-label", this.theme.value);
    this.button.textContent = this.theme.value === "light" ? "🌞" : "🌙";
  }
}

customElements.define("theme-toggle", ThemeToggle);

Step 6: Add System Preference Listener

Finally, let’s add a listener for system preference changes, so that if the user one day for some reason decides to leave dark mode and embrace light instead, our website will change according to their preference as well!

class ThemeToggle extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });

    shadowRoot.innerHTML = `
      <style>
        button {
          background: none;
          border: none;
          cursor: pointer;
          font-size: 24px;
          padding: 0 5px;
          border-radius: 50%;
          transition: background-color 0.3s ease;
          aspect-ratio:1
        }
        button:hover {
          background-color: rgba(0, 0, 0, 0.1);
        }
      </style>
      <button id="theme-toggle" title="Toggle light & dark theme" aria-label="auto" aria-live="polite">
        🌞
      </button>
    `;

    this.button = shadowRoot.getElementById("theme-toggle");
    this.storageKey = "theme-preference";
    this.theme = { value: this.getPreference() };

    this.button.addEventListener("click", () => this.onClick());

    window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", ({ matches: isDark }) => {
      this.theme.value = isDark ? "dark" : "light";
      this.setPreference();
    });

    this.setPreference();
  }

  onClick() {
    this.theme.value = this.theme.value === "light" ? "dark" : "light";
    this.setPreference();
  }

  getPreference() {
    if (localStorage.getItem(this.storageKey)) return localStorage.getItem(this.storageKey);
    else return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }

  setPreference() {
    localStorage.setItem(this.storageKey, this.theme.value);
    this.savePreference();
  }

  savePreference() {
    document.firstElementChild.setAttribute("data-theme", this.theme.value);
    this.button.setAttribute("aria-label", this.theme.value);
    this.button.textContent = this.theme.value === "light" ? "🌞" : "🌙";
  }

  connectedCallback() {
    this.savePreference();
  }
}

customElements.define("theme-toggle", ThemeToggle);

You can now use this component in your HTML like this:

<theme-toggle></theme-toggle>

Final thoughts

While libraries can simplify web component development, I believe getting used to native web components is more valuable in the long run. Once you overcome the initial learning curve, native components offer unparalleled longevity, framework independence, and performance benefits. As Jake Lazaroff notes, Web Components Will Outlive Your JavaScript Framework . By investing in native web components, you’re future-proofing your skills and creating truly reusable, cross-project UI elements.

If you have any questions or suggestions feel free to drop me a message on twitter

web components