What Web Components Are and What They Are Not
Web components look like built-in elements, but they are not. They are a set of technologies that let us define what an element is and how it behaves, in the same way that "responsive web design" is not a single thing but a collection of strategies — fluid grids, flexible images, media queries — for adapting design to different contexts.
That collection has three parts:
- Custom elements. HTML elements the browser does not ship with. We invent them, and their names contain a letter and a dash.
- HTML templates. Reusable markup that generates more markup. We can keep it hidden until we use it.
- Shadow DOM. A fragment of the DOM where markup, scripts, and styles are encapsulated away from other DOM elements, including how to
<slot>content in.
A fourth ingredient, HTML Imports, has been nixed.
So the term is a bit of a misnomer: these are technologies rather than components in the React sense. A React component works somewhat like a partial — you drop an HTML snippet into your code and it outputs into the DOM. Web components are built on HTML elements and are not replaced at render time the way they are in JavaScript component frameworks. They are literally HTML elements and must obey HTML rules, which means we generate meaningful markup up-front instead of rendering it client-side after the fact. Provide the markup and enhance it. Web components have been around for a while even if we are only now talking about them.
Custom Elements
Custom elements are not built-in HTML elements; we instruct what they are and how they behave. Their names use a dash and must contain at least one letter. All of these are valid:
<super-component><a-><a-4-><card-10.0.1><card-♠️>
Some names are reserved for MathML and SVG elements, such as <font-face>. Custom elements also cannot be void elements — <my-element /> is invalid, so a closing tag is required.
Because they are undefined by default, custom elements are immediately useful without JavaScript: they can serve as containers with default properties, they are display: inline and inherit the current font-family (handy for passing styles to their contents), they can act as styling hooks since CSS can select them, and they can carry accessibility hints.
JavaScript changes the picture in a specific way. If a single <my-button> exists on the page, we can query it and attach a click handler. Any instances inserted later are not part of the original document rendering, so we have to query those when they are appended and re-run the function.
Registering an element
Defining and registering tells the browser this is an instance of the Custom Elements API and extends the same class that makes other HTML elements valid HTML elements:
Lifecycle callbacks
A component passes through several moments in its life: constructed (constructor), connected (connectedCallback), adopted (adoptedCallback), attribute changed (attributeChangedCallback), and disconnected (disconnectedCallback). Hooking into them is how we define behavior.
constructor()
A constructor is optional, but if we write one we must call super(), because we are extending another class and want its properties. It is useful for setting up initial state, registering default properties, adding event listeners, and creating Shadow DOM. It is not useful for much else: at that point we have merely defined the element, so we cannot tell whether it sits inside another element, since nothing is known about its parent container yet.
connectedCallback()
Timing here is strange — isConnected sometimes returns true during the constructor — so connectedCallback() is our best signal that the component is on the page. This is the moment it connects to the DOM, and it is where we attach event listeners.
If the <script> tag comes before the DOM is parsed, it may not recognize childNodes. That is not uncommon. Adding type="module" to the <script> defers the script and gives us the child nodes; setTimeout also works but looks a little gross.
disconnectedCallback
Useful when a component needs cleanup, such as stopping an animation or preventing memory leaks.
adoptedCallback()
This fires when another document or page adopts the component. Moving a custom element from a page into one of its iframes produces a full lifecycle: created, added, removed, adopted, added again. Adoption happens automatically just by picking the element up and dragging it between documents in the DOM.
Attributes are strings, not props
Unlike React, HTML attributes are strings. Global attributes work as expected, though some are reflected as properties, and you can reflect any attribute if you want — just be careful with naming to avoid conflicts. Avoid standard attributes on a custom element too, since that confuses whoever you hand the component to; type, for example, is already used by <input> elements, so data-type is a safer choice.
The basic pattern is to read a greeting attribute and set it on the element, and an attributeChangedCallback can print a changed attribute into the element's contents. Custom elements also support custom methods and events, and you can bring your own base class, the way frameworks like Lit do.
One practice prompt: build a custom element called <say-hi> that displays "Hi, World!" when added to the page, then extend it to accept a name attribute and display "Hi, [Name]!" instead.
HTML templates
The <template> element is for developers, not users; browsers do not expose it visibly. Templates hold HTML fragments inside a document fragment, so the inner document is a #document-fragment. A template can still be selected in CSS — it just does not render, which illustrates the point even if there is little reason to do it.
The content property is JavaScript, not CSS: we can query a template's inner contents and print them elsewhere. A document fragment can also be used without a <template> at all. If you insert a single node, the component only works once, so multiple instances require cloning it.
A more practical case: stub out a template for a list item and insert the clones into an unordered list. The other way to use templates, Shadow DOM, comes next.
Shadow DOM
The Shadow DOM gives you nodes in the DOM that are encapsulated from everything else. They exist, but regular CSS and JavaScript can't reach them without some finagling. That encapsulation cuts both ways: outside styles and scripts can't leak in, but you're responsible for defining everything inside and for figuring out how to reach it when you need to.

