TS-37: Web platform APIs
This technical standard covers native browser APIs for building portable,
framework-independent components — Custom Elements, Shadow DOM, and the
<template>/<slot> family, collectively known as web components. It
covers when web components are the right tool, the Shadow DOM encapsulation
trade-off, how to build a custom element, and the platform’s known
limitations.
This standard does not cover CSS layout and typography techniques (fluid type, container queries, intrinsic layouts) — see TS-18: Web GUIs for those. For the design of URLs, see TS-63: URL design.
When to use web components
Web components — Custom Elements, Shadow DOM, and <template>/<slot> — are
native browser APIs for defining new, reusable HTML elements. Unlike a
component in a JavaScript framework, a web component is portable: it works
without a build step, runs on any page regardless of the framework (or
absence of one) that page otherwise uses, and its behavior travels with the
markup rather than living in a separate render tree.
This portability makes web components a good fit for specific situations rather than a general application-building tool:
- Portable, embeddable widgets. A widget that must render correctly on any third-party site, in any position, at any viewport, with any surrounding content — for example, an embeddable signup form or support-chat launcher — is the strongest case for a web component. See [Choosing an encapsulation strategy] for how this compares to the alternatives.
- Leaf nodes and presentational wrappers. Small, self-contained pieces of
UI with no internal routing or global state — a tooltip, a tab panel, a
rating widget — are well suited to custom elements, including ones that use
<slot>to wrap content supplied by the page. - Design systems. A design system’s components are consumed by teams on different stacks, sometimes including teams outside the organization’s control (after an acquisition, or across departments with independent front-end choices). Web components deliver one implementation that works regardless of the consuming stack.
- Progressive enhancement of existing HTML. A custom element can wrap regular, already-meaningful HTML and layer behavior onto it, rather than replacing it. See HTML web components vs. JavaScript web components.
- Buildless and low-maintenance projects. Because no compiler or bundler is required, web components suit one-off projects, prototypes, and any project where minimizing the dependency and tooling surface is a priority.
- Distributing framework-agnostic demos or patterns. Packaging an accessibility pattern, an animation, or a CSS technique as a web component makes it usable without the recipient adopting the packaging team’s framework.
Web components are a poor fit for two related things: replacing a full-page application framework, and encapsulating what should be plain HTML. See Limitations for why a web-component router is NOT RECOMMENDED, and HTML web components vs. JavaScript web components for why "add a custom element" is not the default answer to every component-shaped problem.
Tip
Not everything needs to be a web component. A design system built entirely from web components has a real cost — see Shadow DOM — and most in-application UI is better served by the application’s existing framework or by plain HTML plus augmentation. Reach for a web component when portability across stacks, or resilience to a missing script, is a requirement — not by default.
HTML web components vs. JavaScript web components
Custom elements support two fundamentally different mindsets, and this standard RECOMMENDS the first over the second as the default.
HTML web components wrap markup that is already meaningful without JavaScript, and use the custom element only to add behavior:
<user-avatar> <img src="<url>" alt="<name>" /> </user-avatar>
If the component’s script never loads, the <img> still renders. The custom
element is augmentation: it enhances HTML that already does useful work on
its own.
JavaScript web components are empty shells that depend entirely on JavaScript to produce any content:
<user-avatar src="<url>" alt="<name>"></user-avatar>
If the component’s script never loads, this element renders nothing. This is the same "replace, don’t augment" mindset that a component framework such as React encourages — a component as an opaque box that receives props and does all the rendering work itself, reinventing what the browser could otherwise render on its own. That mindset is a reasonable default inside a JavaScript framework, but it discards web components' main advantage over framework components: the ability to render before, or without, JavaScript.
This standard RECOMMENDS HTML web components as the default pattern. Custom elements MUST wrap and enhance existing semantic HTML wherever the underlying content can be expressed in HTML at all; a JavaScript web component SHOULD be reserved for functionality that has no meaningful HTML-only representation.
Two examples illustrate the HTML web component pattern:
- A
<super-slider>element wraps a standard<label>and<input type="range">, and adds behavior — for example, a live readout of the current value — with regular CSS for styling and no Shadow DOM at all. The slider works, and is stylable and accessible via ordinary means, before any JavaScript runs. - An
<icon-list>element wraps a plain<ul>of<li>elements and attaches purely presentational or interactive behavior — for example, swapping bullet markers for icons — again with no Shadow DOM, no<template>, and no<slot>. The list is a list, with or without the custom element’s script.
Neither example needs Shadow DOM to be a legitimate web component. Shadow DOM is one capability that custom elements MAY use, not a requirement for something to count as a web component — see [Choosing an encapsulation strategy] for when it earns its cost.
Note
Augmentation over replacement is not unique to web components — it is a
recurring pattern in how the web platform absorbs good ideas from userland.
XHTML attempted to replace HTML and lost to HTML5, which extended it
instead. XMLHttpRequest was augmented, not replaced, by the fetch API.
Patterns pioneered by libraries such as Sass and jQuery were absorbed into
native CSS and the DOM. Web components are the platform’s absorption of the
component model popularized by JavaScript frameworks — augmenting HTML
rather than requiring a wholesale replacement of it. Preferring augmentation
in how a team uses web components is consistent with that platform grain,
and tends to age better as the platform continues to absorb framework
ideas.
Shadow DOM
Shadow DOM attaches an encapsulated DOM subtree to an element, with its own style scope: styles defined inside the shadow root do not leak out to the page, and (with rare exceptions, such as inherited properties) page styles do not leak in. It is a powerful tool, but it is also the biggest source of web components' complexity and rough edges, so this standard treats it as something to reach for deliberately rather than by default.
Shadow DOM as a last resort
Prefer Light DOM — regular, unencapsulated markup — for as long as it solves the problem. Attach a shadow root only once encapsulation is solving a real problem: a genuine style-collision risk, or a component that MUST be portable into pages whose styles cannot be predicted or controlled. Most in-application components, including the HTML web components described in HTML web components vs. JavaScript web components, do not need it.
Important
Shadow DOM has a real learning curve, and teams routinely hit "gotcha"
moments the first time they use it in earnest — for example, the :root
leak described below, or discovering that a global stylesheet no longer
reaches into a component. Budget time for the team to get past that curve
before adopting Shadow DOM on a deadline-sensitive project.
Choosing an encapsulation strategy
Before attaching a shadow root, weigh it against the two other ways to achieve style isolation for a portable, embeddable widget — a signup form or a chat launcher that MUST render correctly on an arbitrary third-party page:
Strategy | Isolation | Key limitation |
|---|---|---|
Script-injected HTML | None | Page CSS can leak into the widget, and the widget’s CSS can leak into the page, in both directions. |
| Full (separate document) | Cannot dynamically resize to fit its content, and a |
Web component with Shadow DOM | Style encapsulation, same document | Requires JavaScript to register the element (see [Building a custom
element]); the |
A web component with Shadow DOM is usually the right choice for this
specific problem: it is portable like ordinary HTML, and the shadow root
prevents the page’s styles from leaking in and the component’s styles from
leaking out, without the resizing and form-submission problems that come
with an <iframe>.
The :root leak
A shadow root does not establish its own :root element — :root always
refers to the host document’s root. Any rem unit used inside a shadow
root’s stylesheet therefore resolves against the host page’s root
font-size, not against any local baseline the component defines. A page that
changes its root font-size — deliberately, or via a user’s browser
zoom/accessibility settings — will scale an embedded component’s rem-based
sizing along with it, which is not always the intended effect for a
supposedly self-contained widget.
Where a component’s internal sizing MUST stay independent of the host page,
use a local baseline instead of rem — for example, set a font-size on the
shadow root’s own top-level element and size internally with em, or use
absolute units where scaling truly should not follow the host page.
Cross-root accessibility
Associating a <label> in the document with an <input> inside a shadow
root — or, more generally, any ARIA relationship (aria-labelledby,
aria-describedby, aria-controls) that needs to reach across a shadow
boundary — has historically not worked, because the id references those
attributes rely on do not resolve across shadow roots. This is a
long-standing accessibility gap in Shadow DOM, and it MUST be accounted for
when a component’s shadow root contains form controls or other elements that
need to be described or labeled from outside it.
The referencetarget attribute is rolling out in Chromium to address this
gap directly, letting a cross-root ARIA reference resolve into a shadow
root’s internals. Until it has broad support, treat cross-root ARIA
relationships as a known risk: test with a screen reader, and prefer keeping
a shadow root’s own labeling self-contained (label the control from inside
the same shadow root) over relying on a reference that crosses the boundary.
Building a custom element
A custom element requires no build system. At minimum, it is a class
extending HTMLElement, registered with customElements.define:
class MyWidget extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>/* scoped styles */</style>
<p>Widget content</p>
`;
}
}
customElements.define("my-widget", MyWidget);attachShadow({ mode: "open" })creates the shadow root. Usemode: "open"unless there is a specific reason to hide the shadow tree from the page’s own JavaScript (mode: "closed"), which mainly complicates debugging and testing for little real security benefit — a "closed" shadow root does not stop determined inspection, it only removes the element’s.shadowRootproperty from ordinary script access.- Do the attachment and initial render in
connectedCallback, which fires each time the element is inserted into the document, rather than in the constructor. This keeps the component correct if it is disconnected and reconnected, and avoids doing DOM work before the element is guaranteed to be in a document. customElements.defineregisters the tag name globally. Custom element names MUST contain a hyphen, per the platform’s own requirement — this both disambiguates them from any future native element name and is what tells the browser to treat the tag as a custom element rather than an unknown one.
HTML’s fault tolerance is progressive enhancement
HTML is fault-tolerant by design: a browser renders any recognized markup it finds, and silently ignores tags it does not understand. This has a useful consequence for HTML web components (see [HTML web components vs. JavaScript web components]): any HTML placed between a custom element’s opening and closing tags renders automatically, exactly as written, if the component’s script never loads.
<user-avatar> <img src="<url>" alt="<name>" /> </user-avatar>
If user-avatar’s script fails to load — a CDN outage, a slow network, an
ad-blocker false positive — the browser still renders the `<img>, because it
was always valid HTML the browser understood, independent of any custom
element definition. This is progressive enhancement for free: it costs
nothing beyond choosing to wrap real content rather than an empty shell.
Custom elements that follow the JavaScript web component pattern do not get
this benefit, because there is no fallback content for the browser to fall
back to.
Limitations
Web components solve specific problems well, but they carry real rough edges that MUST be weighed before adopting them for a given use case.
Server-side rendering. A custom element’s shadow root CAN be server
rendered using Declarative Shadow DOM (a <template shadowrootmode="open">
inside the host element, which the browser parses directly into a shadow
root without waiting for JavaScript). In practice, published guidance on
doing this well is sparse, and the implementations that exist tend to be
either kludgy or specific to one framework’s own conventions rather than
generally applicable. Enhance is a notable exception — a framework that
supports Declarative Shadow DOM SSR as a first-class, out-of-the-box
capability rather than something a team has to assemble itself. Where
SSR is a requirement, evaluate whether an existing framework’s support for
Declarative Shadow DOM is mature enough for the target use case before
committing to a hand-rolled implementation.
Page-level routing. Web components SHOULD NOT be used as the page-level abstraction for an application, driven by a web-component-based router. Server-generated HTML, with full page navigations or a thin progressive-enhancement layer on top, remains the more resilient abstraction for what a "page" is. Reserve web components for the component level — see When to use web components — not for owning routing or top-level application state.
Cross-root accessibility. See "Cross-root accessibility" in Shadow DOM for the ARIA-reference limitation and its emerging fix.
Building framework compilers. Some JavaScript frameworks compile their own component syntax down to native custom elements, so that framework components can be consumed as plain web components elsewhere. Building and maintaining that kind of compiler is a substantial, ongoing undertaking, and is an active area of friction in the standards community rather than a solved problem. Do not take on building a framework-to-web-component compiler as an incidental part of adopting web components; treat it as its own significant project if it is genuinely needed.
DOM events and HTTP requests
This section covers two families of native browser API that sit underneath GUI-level component behavior: the DOM event-propagation model, and the APIs for making an HTTP request without a full page navigation. For where these APIs fit into a component behavior’s own conventions — which elements to bind them to, which hook attribute to use — see TS-18: Web GUIs, "DOM interaction conventions".
Event propagation
An event dispatched on a DOM element travels through the document tree in two phases, standardized by the DOM Events specification:
- Capturing. The event starts at the document root and travels down towards the target element.
- Bubbling. Once the event reaches its target, it travels back up through every ancestor element.
addEventListener MUST attach to the bubbling phase by default (its third
argument, useCapture, defaults to false); only set it to true to
observe the capturing phase, which is rarely needed. Bubbling is what makes
event delegation possible: a single listener on a common ancestor can
observe events dispatched on any of its descendants, using the event’s
target property to determine which descendant actually triggered it. See
the event-delegation guidance in
TS-18: Web GUIs, "Reflows, repaints, and layout thrashing".
A handler MUST call stopPropagation() deliberately, not as a default
habit, since it prevents any ancestor’s delegated listener from seeing the
event at all.
Choosing an event type
Prefer the following widely-supported event types; a "DOM"-prefixed event
(DOMAttrModified, DOMNodeInserted) is legacy and MUST NOT be used —
observe DOM mutations with MutationObserver instead (see
Observing DOM mutations).
- Pointer input:
click,mousedown,mouseup,mousemove. Prefermousedown/mouseupoverclickwhere the exact moment of press or release matters — a user can depress a button, drag off it, and release elsewhere, which registers as amousedownwith no matchingclick. - Keyboard input:
keydownandkeyupfor detecting which key was pressed — including control keys such as arrows andEscapethat produce no printable character — and theinputevent, on the element itself, for detecting a change to a text value regardless of how it was produced (typing, paste, or voice input). Do not usekeydown/keyupto detect text input: aninputevent fires for input methods that never raise a keyboard event. - Form elements:
input(fires on every value change) andchange(fires when a value change is committed — on blur for a text field, or immediately for a checkbox, radio, or<select>).
KeyboardEvent.key (a string such as "a", "Enter", or "ArrowLeft") is
the current standard for identifying which key produced an event. The older
keyCode, charCode, and which numeric properties are deprecated and
MUST NOT be used in new code.
Observing DOM mutations
MutationObserver observes changes to a DOM subtree — added or removed
nodes, attribute changes, text-content changes — and invokes a callback with
a batch of the changes, rather than firing once per individual change. It is
the standard replacement for the deprecated MutationEvents API, and is the
correct tool for reacting to DOM changes a component behavior did not itself
make (for example, a third-party script inserting content into the page).
fetch
fetch is the standard API for making an HTTP request from client-side
JavaScript. It MUST be preferred over XMLHttpRequest in new code —
XMLHttpRequest remains supported for legacy compatibility only.
const response = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: "widget", quantity: 2 }),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const data = await response.json();fetch only rejects its returned promise on a network failure (the request
could not be sent, or no response was received at all); an HTTP error
status such as 404 or 500 still resolves successfully. Every call site
MUST check response.ok (or response.status) explicitly before treating
the response as successful.
Restrict client-side HTTP requests to fetching and submitting data, not markup. A request MUST NOT return a partial HTML view to be spliced into the page — all of the markup a page can ever need SHOULD already be present in the document from the initial server render, per the progressive enhancement model described in TS-18: Web GUIs, "Performance optimization".
Use the FormData interface, constructed directly from an existing
<form> element, to submit form data (including file uploads) via fetch
without hand-assembling a request body:
async function submitForm(form) {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
});
return response.ok;
}CORS
A fetch request to a different origin (a different scheme, host, or port)
is subject to Cross-Origin Resource Sharing (CORS). The browser only exposes
the response to the requesting script if the server explicitly allows it, by
returning an Access-Control-Allow-Origin response header naming either the
requesting origin or for any origin. Requests carrying credentials
(cookies, HTTP authentication) MUST have credentials enabled explicitly on
the request (credentials: "include") and the server MUST echo back the
exact requesting origin, rather than , in Access-Control-Allow-Origin —
the wildcard is not permitted alongside credentialed requests.
A request that uses a non-simple method (anything other than GET, HEAD,
or POST with a simple content type) or a custom header triggers a
browser-issued CORS preflight: an automatic OPTIONS request the server
must also answer correctly, independent of the actual request that follows
it. This is browser-enforced, automatic behavior — it requires no
application code, only correct server-side CORS headers.
Where a client-side request targets an origin outside the application’s own control, treat the possibility of the remote server withdrawing or restricting CORS support as an ordinary failure mode to handle, not an edge case.
References
- Smudford, T (2024). Hyper-Responsive Web Components.
— The source for the portable-embeddable-widget motivation and the
script-injection/iframe/Shadow-DOM comparison in [When to use web
components] and Choosing an encapsulation strategy, the custom-element
build pattern in Building a custom element, and the
:rootremleak in Shadow DOM. - Rupert, D (2024). Super Web Components, Sunshine!. — The source for the "good parts" catalog in [When to use web components] and the SSR/Declarative-Shadow-DOM, page-routing, and cross-root-ARIA limitations in Limitations and Shadow DOM.
- Keith, J (2024). HTML Web Components.
— The source for the HTML-vs-JavaScript web component distinction, the
super-sliderandicon-listexamples, and the augmentation-over-replacement argument in HTML web components vs. JavaScript web components and Shadow DOM. - WHATWG. DOM Standard and
Fetch Standard. — The source for the
event-propagation model,
fetch, and CORS mechanics in DOM events and HTTP requests.