Skip to content
Frontend

Web Components - Native Browser Components That Are Changing Frontend

Published on:
·6 min read·Author: MDS Software Solutions Group

Web Components Native

frontend

Web Components - Native Browser Components

Web Components are a set of native browser APIs that allow you to create reusable, encapsulated UI components without any framework. This is not another library that will become obsolete next year - it is a standard built into every modern browser. Components you write today will work in 10 years without any changes.

In this article, you will learn all the key elements of the Web Components standard: Custom Elements API, Shadow DOM, HTML Templates and Slots, component lifecycle, events, styling, as well as tools like Lit and Shoelace that will take your productivity to the next level.

Custom Elements API - Defining Your Own HTML Tags#

The Custom Elements API is the foundation of Web Components. It allows you to define entirely new HTML tags with their own logic, styles, and behavior. Every Custom Element is a JavaScript class that extends HTMLElement.

Creating Your First Component#

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const name = this.getAttribute('name') || 'Anonymous';
    const role = this.getAttribute('role') || 'User';
    const avatar = this.getAttribute('avatar') || '/default-avatar.png';

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, sans-serif;
        }
        .card {
          display: flex;
          align-items: center;
          gap: 16px;
          padding: 16px;
          border-radius: 12px;
          background: #ffffff;
          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
          transition: box-shadow 0.2s ease;
        }
        .card:hover {
          box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
        }
        .avatar {
          width: 48px;
          height: 48px;
          border-radius: 50%;
          object-fit: cover;
        }
        .info h3 {
          margin: 0 0 4px;
          font-size: 1rem;
          color: #1a1a1a;
        }
        .info p {
          margin: 0;
          font-size: 0.875rem;
          color: #666;
        }
      </style>
      <div class="card">
        <img class="avatar" src="${avatar}" alt="${name}" />
        <div class="info">
          <h3>${name}</h3>
          <p>${role}</p>
        </div>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);

Now you can use this component anywhere in your HTML:

<user-card
  name="John Smith"
  role="Frontend Developer"
  avatar="/avatars/john.jpg"
></user-card>

Naming Conventions#

Custom Elements must contain a hyphen in their name (e.g., user-card, app-header). This distinguishes them from built-in HTML elements and prevents name collisions with future standards.

Shadow DOM - Full Style and Structure Encapsulation#

Shadow DOM is an encapsulation mechanism that creates an isolated DOM tree inside a component. Styles defined within the Shadow DOM do not leak out, and global styles do not affect the component's internals.

Shadow DOM Modes#

// "open" mode - shadowRoot is accessible from outside
this.attachShadow({ mode: 'open' });
// element.shadowRoot returns the ShadowRoot

// "closed" mode - shadowRoot is inaccessible
this.attachShadow({ mode: 'closed' });
// element.shadowRoot returns null

In practice, the open mode is far more commonly used as it makes debugging and testing easier.

Why Encapsulation Matters#

Imagine a large application with hundreds of components where each team writes their own CSS. Without encapsulation, .button from one module could override .button in another. Shadow DOM eliminates this problem entirely:

class IsolatedButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        /* These styles will NOT affect any other elements on the page */
        button {
          background: #3b82f6;
          color: white;
          border: none;
          padding: 8px 20px;
          border-radius: 6px;
          font-size: 14px;
          cursor: pointer;
        }
        button:hover {
          background: #2563eb;
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}

customElements.define('isolated-button', IsolatedButton);

HTML Templates and Slots - Flexible Content Composition#

The <template> element defines a fragment of HTML that is not rendered until it is used programmatically. Combined with Slots, it provides a powerful content projection system.

Using Templates#

<template id="product-template">
  <style>
    .product {
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      overflow: hidden;
    }
    .product-image {
      width: 100%;
      height: 200px;
      object-fit: cover;
    }
    .product-body {
      padding: 16px;
    }
    .product-title {
      font-size: 1.125rem;
      font-weight: 600;
      margin: 0 0 8px;
    }
    .product-price {
      font-size: 1.25rem;
      color: #16a34a;
      font-weight: 700;
    }
  </style>
  <div class="product">
    <slot name="image"></slot>
    <div class="product-body">
      <h3 class="product-title"><slot name="title">Product</slot></h3>
      <p class="product-price"><slot name="price"></slot></p>
      <slot></slot>
    </div>
  </div>