Declarative shadow roots
A <template> renders in the Shadow DOM without appearing on the page — a fragment of code, not a displayed element. It renders as a #shadow-root without the <template> tags themselves, which effectively marks the shadow boundary. Omit the shadowrootmode attribute and you're left with an unrendered template. Either way the paragraph lives in the DOM, encapsulated from other styles and scripts.
<template shadowrootmode="closed">
<p>This will render in the Shadow DOM.</p>
</template>

As with styling, there are times you'll want to pierce the Shadow DOM and allow some script access by opening up shadowrootmode. You can then query the div that contains the <template> and select the #shadow-root. That wrapper <div> is necessary: since the <template> never renders, there'd otherwise be nothing in the DOM to query.

<div>
<template shadowrootmode="open">
<p>This will render in the Shadow DOM.</p>
</template>
</div>
document.querySelector("div").shadowRoot
// #shadow-root (open)
// <p>This will render in the Shadow DOM.</p>
Shadow root behavior and slots
<!-- should this root stay with a parent clone? -->
<template shadowrootcloneable>
<!-- allow shadow to be serialized into a string object — can forget about this -->
<template shadowrootserializable>
<!-- click in element focuses first focusable element -->
<template shadowrootdelegatesfocus>
Adding a shadow root makes it the only rendered root in that shadow host; elements after the shadow root node simply don't render. If a DOM element contains multiple shadow root nodes, everything after the first becomes a template tag — the Shadow DOM effectively eats its siblings.
Slots bring them back, distributing all siblings through the slots so they're visible again.
<div>
<template shadowroot="closed">
<slot></slot>
<p>I'm a sibling of a shadow root, and I am visible.</p>
</template>
</div>
Imperative declaration
Templates are the declarative way to define the Shadow DOM; JavaScript can do it imperatively, with the same result. Setting the shadow mode works from JavaScript too, and doing it that way requires manually inserting <slot> elements.
<my-element>
<template shadowroot="open">
<p>This will render in the Shadow DOM.</p>
</template>
</my-element>
<script>
customElements.define('my-element', class extends HTMLElement {
constructor() {
super();
// attaches a shadow root node
this.attachShadow({mode: "open"});
// inserts a slot into the template
this.shadowRoot.innerHTML = '<slot></slot>';
}
});
</script>
<my-status>available</my-status>
<script>
customElements.define('my-status', class extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: "open"});
this.shadowRoot.innerHTML = '<p>This item is currently: <slot></slot></p>';
}
});
</script>

