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.

<iframe>

Full (separate document)

Cannot dynamically resize to fit its content, and a <form> inside the iframe navigates the iframe, not the parent page, when submitted.

Web component with Shadow DOM

Style encapsulation, same document

Requires JavaScript to register the element (see [Building a custom element]); the :root leak below.

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. Use mode: "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 .shadowRoot property 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.define registers 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. Prefer mousedown/mouseup over click where the exact moment of press or release matters — a user can depress a button, drag off it, and release elsewhere, which registers as a mousedown with no matching click.
  • Keyboard input: keydown and keyup for detecting which key was pressed — including control keys such as arrows and Escape that produce no printable character — and the input event, 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 use keydown/keyup to detect text input: an input event fires for input methods that never raise a keyboard event.
  • Form elements: input (fires on every value change) and change (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