</template>
class ProductCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const template = document.getElementById('product-template');
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

customElements.define('product-card', ProductCard);

Named Slots and the Default Slot#

Slots allow component consumers to insert their own content into designated areas:

<product-card>
  <img slot="image" src="/laptop.jpg" alt="Laptop" />
  <span slot="title">MacBook Pro 16"</span>
  <span slot="price">$2,499</span>
  <p>M3 Pro chip, 18GB RAM, 512GB SSD</p>
</product-card>

The default slot (without a name attribute) captures all content that is not assigned to any named slot.

Lifecycle Callbacks - The Component Lifecycle#

Custom Elements have a set of lifecycle methods that are automatically called by the browser at key moments:

class DataFetcher extends HTMLElement {
  // List of attributes we want to observe
  static observedAttributes = ['url', 'interval'];

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._intervalId = null;
  }

  // Called when the element is added to the DOM
  connectedCallback() {
    console.log('Component added to the page');
    this.fetchData();
    this.startPolling();
  }

  // Called when the element is removed from the DOM
  disconnectedCallback() {
    console.log('Component removed from the page');
    this.stopPolling();
  }

  // Called when an observed attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed: ${oldValue} -> ${newValue}`);
    if (name === 'url' && oldValue !== null) {
      this.fetchData();
    }
    if (name === 'interval') {
      this.stopPolling();
      this.startPolling();
    }
  }

  // Called when the element is moved to a new document
  adoptedCallback() {
    console.log('Component moved to a new document');
  }

  async fetchData() {
    const url = this.getAttribute('url');
    if (!url) return;

    this.shadowRoot.innerHTML = '<p>Loading...</p>';

    try {
      const response = await fetch(url);
      const data = await response.json();
      this.render(data);
    } catch (error) {
      this.shadowRoot.innerHTML = `<p class="error">Error: ${error.message}</p>`;
    }
  }

  startPolling() {
    const interval = parseInt(this.getAttribute('interval') || '0');
    if (interval > 0) {
      this._intervalId = setInterval(() => this.fetchData(), interval);
    }
  }

  stopPolling() {
    if (this._intervalId) {
      clearInterval(this._intervalId);
      this._intervalId = null;
    }
  }

  render(data) {
    this.shadowRoot.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
  }
}

customElements.define('data-fetcher', DataFetcher);

Key principles:

  • connectedCallback is the place for initialization - fetching data, attaching event listeners, starting timers
  • disconnectedCallback is the place for cleanup - removing listeners, clearing timers, releasing resources
  • attributeChangedCallback only fires for attributes listed in observedAttributes

Attributes and Properties - Communicating with Components#

The distinction between HTML attributes and JavaScript properties is a crucial aspect of Web Component design.

class ToggleSwitch extends HTMLElement {
  static observedAttributes = ['checked', 'disabled', 'label'];

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._checked = false;
  }

  // JavaScript properties - getter/setter
  get checked() {
    return this._checked;
  }

  set checked(value) {
    this._checked = Boolean(value);
    // Synchronize with the HTML attribute
    if (this._checked) {
      this.setAttribute('checked', '');
    } else {
      this.removeAttribute('checked');
    }
    this.updateVisual();
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'checked') {
      this._checked = newValue !== null;
      this.updateVisual();
    }
  }

  connectedCallback() {
    this._checked = this.hasAttribute('checked');
    this.render();
    this.shadowRoot.querySelector('.toggle').addEventListener('click', () => {
      if (!this.disabled) {
        this.checked = !this.checked;
        this.dispatchEvent(new CustomEvent('toggle', {
          detail: { checked: this.checked },
          bubbles: true,
          composed: true,
        }));
      }
    });
  }

  render() { /* ... */ }
  updateVisual() { /* ... */ }
}