// open
this.attachShadow({mode: open});
// closed
this.attachShadow({mode: closed});
// cloneable
this.attachShadow({cloneable: true});
// delegateFocus
this.attachShadow({delegatesFocus: true});
// serialized
this.attachShadow({serializable: true});
// Manually assign an element to a slot
this.attachShadow({slotAssignment: "manual"});
<my-element>
<p>This WILL render in shadow DOM but not automatically.</p>
</my-element>
<script>
customElements.define('my-element', class extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open",
slotAssignment: "manual"
});
this.shadowRoot.innerHTML = '<slot></slot>';
}
connectedCallback(){
const slotElem = this.querySelector('p');
this.shadowRoot.querySelector('slot').assign(slotElem);
}
});
</script>
Working with slotted content
How do you get an array of element nodes in a slot, and how do you know when a slot's nodes changed?
this.shadowRoot.querySelector('slot')
.assignedElements();
// get an array of all nodes in a slot, text too
this.shadowRoot.querySelector('slot')
.assignedNodes();
let slot = document.querySelector('div')
.shadowRoot.querySelector("slot");
slot.addEventListener("slotchange", (e) => {
console.log(`Slot "${slot.name}" changed`);
// > Slot "saying" changed
})
Imperative Shadow DOM plays well with templates. Taking the reusable imperative shadow HTML approach moves the string out of your JavaScript; grabbing the component's name programmatically goes slightly further, preventing name collisions.
<my-status>available</my-status>
<script>
customElements.define('my-status', class extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: "open"});
this.shadowRoot.innerHTML = '<p>This item is currently: <slot></slot></p>';
}
});
</script>
<my-status>available</my-status>
<template id="my-status">
<p>This item is currently:
<slot></slot>
</p>
</template>
<script>
customElements.define('my-status', class extends HTMLElement {
constructor(){
super();
this.attachShadow({mode: 'open'});
const template = document.getElementById('my-status');
this.shadowRoot.append(template.content.cloneNode(true));
}
});
</script>
<my-status>available</my-status>
<template id="my-status">
<p>This item is currently:
<slot></slot>
</p>
</template>
<script>
customElements.define('my-status', class extends HTMLElement {
constructor(){
super();
this.attachShadow({mode: 'open'});
const template = document.getElementById( this.nodeName.toLowerCase() );
this.shadowRoot.append(template.content.cloneNode(true));
}
});
</script>
Forms and element internals
The short version: think hard before building custom form controls as web components. Native form controls come with a lot of features and functionality for free — accessibility included — and you'd have to recreate all of it.
Encapsulation causes oddities specifically with forms. Form submissions aren't automatically connected, so an input inside a web component won't have its value included in the submission. Form validation and states aren't communicated across the shadow boundary either, and the same connectivity problems affect accessibility: ARIA is interfered with, and IDs are local to the Shadow DOM.
<form>
<my-input>
<template shadowrootmode="open">
<label>
<slot></slot>
<input type="text" name="your-name">
</label>
</template>
Type your name!
</my-input>
<label><input type="checkbox" name="remember">Remember Me</label>
<button>Submit</button>
</form>
<script>
document.forms[0].addEventListener('input', function(){
let data = new FormData(this);
console.log(new URLSearchParams(data).toString());
});
</script>
Element internals is the escape hatch, though avoiding the problem altogether is the recommended path. Starting with an input value that needs to be included in the form submission, the demonstration adds a static formAssociated variable with internals attached, sets the form value on the internals when the input's value changes, and covers setting states — including toggling a boolean state on and off — then a refactor for ARIA improvements.
<form>
<my-input name="name"></my-input>
<button>Submit</button>
</form>
<script>
customElements.define('my-input', class extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>'
}
});
</script>
<script>
customElements.define('my-input', class extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>'
this.internals = this.attachedInternals();
}
});
</script>
<script>
customElements.define('my-input', class extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>'
this.internals = this.attachedInternals();
this.addEventListener('input', () => {
this-internals.setFormValue(this.shadowRoot.querySelector('input').value);
});
}
});
</script>
// add a checked state
this.internals.states.add("checked");
// remove a checked state
this.internals.states.delete("checked");
<form>
<my-check name="remember">Remember Me?</my-check>
</form>
<script>
customElements.define('my-check', class extends HTMLElement {
static formAssociated = true;
constructor(){
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = '<slot></slot>';
this.internals = this.attachInternals();
let addDelete = false;
this.addEventListener("click", ()=> {
addDelete = !addDelete;
this.internals.states[addDelete ? "add" : "delete"]("checked");
} );
}
});
</script>
<form>
<style>
my-check { display: inline-block; inline-size: 1em; block-size: 1em; background: #eee; }
my-check:state(checked)::before { content: "[x]"; }
</style>
<my-check name="remember" id="remember"></my-check><label for="remember">Remember Me?</label>
</form>
<script>
customElements.define('my-check', class extends HTMLElement {
static formAssociated = true;
constructor(){
super();
this.attachShadow({mode: 'open'});
this.internals = this.attachInternals();
this.internals.role = 'checkbox';
this.setAttribute('tabindex', '0');
let addDelete = false;
this.addEventListener("click", ()=> {
addDelete = !addDelete;
this.internals.states[addDelete ? "add" : "delete"]("checked");
this[addDelete ? "setAttribute" : "removeAttribute"]("aria-checked", true);
});
}
});
</script>

That's a substantial amount of work for something closer to a functional, accessible custom form input — and still a long way from what native controls provide out of the box. A light DOM form is worth questioning first.
Styles can reach a custom element from several directions, and which ones win depends on where the boundary sits. A plain light-DOM rule on the tag name needs no JavaScript, and it is not encapsulated at all — it scopes to a single element like any other CSS. Switching a shadow root from closed to open does not change any of this; open mode only lets JavaScript pierce the shadow boundary, not CSS.
Inheritance slips through
Document styles stop at the shadow root. A rule like p { color: red } colors the light-DOM paragraphs but not one rendered from inside a <template>, whether the root is open or closed. Styles declared inside that template stay inside it too, never leaking back out, even when they come later in the cascade.
Set the color on <body> instead and everything turns red, template paragraph included. This is expected: inheritable properties pass through the shadow barrier. Inherited styles come from the computed values of a parent, and color is one of many inheritable properties, so every child — custom elements included — picks them up.
You can answer that from inside the template, targeting the paragraph in its own style block to override what the body handed down, without leaking anything back to the other paragraphs. It works, but it is fragile: a new property or role introduced later may carry inherited styles you never thought to reset. Resetting with all: initial is one defensive option; adding more elements to the component keeps the fight going.
:host and specificity
Scoping rules to the shadow root's :host selector protects them, until light-DOM styles aim at the universal selector. Shadow DOM styles are applied before light-DOM styles, so * lands after :host and overrides it — an escalation in specificity. Scott's position is that !important is one of the few tools available for protecting a component from outside styles, and that this is a legitimate use despite the keyword's reputation. It does not affect styles outside the element in any case.
<style>
* {
color: red;
font-family: fantasy;
font-size: 2em;
}
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style>
/* reset the light dom styles */
:host { all: initial; !important }
</style>
<p>Hi</p>
<a href="#">Click me</a>
</template>
</div>
<p>Hi</p>
:host() is a function as well as a pseudo-selector: pass it the <div> that contains the template, and that becomes the scoping context for the whole selector, removing the need for !important.
:host-context() targets the shadow host only when the selector you supply is a parent anywhere up the tree. That is useful when a component's layout context changes, say from an <article> to a <header>.
:defined selects the element at the moment it is created. It is most useful for elements defined imperatively in JavaScript, allowing styles to be applied as soon as the element is constructed. It also guards against a flash of unstyled element for components that are meaningless until JavaScript generates their content — though it is best reserved for elements that are empty and not yet defined; anything meaningful up-front should be styled up-front.
Slots, parts, states
Shadow DOM CSS cannot style slotted content directly. A rule inside the template for p does not turn a slotted paragraph green — it would only apply to a paragraph rendered in the template, not to one projected into it. Slots belong to the light DOM, so styling them from the document works, which makes slots a good route for progressive style enhancement.
The ::slotted() pseudo-element is also a function taking an element or class, selecting elements from within the shadow root. It is weak against global selectors, so an outside inheritable style can still break it. That is another place where !important could make sense — it wins even when the global style is also !important. For a more defensive posture, pass the universal selector to ::slotted() and reset everything to its initial value, encapsulating all slotted content from outside styles.
Parts are the deliberate exposure route: a part attribute on a shadow element makes it reachable from the parent document, and without it no style can reach that paragraph. This works like a styling API, declaring what may and may not be styled. Note that ::part cannot be used inside a complex selector such as a descendant selector. Parts can also be exported, nesting them within nested elements.
States and validity have their own hooks: the :state pseudo-function takes states you defined through element internals, and the :invalid pseudo-class is available as well.
Custom properties, stylesheets, containers
Custom properties cross the shadow barrier, which makes them a clean channel for outside control over internals.
For shared styles, the classic external <link> is one option. Repeating the same link at the top of every component looks anti-DRY, and it is repetitive to write, but the sheet is fetched once and available everywhere with no further requests — so the repetition costs nothing at runtime. CSS @import works too, as does a JavaScript approach.
With modules you can import CSS into a string and adopt it on the shadow root via shadowRoot.adoptedStyleSheets. Adopted stylesheets are dynamic: construct one, share it across instances, and update it through the CSSOM to have the changes ripple to every component that adopted it.
Container queries pair naturally with custom elements, since a component can declare itself a container via :host and then respond to its own size. You can define the container and protective scoped styles on :host, then add a query that changes the layout of an unordered list once the element is at least 50em wide.
Patterns from the wild
The counter is usually the first thing a React tutorial teaches, and it works as a web component too:
<counter-element></counter-element>
<script type="module">
customElements.define('counter-element', class extends HTMLElement {
#count = 0;
connectedCallback() {
this.innerHTML = `<button id="dec">-</button><p id="count">${this.#count}</p><button id="inc">+</button>`;
this.addEventListener('click', e => this.update(e) );
}
update(e) {
if( e.target.nodeName !== 'BUTTON' ) { return }
this.#count = e.target.id === 'inc' ? this.#count + 1 : this.#count - 1;
this.querySelector('#count').textContent = this.#count;
}
});
</script>
Small libraries, light DOM diffing
Reef, by Chris Ferdinandi, is 2.6KB minified and zipped and provides DOM diffing for state-based reactive UIs — the job React does at a far larger size.
<div id="greeting"></div>
<script type="module">
import {signal, component} from '.../reef.es..min.js';
// Create a signal
let data = signal({
greeting: 'Hello',
name: 'World'
});
component('#greeting', () => `<p>${data.greeting}, ${data.name}!</p>`);
</script>
A "signal" is a live-update object; component() selects the update target and injects a template literal carrying the variables and markup. Values can then be changed on a timer:
<div id="greeting"></div>
<script type="module">
import {signal, component} from '.../reef.es..min.js';
// Create a signal
let data = signal({
greeting: 'Hello',
name: 'World'
});
component('#greeting', () => `<p>${data.greeting}, ${data.name}!</p>`);
setTimeout(() => {
data.greeting = '¡Hola'
data,name = 'Scott'
}, 3000)
</script>
Reef also pairs with a custom element. The data can live outside the component, standing in for application state:
<my-greeting></my-greeting>
<script type="module">
import {signal, component} from 'https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.es.min.js';
window.data = signal({
greeting: 'Hi',
name: 'Scott'
});
customElements.define('my-greeting', class extends HTMLElement {
connectedCallback(){
component(this, () => `<p>${data.greeting}, ${data.name}!</p>` );
}
});
</script>
That is a virtual DOM inside a web component. A second approach watches attribute changes and pushes them into application state, which in turn updates the greeting:
<my-greeting greeting="Hi" name="Scott"></my-greeting>
<script type="module">
import {signal, component} from 'https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.es.min.js';
customElements.define('my-greeting', class extends HTMLElement {
static observedAttributes = ["name", "greeting"];
constructor(){
super();
this.data = signal({
greeting: '',
name: ''
});
}
attributeChangedCallback(name, oldValue, newValue) {
this.data[name] = newValue;
}
connectedCallback(){
component(this, () => `<p>${this.data.greeting}, ${this.data.name}!</p>` );
}
});
</script>
Attribute changes are per-instance, the data is registered when the component is constructed, and only string attributes change — not objects with properties.
HTML web components
Not every custom element should be empty until JavaScript arrives:
<my-greeting></my-greeting>
That React-style element holds no markup of its own. Web components are useful without JavaScript, and Jeremy Keith's 2023 article named the alternative:
[…] we could call them “HTML web components.” If your custom element is empty, it’s not an HTML web component. But if you’re using a custom element to extend existing markup, that’s an HTML web component.
Keith cites Robin Rendle on the same distinction:
[…] I’ve started to come around and see Web Components as filling in the blanks of what we can do with hypertext: they’re really just small, reusable chunks of code that extends the language of HTML.
The React approach looks like HTML but isn't:
<UserAvatar
src="https://example.com/path/to/img.jpg"
alt="..."
/>
Those props merely feed information used to swap <UserAvatar /> for JavaScript-generated markup. A web component can do the same:
<user-avatar
src="https://example.com/path/to/img.jpg"
alt="..."
></user-avatar>
Only here the markup is real, and progressive enhancement drives the HTML web component mindset:
class UserAvatar extends HTMLElement {
connectedCallback() {
const src = this.getAttribute("src");
const name = this.getAttribute("name");
this.innerHTML = `
<div>
<img src="${src}" alt="Profile photo of ${name}" width="32" height="32" />
<!-- Markup for the tooltip -->
</div>
`;
}
}
customElements.define('user-avatar', UserAvatar);
Better still, put the <img> in the component itself so the markup exists immediately:
<user-avatar>
<img src="https://example.com/path/to/img.jpg" alt="..." />
</user-avatar>
The image is then downloaded before JavaScript loads. Augment rather than replace.
Two tools worth knowing
resizeasaurus
For testing responsive component layouts, container queries in particular.
<resize-asaurus>
Drop any HTML in here to test.
</resize-asaurus>
<!-- for example: -->
<resize-asaurus>
<div class="my-responsive-grid">
<div>Cell 1</div> <div>Cell 2</div> <div>Cell 3</div> <!-- ... -->
</div>
</resize-asaurus>

lite-youtube-embed
YouTube embedding without the weight of the usual embed snippet.
<lite-youtube videoid="ogYfd705cRs" style="background-image: url(...);">
<a href="https://youtube.com/watch?v=ogYfd705cRs" class="lyt-playbtn" title="Play Video">
<span class="lyt-visually-hidden">Play Video: Keynote (Google I/O '18)</span>
</a>
</lite-youtube>
<link rel="stylesheet" href="./src.lite-yt-embed.css" />
<script src="./src.lite-yt-embed.js" defer></script>
Its starting point is a link, which survives a failed video load. Once the script runs, an <iframe> is added to the markup.
Frameworks
Lit
Lit extends the base class and what that class provides while still sitting directly on web components, adding syntax shortcuts and structure in roughly 5–7KB:
- Fast templating
- Reactive properties
- Reactive update lifecycle
- Scoped styles
<simple-greeting name="Geoff"></simple-greeting>
<script>
import {html, css, LitElement} from 'lit';
export class SimpleGreeting extends LitElement {
state styles = css`p { color: blue }`;
static properties = {
name: {type = String},
};
constructor() {
super();
this.name = 'Somebody';
}
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
customElements.define('simple-greeting', SimpleGreeting);
</script>
| Pros | Cons |
|---|---|
| Ecosystem | No official SSR story (but that is changing) |
| Community | |
| Familiar ergonomics | |
| Lightweight | |
| Industry-proven |
webc
Part of the 11ty project, it lets custom elements be defined as files written as single-file components.
<!-- starting element / index.html -->
<my-element></my-element>
<!-- ../components/my-element.webc -->
<p>This is inside the element</p>
<style>
/* etc. */
</style>
<script>
// etc.
</script>
| Pros | Cons |
|---|---|
| Community | Geared toward SSG |
| SSG progressive enhancement | Still in early stages |
| Single file component syntax | |
| Zach Leatherman! |
Enhance
Renders web components on the server, driven by per-request application state — custom elements on the server side.
| Pros | Cons |
|---|---|
| Ergonomics | Still in early stages |
| Progressive enhancement | |
| Single file component syntax | |
| Full-stack stateful, dynamic SSR components |
Third-party component libraries
A few notable third-party libraries, all closer in spirit to React: custom elements behaving like replaced elements with little meaningful up-front markup. That is not a criticism so much as a reminder that requiring JavaScript for meaningful content carries a cost.
Spectrum
<sp-button variant="accent" href="components/button">
Use Spectrum Web Component buttons
</sp-button>
- Adobe's design system.
- One of the more ambitious projects, with support for frameworks such as React.
- Open source.
- Built on Lit.
Most components are not HTML-first; the pattern is closer to replaced elements. The complexity fits a system powering an application like Photoshop that must drop into any project, but delivering meaningful content up front still costs something, and all-or-nothing may be too stark for a small site.
FAST
<fast-checkbox>Checkbox</fast-checkbox>
- Microsoft's system.
- Philosophically like Spectrum: very little meaningful HTML up front.
- Fluent extends the system for UI components.
- Microsoft Edge rebuilt the browser's Chrome with these components.
Shoelace
<sl-button>Click Me</sl-button>
- Built purely for third-party developers.
- The name plays on Bootstrap.
- Markup is mostly a custom element containing some text, rather than HTML-first.
- Acquired by Font Awesome, which is creating Web Awesome Components as a subscription-based new era of Shoelace.
What's coming
Declarative custom elements
Define an element in HTML alone and reuse it with simpler syntax. See the GitHub issue and Zach Leatherman's write-up.
Cross-root ARIA
Easier pairing of custom elements with Light DOM elements and with each other through ARIA. See the GitHub explainer and the GitHub proposal.
Container queries
Using container queries without an extra wrapper around the custom element.
HTML modules
Once a core web components feature, later removed. They define HTML externally for repeated use. See the GitHub explainer.
External styling
Also called "open styling". See the GitHub explainer.
DOM Parts
A templating feature enabling JSX-string-literal-like syntax in which variables inject data:
<section>
<h1 id="name">{name}</h1>
Email: <a id="link" href="mailto:{email}">{email}</a>
</section>
Producing a template with this content:
<template>
<section>
<h1 id="name">{{}}</h1>
Email: <a id="link" href="{{}}">{{}}</a>
</section>
</template>
See the GitHub proposal.
Scoped element registries
Variations of the same web component without name collisions. See the GitHub issue.



