Web Components
Web Components are a set of browser-native APIs that enable you to create reusable, encapsulated custom HTML elements. Unlike framework components (React, Vue, Angular), Web Components are framework-agnostic — they work across any JavaScript framework or no framework at all. They are part of the browser specification and require no external libraries.
The Web Components specification consists of three core technologies: Custom Elements for defining new HTML tags, Shadow DOM for encapsulating styles and markup, and HTML Templates (including <template> and <slot>) for declarative markup fragments. Together, they provide the same encapsulation and reusability benefits as framework components, but at the browser level.
Web Components are supported natively in all modern browsers (Chrome, Firefox, Safari, Edge). With lightweight polyfills, they work back to Internet Explorer 11. Major companies like Google, Microsoft, ING, GitHub, and Salesforce use Web Components in production for design systems, widget libraries, and cross-framework component sharing.
Web Components rest on three browser APIs that work independently but are designed to be composed together:
1. Custom Elements
The customElements.define() API lets you register a new HTML element with a custom tag name (must contain a hyphen). Your element can react to lifecycle events — creation, attribute changes, connection to the DOM, and removal. Custom Elements can extend native HTML elements (like <button> or <input>) using the extends keyword.
2. Shadow DOM
The Shadow DOM provides style and markup encapsulation. When you attach a shadow root to an element, its internal DOM tree is isolated from the main document. CSS scoped inside the shadow tree does not leak out, and external CSS does not penetrate in (unless you use ::part() or CSS custom properties). This eliminates style conflicts entirely.
3. HTML Templates & Slots
The <template> element holds HTML fragments that are parsed but not rendered. Clone its content with JavaScript to stamp out repeated structures. The <slot> element creates insertion points inside the Shadow DOM, allowing users of your component to project their own content into designated areas — this is how composition works in Web Components.
note
Custom Elements are defined by creating a class that extends HTMLElement (or a specific HTML element interface like HTMLButtonElement for customized built-in elements). The class defines the element's behavior, appearance, and lifecycle callbacks.
| Lifecycle Callback | Called When |
|---|---|
| constructor() | Element is created (before being attached to DOM) |
| connectedCallback() | Element is inserted into the DOM |
| disconnectedCallback() | Element is removed from the DOM |
| attributeChangedCallback(name, oldValue, newValue) | An observed attribute is added, changed, or removed |
| adoptedCallback() | Element is moved to a new document |
| 1 | // Define a custom element class |
| 2 | class MyElement extends HTMLElement { |
| 3 | constructor() { |
| 4 | super(); |
| 5 | // Initialize state, set up event listeners |
| 6 | this._count = 0; |
| 7 | } |
| 8 | |
| 9 | // Specify which attributes to observe |
| 10 | static get observedAttributes() { |
| 11 | return ['count', 'label']; |
| 12 | } |
| 13 | |
| 14 | // Lifecycle: element inserted into DOM |
| 15 | connectedCallback() { |
| 16 | this.render(); |
| 17 | } |
| 18 | |
| 19 | // Lifecycle: element removed from DOM |
| 20 | disconnectedCallback() { |
| 21 | // Clean up: remove event listeners, timers |
| 22 | } |
| 23 | |
| 24 | // Lifecycle: observed attribute changes |
| 25 | attributeChangedCallback(name, oldVal, newVal) { |
| 26 | if (oldVal === newVal) return; |
| 27 | if (name === 'count') this._count = parseInt(newVal) || 0; |
| 28 | if (name === 'label') this._label = newVal; |
| 29 | this.render(); |
| 30 | } |
| 31 | |
| 32 | render() { |
| 33 | this.textContent = `${this._label || 'Count'}: ${this._count}`; |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // Register the element (tag MUST contain a hyphen) |
| 38 | customElements.define('my-element', MyElement); |
Using the custom element in HTML:
| 1 | <!-- Use the custom element like any HTML tag --> |
| 2 | <my-element count="5" label="Items"></my-element> |
| 3 | |
| 4 | <!-- Attribute changes trigger attributeChangedCallback --> |
| 5 | <script> |
| 6 | const el = document.querySelector('my-element'); |
| 7 | el.setAttribute('count', '10'); // triggers update |
| 8 | el.removeAttribute('label'); // triggers update |
| 9 | </script> |
best practice
The Shadow DOM provides encapsulation for both markup and styles. When you attach a shadow root, the element gets its own isolated DOM tree. Styles defined inside the shadow tree do not affect the outside page, and external styles do not affect the shadow tree's interior.
There are two modes for attaching a shadow root:
| Mode | Description | Accessible via JS |
|---|---|---|
| open | Shadow root is accessible via element.shadowRoot | Yes |
| closed | element.shadowRoot returns null | No |
| 1 | class ShadowedComponent extends HTMLElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | // Attach shadow root in 'open' mode |
| 5 | const shadow = this.attachShadow({ mode: 'open' }); |
| 6 | |
| 7 | // Create internal DOM structure |
| 8 | const wrapper = document.createElement('div'); |
| 9 | wrapper.className = 'wrapper'; |
| 10 | |
| 11 | const style = document.createElement('style'); |
| 12 | style.textContent = ` |
| 13 | .wrapper { |
| 14 | padding: 16px; |
| 15 | border: 1px solid #333; |
| 16 | border-radius: 8px; |
| 17 | background: #0D0D0D; |
| 18 | color: #E0E0E0; |
| 19 | font-family: system-ui; |
| 20 | } |
| 21 | .title { |
| 22 | color: #00FF41; |
| 23 | font-size: 14px; |
| 24 | font-weight: 600; |
| 25 | margin: 0 0 8px 0; |
| 26 | } |
| 27 | /* These styles are scoped — they won't leak out */ |
| 28 | `; |
| 29 | |
| 30 | const title = document.createElement('h2'); |
| 31 | title.className = 'title'; |
| 32 | title.textContent = this.getAttribute('title') || 'Shadow Component'; |
| 33 | wrapper.appendChild(title); |
| 34 | |
| 35 | // Create slot for user content |
| 36 | const slot = document.createElement('slot'); |
| 37 | wrapper.appendChild(slot); |
| 38 | |
| 39 | shadow.appendChild(style); |
| 40 | shadow.appendChild(wrapper); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | customElements.define('shadowed-component', ShadowedComponent); |
Using the shadowed component:
| 1 | <shadowed-component title="My Card"> |
| 2 | <!-- This content is projected into the slot --> |
| 3 | <p>This content is projected into the slot area.</p> |
| 4 | <button>Click me</button> |
| 5 | </shadowed-component> |
| 6 | |
| 7 | <!-- CSS outside the shadow DOM will NOT affect internal styles --> |
| 8 | <style> |
| 9 | /* These styles do NOT penetrate the shadow DOM */ |
| 10 | .wrapper { background: red !important; } /* no effect */ |
| 11 | shadowed-component { display: block; margin: 16px 0; } /* only host styles work */ |
| 12 | </style> |
info
The <template> element holds inert HTML that is not rendered until cloned and inserted by JavaScript. Combined with <slot> elements inside Shadow DOM, you get a declarative composition system similar to Vue slots or React children.
| 1 | <!-- Define a template --> |
| 2 | <template id="user-card-template"> |
| 3 | <style> |
| 4 | .card { |
| 5 | border: 1px solid #333; |
| 6 | border-radius: 8px; |
| 7 | padding: 16px; |
| 8 | background: #111; |
| 9 | color: #E0E0E0; |
| 10 | font-family: system-ui; |
| 11 | } |
| 12 | .name { color: #00FF41; font-size: 16px; font-weight: 600; } |
| 13 | .role { color: #808080; font-size: 12px; } |
| 14 | ::slotted(img) { |
| 15 | width: 48px; |
| 16 | height: 48px; |
| 17 | border-radius: 50%; |
| 18 | border: 2px solid #00FF41; |
| 19 | } |
| 20 | </style> |
| 21 | <div class="card"> |
| 22 | <slot name="avatar"> |
| 23 | <div class="default-avatar">👤</div> |
| 24 | </slot> |
| 25 | <div class="name"><slot name="name">User Name</slot></div> |
| 26 | <div class="role"><slot name="role">Role</slot></div> |
| 27 | <slot></slot> |
| 28 | </div> |
| 29 | </template> |
| 30 | |
| 31 | <!-- Usage with slot content --> |
| 32 | <user-card> |
| 33 | <img slot="avatar" src="profile.jpg" alt="Avatar" /> |
| 34 | <span slot="name">Jane Doe</span> |
| 35 | <span slot="role">Senior Engineer</span> |
| 36 | <p>Additional content goes to the default slot.</p> |
| 37 | </user-card> |
The corresponding JavaScript to define the custom element:
| 1 | class UserCard extends HTMLElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | const template = document.getElementById('user-card-template'); |
| 5 | const shadow = this.attachShadow({ mode: 'open' }); |
| 6 | shadow.appendChild(template.content.cloneNode(true)); |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | customElements.define('user-card', UserCard); |
best practice
This example combines Custom Elements, Shadow DOM, Templates, and Slots into a fully functional rating component with attributes, events, and state management.
| 1 | <!-- rating-component.html — A complete star rating widget --> |
| 2 | |
| 3 | <!-- Template for the component's shadow DOM --> |
| 4 | <template id="rating-template"> |
| 5 | <style> |
| 6 | :host { |
| 7 | display: inline-flex; |
| 8 | align-items: center; |
| 9 | gap: 4px; |
| 10 | font-family: system-ui; |
| 11 | } |
| 12 | :host([disabled]) { |
| 13 | opacity: 0.5; |
| 14 | pointer-events: none; |
| 15 | } |
| 16 | .star { |
| 17 | cursor: pointer; |
| 18 | font-size: var(--star-size, 24px); |
| 19 | transition: transform 0.15s, opacity 0.15s; |
| 20 | background: none; |
| 21 | border: none; |
| 22 | padding: 0; |
| 23 | line-height: 1; |
| 24 | } |
| 25 | .star:hover { |
| 26 | transform: scale(1.2); |
| 27 | } |
| 28 | .star.empty { opacity: 0.3; } |
| 29 | .star.filled { opacity: 1; color: #FFD700; } |
| 30 | .star.half { |
| 31 | position: relative; |
| 32 | display: inline-block; |
| 33 | } |
| 34 | .label { |
| 35 | font-size: 12px; |
| 36 | color: #808080; |
| 37 | margin-left: 8px; |
| 38 | } |
| 39 | </style> |
| 40 | <span id="stars"></span> |
| 41 | <span class="label" id="label"></span> |
| 42 | </template> |
| 43 | |
| 44 | <div id="app"> |
| 45 | <star-rating value="3" max="5"></star-rating> |
| 46 | <star-rating value="4" max="5" disabled></star-rating> |
| 47 | </div> |
| 48 | |
| 49 | <script> |
| 50 | class StarRating extends HTMLElement { |
| 51 | static get observedAttributes() { |
| 52 | return ['value', 'max', 'disabled']; |
| 53 | } |
| 54 | |
| 55 | constructor() { |
| 56 | super(); |
| 57 | const template = document.getElementById('rating-template'); |
| 58 | const shadow = this.attachShadow({ mode: 'open' }); |
| 59 | shadow.appendChild(template.content.cloneNode(true)); |
| 60 | |
| 61 | this._starsContainer = shadow.getElementById('stars'); |
| 62 | this._labelEl = shadow.getElementById('label'); |
| 63 | |
| 64 | this._value = parseInt(this.getAttribute('value')) || 0; |
| 65 | this._max = parseInt(this.getAttribute('max')) || 5; |
| 66 | } |
| 67 | |
| 68 | connectedCallback() { |
| 69 | this.render(); |
| 70 | this._starsContainer.addEventListener('click', (e) => { |
| 71 | const star = e.target.closest('.star'); |
| 72 | if (!star || this.disabled) return; |
| 73 | const idx = parseInt(star.dataset.index); |
| 74 | this.setAttribute('value', String(idx + 1)); |
| 75 | this.dispatchEvent(new CustomEvent('rating-change', { |
| 76 | detail: { value: idx + 1, max: this._max }, |
| 77 | bubbles: true, |
| 78 | composed: true, |
| 79 | })); |
| 80 | }); |
| 81 | } |
| 82 | |
| 83 | attributeChangedCallback(name, oldVal, newVal) { |
| 84 | if (oldVal === newVal) return; |
| 85 | if (name === 'value') this._value = parseInt(newVal) || 0; |
| 86 | if (name === 'max') this._max = parseInt(newVal) || 5; |
| 87 | this.render(); |
| 88 | } |
| 89 | |
| 90 | render() { |
| 91 | if (!this._starsContainer) return; |
| 92 | this._starsContainer.innerHTML = ''; |
| 93 | for (let i = 0; i < this._max; i++) { |
| 94 | const star = document.createElement('button'); |
| 95 | star.className = `star ${i < this._value ? 'filled' : 'empty'}`; |
| 96 | star.dataset.index = i; |
| 97 | star.textContent = i < this._value ? '★' : '☆'; |
| 98 | star.setAttribute('aria-label', `Star ${i + 1}`); |
| 99 | this._starsContainer.appendChild(star); |
| 100 | } |
| 101 | this._labelEl.textContent = `${this._value} / ${this._max}`; |
| 102 | } |
| 103 | |
| 104 | get disabled() { |
| 105 | return this.hasAttribute('disabled'); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | customElements.define('star-rating', StarRating); |
| 110 | </script> |
Live preview of the star rating component:
pro tip
Custom Elements accept data through HTML attributes and JavaScript properties. The observedAttributes static getter declares which attributes the component watches. Changes trigger attributeChangedCallback. For JavaScript properties, you define getters and setters directly on the element class.
| 1 | class ToggleSwitch extends HTMLElement { |
| 2 | // Declare observed attributes for reactive updates |
| 3 | static get observedAttributes() { |
| 4 | return ['checked', 'disabled', 'label']; |
| 5 | } |
| 6 | |
| 7 | constructor() { |
| 8 | super(); |
| 9 | const shadow = this.attachShadow({ mode: 'open' }); |
| 10 | shadow.innerHTML = ` |
| 11 | <style> |
| 12 | :host { display: inline-flex; align-items: center; gap: 8px; |
| 13 | font-family: system-ui; cursor: pointer; } |
| 14 | .track { width: 40px; height: 22px; background: #333; |
| 15 | border-radius: 11px; position: relative; |
| 16 | transition: background 0.2s; } |
| 17 | .track.checked { background: #00FF41; } |
| 18 | .thumb { width: 18px; height: 18px; background: #E0E0E0; |
| 19 | border-radius: 50%; position: absolute; top: 2px; |
| 20 | left: 2px; transition: left 0.2s; } |
| 21 | .checked .thumb { left: 20px; } |
| 22 | :host([disabled]) { opacity: 0.5; pointer-events: none; } |
| 23 | .label-text { font-size: 12px; color: #808080; } |
| 24 | </style> |
| 25 | <div class="track" id="track"> |
| 26 | <div class="thumb"></div> |
| 27 | </div> |
| 28 | <span class="label-text" id="labelText"></span> |
| 29 | `; |
| 30 | this._track = shadow.getElementById('track'); |
| 31 | this._labelText = shadow.getElementById('labelText'); |
| 32 | this._onClick = this._toggle.bind(this); |
| 33 | } |
| 34 | |
| 35 | // Property getter/setter for checked |
| 36 | get checked() { return this.hasAttribute('checked'); } |
| 37 | set checked(val) { |
| 38 | if (val) this.setAttribute('checked', ''); |
| 39 | else this.removeAttribute('checked'); |
| 40 | } |
| 41 | |
| 42 | get disabled() { return this.hasAttribute('disabled'); } |
| 43 | set disabled(val) { |
| 44 | if (val) this.setAttribute('disabled', ''); |
| 45 | else this.removeAttribute('disabled'); |
| 46 | } |
| 47 | |
| 48 | connectedCallback() { |
| 49 | this.addEventListener('click', this._onClick); |
| 50 | this._update(); |
| 51 | } |
| 52 | |
| 53 | disconnectedCallback() { |
| 54 | this.removeEventListener('click', this._onClick); |
| 55 | } |
| 56 | |
| 57 | attributeChangedCallback(name, oldVal, newVal) { |
| 58 | if (oldVal === newVal) return; |
| 59 | this._update(); |
| 60 | if (name === 'checked') { |
| 61 | this.dispatchEvent(new CustomEvent('toggle', { |
| 62 | detail: { checked: this.checked }, |
| 63 | bubbles: true, |
| 64 | composed: true, |
| 65 | })); |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | _toggle() { |
| 70 | if (this.disabled) return; |
| 71 | this.checked = !this.checked; |
| 72 | } |
| 73 | |
| 74 | _update() { |
| 75 | if (!this._track) return; |
| 76 | this._track.className = `track ${this.checked ? 'checked' : ''}`; |
| 77 | this._labelText.textContent = this.getAttribute('label') || ''; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | customElements.define('toggle-switch', ToggleSwitch); |
| 82 | |
| 83 | // Usage: |
| 84 | // <toggle-switch checked label="Dark Mode"></toggle-switch> |
| 85 | // document.querySelector('toggle-switch').checked = true; |
Live preview of the toggle switch:
Web Components communicate with the outside world through DOM events. The standard CustomEvent API lets you dispatch structured events with arbitrary data. When dispatching from within Shadow DOM, always set bubbles: true and composed: true so the event crosses the shadow boundary.
| 1 | class EventDemo extends HTMLElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | const shadow = this.attachShadow({ mode: 'open' }); |
| 5 | shadow.innerHTML = ` |
| 6 | <style> |
| 7 | button { padding: 8px 16px; background: #00FF41; color: #000; |
| 8 | border: none; border-radius: 4px; font-size: 12px; |
| 9 | font-weight: 600; cursor: pointer; |
| 10 | font-family: system-ui; } |
| 11 | </style> |
| 12 | <button id="btn">Trigger Event</button> |
| 13 | `; |
| 14 | shadow.getElementById('btn') |
| 15 | .addEventListener('click', () => { |
| 16 | // Dispatch a custom event that crosses shadow boundary |
| 17 | this.dispatchEvent(new CustomEvent('action', { |
| 18 | detail: { |
| 19 | message: 'Button was clicked!', |
| 20 | timestamp: Date.now(), |
| 21 | source: 'event-demo', |
| 22 | }, |
| 23 | bubbles: true, // bubble up through DOM tree |
| 24 | composed: true, // cross shadow boundary |
| 25 | })); |
| 26 | }); |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | customElements.define('event-demo', EventDemo); |
| 31 | |
| 32 | // Listening for the event |
| 33 | document.querySelector('event-demo') |
| 34 | .addEventListener('action', (e) => { |
| 35 | console.log(e.detail.message); // "Button was clicked!" |
| 36 | }); |
warning
Proper lifecycle management prevents memory leaks and ensures your component behaves correctly across DOM mutations. The two critical callbacks are connectedCallback (set up listeners, fetch data) and disconnectedCallback (clean up timers, remove listeners).
| 1 | class LifecycleDemo extends HTMLElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | this._timer = null; |
| 5 | this._resizeHandler = this._onResize.bind(this); |
| 6 | this._intersectionObserver = null; |
| 7 | } |
| 8 | |
| 9 | connectedCallback() { |
| 10 | // Set up interval |
| 11 | this._timer = setInterval(() => { |
| 12 | this.textContent = new Date().toLocaleTimeString(); |
| 13 | }, 1000); |
| 14 | |
| 15 | // Listen for resize |
| 16 | window.addEventListener('resize', this._resizeHandler); |
| 17 | |
| 18 | // Observe intersection for lazy behavior |
| 19 | this._intersectionObserver = new IntersectionObserver( |
| 20 | (entries) => { |
| 21 | if (entries[0].isIntersecting) { |
| 22 | this.setAttribute('visible', ''); |
| 23 | } else { |
| 24 | this.removeAttribute('visible'); |
| 25 | } |
| 26 | } |
| 27 | ); |
| 28 | this._intersectionObserver.observe(this); |
| 29 | } |
| 30 | |
| 31 | disconnectedCallback() { |
| 32 | // Clean up EVERYTHING |
| 33 | clearInterval(this._timer); |
| 34 | window.removeEventListener('resize', this._resizeHandler); |
| 35 | this._intersectionObserver?.disconnect(); |
| 36 | this._intersectionObserver = null; |
| 37 | } |
| 38 | |
| 39 | _onResize() { |
| 40 | this.dispatchEvent(new CustomEvent('resized', { |
| 41 | detail: { width: window.innerWidth }, |
| 42 | bubbles: true, composed: true, |
| 43 | })); |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | customElements.define('lifecycle-demo', LifecycleDemo); |
best practice
One of Web Components' greatest strengths is framework interoperability. A component built with Web Components works in React, Vue, Angular, Svelte, or vanilla HTML without modification. However, each framework has specific considerations.
React
React passes all props as HTML attributes (not properties) by default. To pass complex data (objects, arrays), use ref to set properties directly on the DOM element. React's synthetic event system also requires composed: true on custom events.
| 1 | function App() { |
| 2 | const toggleRef = useRef(null); |
| 3 | |
| 4 | useEffect(() => { |
| 5 | // Set complex properties directly on the element |
| 6 | if (toggleRef.current) { |
| 7 | toggleRef.current.options = { theme: 'dark' }; |
| 8 | } |
| 9 | }, []); |
| 10 | |
| 11 | // React passes primitives as attributes |
| 12 | // Listen with addEventListener for custom events |
| 13 | useEffect(() => { |
| 14 | const el = toggleRef.current; |
| 15 | const handler = (e) => console.log(e.detail); |
| 16 | el?.addEventListener('toggle', handler); |
| 17 | return () => el?.removeEventListener('toggle', handler); |
| 18 | }, []); |
| 19 | |
| 20 | return ( |
| 21 | <div> |
| 22 | {/* Primitives work as attributes */} |
| 23 | <toggle-switch |
| 24 | ref={toggleRef} |
| 25 | checked |
| 26 | label="Enable feature" |
| 27 | /> |
| 28 | </div> |
| 29 | ); |
| 30 | } |
Vue
Vue has excellent Web Components support. Use v-bind for props and @ for events. Vue automatically detects custom elements if they contain a hyphen in the tag name. In Vue 3, set compilerOptions.isCustomElement in the config.
| 1 | <!-- Vue 3 template — works out of the box --> |
| 2 | <template> |
| 3 | <div> |
| 4 | <toggle-switch |
| 5 | :checked="isEnabled" |
| 6 | :label="labelText" |
| 7 | @toggle="onToggle" |
| 8 | /> |
| 9 | </div> |
| 10 | </template> |
| 11 | |
| 12 | <script setup> |
| 13 | import { ref } from 'vue'; |
| 14 | |
| 15 | const isEnabled = ref(true); |
| 16 | const labelText = ref('Enable notifications'); |
| 17 | |
| 18 | function onToggle(e) { |
| 19 | isEnabled.value = e.detail.checked; |
| 20 | } |
| 21 | </script> |
Angular
Angular works well with Web Components. Register custom elements in CUSTOM_ELEMENTS_SCHEMA and use standard Angular bindings. For inputs, use [prop] binding; for events, use (event).
| 1 | // app.module.ts |
| 2 | import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; |
| 3 | |
| 4 | @NgModule({ |
| 5 | schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 6 | // ... |
| 7 | }) |
| 8 | export class AppModule { } |
| 9 | |
| 10 | // app.component.html |
| 11 | <toggle-switch |
| 12 | [attr.checked]="isChecked" |
| 13 | [attr.label]="'Dark Mode'" |
| 14 | (toggle)="onToggle($event)" |
| 15 | ></toggle-switch> |
info
Web Components are an excellent foundation for design systems because they work across any framework. Companies like Google (Material Web Components), Microsoft (Fluent UI), ING (Lion), and Adobe (Spectrum) use Web Components for their cross-platform design systems.
Key considerations for design system components:
| 1 | // Design system button with theming support |
| 2 | class DSButton extends HTMLElement { |
| 3 | constructor() { |
| 4 | super(); |
| 5 | const shadow = this.attachShadow({ mode: 'open' }); |
| 6 | shadow.innerHTML = ` |
| 7 | <style> |
| 8 | :host { |
| 9 | --btn-bg: var(--ds-primary, #00FF41); |
| 10 | --btn-color: var(--ds-on-primary, #000000); |
| 11 | --btn-radius: var(--ds-radius, 6px); |
| 12 | --btn-font: var(--ds-font, system-ui); |
| 13 | } |
| 14 | button { |
| 15 | background: var(--btn-bg); |
| 16 | color: var(--btn-color); |
| 17 | border: none; |
| 18 | border-radius: var(--btn-radius); |
| 19 | font-family: var(--btn-font); |
| 20 | padding: 10px 20px; |
| 21 | font-size: 13px; |
| 22 | font-weight: 600; |
| 23 | cursor: pointer; |
| 24 | transition: opacity 0.15s; |
| 25 | } |
| 26 | button:hover { opacity: 0.85; } |
| 27 | button:focus-visible { |
| 28 | outline: 2px solid var(--btn-bg); |
| 29 | outline-offset: 2px; |
| 30 | } |
| 31 | :host([variant="outline"]) button { |
| 32 | background: transparent; |
| 33 | border: 2px solid var(--btn-bg); |
| 34 | color: var(--btn-bg); |
| 35 | } |
| 36 | :host([size="small"]) button { |
| 37 | padding: 6px 12px; |
| 38 | font-size: 11px; |
| 39 | } |
| 40 | </style> |
| 41 | <button part="button"><slot></slot></button> |
| 42 | `; |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | customElements.define('ds-button', DSButton); |
| 47 | |
| 48 | /* Consumer theming: |
| 49 | ds-button { --ds-primary: #4FC3F7; --ds-radius: 12px; } |
| 50 | ds-button::part(button) { text-transform: uppercase; } |
| 51 | */ |
best practice
All three Web Components technologies are supported natively in every modern browser. For legacy browser support (IE11 and older), lightweight polyfills are available.
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Custom Elements (v1) | 67+ | 63+ | 10.1+ | 79+ |
| Shadow DOM (v1) | 53+ | 63+ | 10.1+ | 79+ |
| <template> | 26+ | 22+ | 8+ | 13+ |
| <slot> | 53+ | 63+ | 10.1+ | 79+ |
For legacy browser support, use the @webcomponents/webcomponentsjs polyfill. It detects missing features and loads only the needed polyfills:
| 1 | <!-- Web Components polyfill for legacy browsers --> |
| 2 | <script |
| 3 | src="https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-loader.js" |
| 4 | defer |
| 5 | ></script> |
| 6 | |
| 7 | <!-- Or conditionally load polyfills --> |
| 8 | <script> |
| 9 | if (!('customElements' in window)) { |
| 10 | const script = document.createElement('script'); |
| 11 | script.src = 'https://unpkg.com/@webcomponents/webcomponentsjs@2/custom-elements-es5-adapter.js'; |
| 12 | document.head.appendChild(script); |
| 13 | } |
| 14 | if (!('attachShadow' in Element.prototype)) { |
| 15 | const script = document.createElement('script'); |
| 16 | script.src = 'https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-sd-ce.js'; |
| 17 | document.head.appendChild(script); |
| 18 | } |
| 19 | </script> |
note
Naming
Custom element names must contain a hyphen and should follow the pattern library-name-element-name. This prevents collisions with other libraries and future HTML elements. Examples: mwc-button, fluent-checkbox, lion-input.
Single Responsibility
Each component should do one thing well. A button component handles button behavior. A card component handles card layout. Compose small components together rather than building monolithic components. This improves testability, reusability, and maintainability.
Accessibility
Use native semantic elements inside your Shadow DOM whenever possible. A <button> inside Shadow DOM is still accessible to screen readers. Set aria-* attributes on the host element via this.setAttribute(). Use role attributes appropriately and manage focus for interactive components.
Performance
Use <template> for component markup rather than building DOM with innerHTML or createElement calls. Templates are parsed once and cloned efficiently. For lists, consider using connectedCallback for initial render and attributeChangedCallback for incremental updates. Avoid heavy work in constructor.
Testing
Web Components can be tested with standard DOM testing tools. Use document.createElement('my-element') to instantiate components in tests. Set attributes, dispatch events, and assert on the DOM. Tools like Web Test Runner, Playwright, and Cypress support Web Components natively.
Lit — The Recommended Base Library
While you can build Web Components with vanilla JavaScript, the Lit library from Google provides a reactive rendering system, declarative templates, and streamlined property management. It is the most popular Web Components library and is used in production by Adobe, IBM, Microsoft, and others. Lit components are still standard Web Components — they work in any framework.
pro tip