customElements.define('toggle-switch', ToggleSwitch);

Convention: HTML attributes serve for declarative configuration (in HTML), while JavaScript properties are for programmatic interaction.

Events - CustomEvent and Cross-Component Communication#

Web Components communicate with their surroundings through DOM events. CustomEvent allows you to create custom events with arbitrary data:

class SearchBox extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .search-container {
          display: flex;
          gap: 8px;
        }
        input {
          flex: 1;
          padding: 10px 16px;
          border: 2px solid #e5e7eb;
          border-radius: 8px;
          font-size: 14px;
          outline: none;
          transition: border-color 0.2s;
        }
        input:focus {
          border-color: #3b82f6;
        }
      </style>
      <div class="search-container">
        <input type="text" placeholder="Search..." />
      </div>
    `;

    const input = this.shadowRoot.querySelector('input');
    let debounceTimer;

    input.addEventListener('input', (e) => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        // bubbles: true - event propagates up the DOM tree
        // composed: true - event crosses Shadow DOM boundaries
        this.dispatchEvent(new CustomEvent('search', {
          detail: {
            query: e.target.value,
            timestamp: Date.now(),
          },
          bubbles: true,
          composed: true,
        }));
      }, 300);
    });

    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        this.dispatchEvent(new CustomEvent('search-submit', {
          detail: { query: e.target.value },
          bubbles: true,
          composed: true,
        }));
      }
    });
  }
}

customElements.define('search-box', SearchBox);

Listening for events from the parent level:

document.querySelector('search-box').addEventListener('search', (e) => {
  console.log('Search query:', e.detail.query);
  filterResults(e.detail.query);
});

Key CustomEvent flags:

  • bubbles: true - the event propagates up the DOM tree (like native click)
  • composed: true - the event crosses Shadow DOM boundaries (without this, the event stops at the shadowRoot)

Styling Web Components - ::part() and CSS Custom Properties#

Shadow DOM provides full encapsulation, but sometimes you want to allow component consumers to make controlled visual adjustments.

CSS Custom Properties (CSS Variables)#

CSS variables cross Shadow DOM boundaries, making them the ideal theming mechanism:

class ThemedCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          --card-bg: #ffffff;
          --card-radius: 12px;
          --card-padding: 24px;
          --card-shadow: 0 2px 8px rgba(0,0,0,0.1);
          --card-title-color: #1a1a1a;
          --card-text-color: #4b5563;
          display: block;
        }
        .card {
          background: var(--card-bg);
          border-radius: var(--card-radius);
          padding: var(--card-padding);
          box-shadow: var(--card-shadow);
        }
        .card-title {
          color: var(--card-title-color);
          margin: 0 0 12px;
        }
        .card-content {
          color: var(--card-text-color);
          line-height: 1.6;
        }
      </style>
      <div class="card">
        <h2 class="card-title"><slot name="title"></slot></h2>
        <div class="card-content"><slot></slot></div>
      </div>
    `;
  }
}

customElements.define('themed-card', ThemedCard);

Consumers can override variables from outside:

themed-card {
  --card-bg: #1e293b;
  --card-title-color: #f8fafc;
  --card-text-color: #94a3b8;
  --card-radius: 16px;
}

The ::part() Pseudo-Element#

The part attribute exposes internal elements for external styling:

this.shadowRoot.innerHTML = `
  <style>
    .btn { /* default styles */ }
  </style>
  <button part="button" class="btn">
    <slot></slot>
  </button>
`;
/* Styling from outside */
my-button::part(button) {
  background: linear-gradient(135deg, #667eea, #764ba2);
  color: white;
  font-weight: 600;
}

my-button::part(button):hover {
  transform: translateY(-1px);
}

Lit - A Modern Framework for Web Components#

Writing Web Components in vanilla JavaScript works, but it is verbose. Lit is a lightweight library (5KB gzip) from Google that drastically simplifies component creation:

import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('task-list')
class TaskList extends LitElement {
  static styles = css`
    :host {
      display: block;
      max-width: 480px;
    }
    ul {
      list-style: none;
      padding: 0;
    }
    li {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px;
      border-bottom: 1px solid #f1f5f9;
    }
    li.done span {
      text-decoration: line-through;
      color: #94a3b8;
    }
    .add-form {
      display: flex;
      gap: 8px;
      margin-bottom: 16px;
    }
    input {
      flex: 1;
      padding: 8px 12px;
      border: 1px solid #e2e8f0;
      border-radius: 6px;
    }
    button {
      padding: 8px 16px;
      background: #3b82f6;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
    }
  `;

  @property({ type: String }) title = 'Task List';

  @state() tasks = [
    { id: 1, text: 'Learn Web Components', done: false },
    { id: 2, text: 'Try Lit', done: false },
  ];

  @state() newTask = '';

  render() {
    return html`
      <h2>${this.title}</h2>
      <div class="add-form">
        <input
          .value=${this.newTask}
          @input=${(e) => this.newTask = e.target.value}
          @keydown=${(e) => e.key === 'Enter' && this.addTask()}
          placeholder="New task..."
        />
        <button @click=${this.addTask}>Add</button>
      </div>
      <ul>
        ${this.tasks.map(task => html`
          <li class=${task.done ? 'done' : ''}>
            <input
              type="checkbox"
              .checked=${task.done}
              @change=${() => this.toggleTask(task.id)}
            />
            <span>${task.text}</span>
          </li>
        `)}
      </ul>
    `;
  }

  addTask() {
    if (this.newTask.trim()) {
      this.tasks = [
        ...this.tasks,
        { id: Date.now(), text: this.newTask, done: false },
      ];
      this.newTask = '';
    }
  }

  toggleTask(id) {
    this.tasks = this.tasks.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    );
  }
}

Lit offers:

  • Reactive properties - changing a value automatically triggers a re-render
  • html templates - tagged template literals with efficient DOM updates
  • Decorators - @property(), @state(), @customElement()
  • Web Components-compatible lifecycle - plus additional methods like updated() and firstUpdated()

Shoelace / Web Awesome - A Ready-Made Component Library#

Shoelace (currently evolving into Web Awesome) is a professional UI component library built on Web Components. It works with any framework or without one:

<!-- Installation via CDN -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace/dist/themes/light.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace/dist/shoelace-autoloader.js"></script>

<!-- Using components -->
<sl-button variant="primary" size="large">
  Click me
</sl-button>

<sl-dialog label="Confirmation">
  Are you sure you want to delete this item?
  <sl-button slot="footer" variant="primary">Yes</sl-button>
  <sl-button slot="footer" variant="default">No</sl-button>
</sl-dialog>

<sl-tab-group>
  <sl-tab slot="nav" panel="general">General</sl-tab>
  <sl-tab slot="nav" panel="settings">Settings</sl-tab>
  <sl-tab-panel name="general">General tab content</sl-tab-panel>
  <sl-tab-panel name="settings">Settings tab content</sl-tab-panel>
</sl-tab-group>

<sl-color-picker label="Choose a color"></sl-color-picker>

Shoelace includes over 50 components: buttons, dialogs, tabs, dropdowns, forms, sliders, and much more - all built on Web Components standards.

Integration with React, Vue, and Angular#

The greatest strength of Web Components is their interoperability. The same component works in every framework.

React#

React has significantly improved Web Components support starting with version 19. Earlier versions required wrappers:

// React 19+ - direct Web Components usage
function App() {
  const handleSearch = (e) => {
    console.log('Query:', e.detail.query);
  };

  return (
    <div>
      <search-box onSearch={handleSearch}></search-box>
      <user-card name="Jane Doe" role="Designer"></user-card>
    </div>
  );
}

Vue#

Vue has excellent support for Custom Elements:

<template>
  <search-box @search="onSearch"></search-box>
  <user-card :name="userName" :role="userRole"></user-card>
</template>

<script setup>
// Configuration in vite.config.js:
// vue({ template: { compilerOptions: { isCustomElement: tag => tag.includes('-') } } })

const userName = ref('Peter Wilson');
const userRole = ref('Backend Developer');

function onSearch(e) {
  console.log('Searching:', e.detail.query);
}
</script>

Angular#

Angular natively supports Custom Elements:

// app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule {}
<!-- Usage in an Angular template -->
<search-box (search)="onSearch($event)"></search-box>
<user-card [attr.name]="userName" [attr.role]="userRole"></user-card>

Browser Support#

Web Components are supported by all modern browsers:

  • Chrome/Edge - full support since version 67+
  • Firefox - full support since version 63+
  • Safari - full support since version 13.1+
  • Market share - over 95% of global traffic

Polyfills are available for older browsers (IE11), though in 2025 they are rarely needed.

Accessibility (a11y) in Web Components#

Building accessible Web Components requires a deliberate approach, as Shadow DOM can complicate interactions with screen readers.

Key Principles#

class AccessibleDialog extends HTMLElement {
  connectedCallback() {
    this.setAttribute('role', 'dialog');
    this.setAttribute('aria-modal', 'true');
    this.setAttribute('aria-labelledby', 'dialog-title');

    this.shadowRoot.innerHTML = `
      <style>
        :host { /* ... */ }
        :host(:focus-within) .overlay { /* ... */ }
      </style>
      <div class="overlay" part="overlay">
        <div class="dialog" role="document">
          <h2 id="dialog-title"><slot name="title">Dialog</slot></h2>
          <div class="content"><slot></slot></div>
          <button
            class="close"
            aria-label="Close dialog"
            @click=${this.close}
          >&times;</button>
        </div>
      </div>
    `;

    // Focus management
    this.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') this.close();
      if (e.key === 'Tab') this.trapFocus(e);
    });
  }

  open() {
    this.setAttribute('open', '');
    this.previouslyFocused = document.activeElement;
    this.shadowRoot.querySelector('.dialog').focus();
  }

  close() {
    this.removeAttribute('open');
    this.previouslyFocused?.focus();
  }

  trapFocus(e) { /* focus trap implementation */ }
}

The most important accessibility principles:

  • Set appropriate ARIA roles (role, aria-label, aria-expanded)
  • Manage focus - trap focus in dialogs, restore focus on close
  • Handle keyboard navigation (Tab, Escape, Enter, arrow keys)
  • Use aria-labelledby to link elements with their labels
  • Delegate focus into Shadow DOM with delegatesFocus: true

When to Use Web Components vs. Frameworks#

Choose Web Components when:#

  • Building a component library - that must work independently of any framework
  • Creating a design system - to be used across different projects and teams
  • You need encapsulation - components must not collide with page styles
  • Building widgets - embedded in external sites (chat, forms, players)
  • Longevity matters - a browser standard will not disappear in 2 years
  • Working with micro-frontends - each team can use a different technology

Choose a framework (React/Vue/Angular) when:#

  • Building a full SPA - routing, state management, SSR
  • You need a rich ecosystem - hundreds of libraries, tools, and integrations
  • The team knows the framework - productivity is key
  • You need SSR/SSG - Next.js, Nuxt, Angular Universal

The Hybrid Approach#

Most often the best solution is to combine both approaches: Web Components for shared UI components (design system), a framework for application logic. This is the approach taken by companies like Google (Lit + Angular), Salesforce (Lightning Web Components), Adobe (Spectrum Web Components), and GitHub (their own Web Components).

Summary#

Web Components are a mature, stable standard that solves real problems in frontend development. Custom Elements, Shadow DOM, Templates, and Slots form a complete toolkit for building reusable, encapsulated UI components.

Tools like Lit and Shoelace make working with Web Components just as productive as working with frameworks, and interoperability with React, Vue, and Angular means a component written once works everywhere.

If you have not yet added Web Components to your toolkit, now is an excellent time to start.


Planning to build a design system, a component library, or implement Web Components in your organization? The MDS Software Solutions Group team has experience building scalable, accessible components based on web standards. Contact us - we will help you build a solution that stands the test of time.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Web Components - Native Browser Components That Are Changing Frontend | MDS Software Solutions Group | MDS Software Solutions Group