# Web components

## What it is

Web components, also written Web Components with capitals, is the usual name for
a group of browser features that let authors define their own HTML elements. A
page then uses such an element like any other tag. The standards speak of the
parts by their own names: custom elements and the `template` and `slot` elements
in the HTML Standard, and shadow trees in the DOM Standard. The collective name
is the title of a W3C Working Group Note of 24 July 2014,
[_Introduction to Web Components_](https://www.w3.org/TR/components-intro/),
which the W3C has since discontinued and marked as a document that should not be
referenced.

A custom element is defined by a JavaScript class that extends `HTMLElement` and
is registered under a name with `customElements.define()`. The definition tells
the HTML parser how to construct the element and how it should react to changes,
and from then on the element can be created from markup, with
`document.createElement()` or with `new`. Its name starts with a lower-case
ASCII letter, contains no upper-case ASCII letters and must contain a hyphen.
The hyphen keeps custom names apart from the language itself: the HTML Standard
says that HTML, SVG and MathML will not gain any element whose name contains a
hyphen.

There are two kinds. An autonomous custom element, such as the standard's own
`<flag-icon country="nl">`, starts from a plain element with no built-in
meaning. A customized built-in element extends an existing one: a class that
extends `HTMLButtonElement`, registered with `{ extends: "button" }` and used as
`<button is="plastic-button">`, keeps everything that makes a button a button,
including focus behaviour, form submission and the `disabled` attribute.

A shadow tree is a separate tree of nodes attached to an element, which is
called its host; the DOM Standard calls the host's ordinary tree the light tree.
`attachShadow()` creates a shadow root in open or closed mode, and only in open
mode does the host's `shadowRoot` property return it. A `slot` element in the
shadow tree marks where the host's own children appear. It shows the nodes
assigned to it, or its own contents when there are none, and a named slot takes
the children whose `slot` attribute carries its name. The `template` element
holds a fragment of HTML that is not rendered and that a script can clone. With
a `shadowrootmode` attribute, a `template` represents a declarative shadow root,
so a shadow tree can also be written straight into the markup.

## Why it matters

A made-up tag used to be of little use: the HTML Standard looks back on such
elements as invalid and of little practical use. A custom element is a full
member of the document. The parser constructs it, the DOM methods create it, and
its class decides what happens when its attributes change or when it enters or
leaves a page.

The definition does not have to arrive first. An element the parser has already
created becomes a custom element once a definition for its name is registered,
and the HTML Standard calls this upgrading. Until then the element is treated as
an undefined element, much like a `span`, so the text and links inside it are
shown as they are. The standard says in so many words that upgrades allow the
content of a custom element to be progressively enhanced.

A custom element can also take part in forms and in the accessibility tree. A
class that sets `static formAssociated = true` and calls `attachInternals()`
gets an `ElementInternals` object, and `setFormValue()` on that object gives the
element a value that its form submits like any other control's. The same object
sets a default ARIA role and default ARIA states, which apply unless the page
author overrides them with the `role` attribute or the matching `aria-*`
attributes, and it can hold custom states that style sheets match with the
`:state()` pseudo-class.

For a team, the shadow tree is what makes a component self-contained: page
styles written for something else stay out of it, and the component decides
which of its parts the page may style. Where several libraries share a page, the
HTML Standard offers scoped registries, created with
`new CustomElementRegistry()`, so that their elements can coexist without the
libraries having to coordinate their names.

## What it looks like in practice

A product card shows the parts working together. The page holds the content as
ordinary markup, with a `slot` attribute on the element that should go into a
named slot:

```html
<product-card>
  <span slot="name">Oak stool</span>
  <a href="/shop/oak-stool/">See the details</a>
</product-card>
<script type="module" src="product-card.mjs"></script>
```

The module defines the element. The template is built once, and every card gets
its own copy of it in an open shadow root:

```js
const template = document.createElement("template");
template.innerHTML = `
  <h2 part="title"><slot name="name">Unnamed product</slot></h2>
  <slot></slot>
`;

class ProductCard extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: "open" });
    root.append(template.content.cloneNode(true));
  }
}

if (!customElements.get("product-card")) {
  customElements.define("product-card", ProductCard);
}
```

The page styles the title through the one part the component exposes:

```css
product-card::part(title) {
  font-size: 1.25rem;
}
```

Before `product-card.mjs` has run, the card is an undefined element and the
reader sees the product name and the link as plain inline content. When the
module runs, `define()` registers the class and the card already in the page is
upgraded. Its constructor attaches a shadow root and fills it with a clone of
the template's contents. The `span` is assigned to the slot called `name` and
appears inside the heading. The link has no `slot` attribute, so it goes to the
unnamed default slot. A card without a name shows the slot's own contents,
“Unnamed product”, in its place. The `customElements.get()` check keeps a second
copy of the module, arriving in another bundle, from making `define()` throw.

Shared components help only if people use them, and this site's portfolio has a
case about that.
[The iOS 26 “Liquid Glass” design system](/portfolio/designed-an-ios-26-liquid-glass-design-system/)
starts from a UI in which every new screen reinvents its own buttons, badges and
cards, each a little different, until every change means touching five bespoke
versions. The answer described there is a canonical set of components that every
screen composes from, among them Card, Label, Button, GlassIconButton and
DirectoryGrid, with lint rules that reject a hand-rolled badge or chip and point
to the canonical component. The page does not say whether those components are
custom elements, but the discipline carries over: one definition per component,
kept in force by tooling rather than by a reviewer's memory. The custom element
registry enforces the first half by refusing a second definition for a name.

## Where it goes wrong

**A hyphenated name stands in for a role.** “An autonomous custom element does
not have any special meaning: it represents its children,” says the HTML
Standard. Its `taco-button` example shows the cost: to act as a button, the
element needs a `tabindex`, the ARIA role `button`, an accessible name,
`aria-disabled`, key handlers that turn Enter and Space into clicks, and styles
for the disabled state. Even then, a custom element cannot declare default focus
behaviour, so `tabindex="0"` may not match how buttons behave on the platform.
Where the element builds on an existing one, the standard says extending a
built-in element reduces the work for both its author and the people using it.
For an autonomous element, set its default role and states through
`ElementInternals` rather than leaving them to every page that uses it.

**The constructor does the work.** The HTML Standard sets rules for custom
element constructors: `super()` with no arguments comes first, the constructor
must not look at the element's attributes or children, because an element
created rather than upgraded has none, and the element must not gain attributes
or children of its own. A constructor that breaks these rules can produce an
element that the parser or the DOM methods cannot create. Use the constructor
for initial state, default values, event listeners and the shadow root, and move
fetching and rendering to `connectedCallback`.

**`connectedCallback` is expected to run once.** It can run more than once. By
default, moving an element runs `disconnectedCallback` and then
`connectedCallback`, so the element resets its state as if it had been removed
and inserted again. Guard set-up that must happen only once with a flag, and
undo in `disconnectedCallback` what `connectedCallback` started. If an element
should keep its state when it is moved with `moveBefore()`, define
`connectedMoveCallback`, whose presence alone replaces the default pair, even
with an empty body.

**A name is defined twice.** `customElements.define()` throws a
`NotSupportedError` when the name or the class is already registered, and a
`SyntaxError` when the name is not valid, for example because it has no hyphen
or contains a capital letter. Check with `customElements.get()` before defining,
and give a library that must coexist with others a scoped registry of its own.

**Closed mode is treated as protection.** Closed mode hides the root from the
host's `shadowRoot` property and from an event's composed path. CSS Shadow Parts
states plainly that shadow trees are deliberately not a security boundary, only
a convenience for page authors. Keep secrets out of components, and choose
closed mode for its effect on outside code, not as a lock.

**`attachShadow()` is called where it cannot work.** The DOM Standard allows a
shadow root only on elements with a valid custom element name and on a short
list of built-in ones, among them `div`, `section`, `p` and `span`, but not
`button`. On a customized built-in button, the call throws a
`NotSupportedError`. It also behaves differently on an element that already has
a root: if that root is declarative and of the same mode, the call removes
everything in it and returns it empty, and otherwise it throws. Check for an
existing shadow root first, so that markup sent from the server in a declarative
root is kept rather than wiped.

**Page styles are expected to reach inside.** CSS Shadow Parts calls the
boundary a styling barrier, and notes that it also makes a component harder to
style when the page wants to. Expose the elements a page may style with `part`,
and style them from outside with `::part()`. A component nested inside another
needs `exportparts` on the inner host to pass its parts on to the page. For
colours and spacing that should follow the page, read custom properties set on
the host, which reach the shadow tree by inheritance.

**The content exists only after the script.** A component that writes all its
text from JavaScript shows readers none of it until the definition has loaded,
and none at all if the script fails. Keep the content in the light tree and
assign it to slots, as the product card does, so that upgrading enhances what is
already there. Where content must wait, the HTML Standard's own example hides it
until `customElements.whenDefined()` has resolved for every element matching
`:not(:defined)`.

## Further reading

- [HTML Standard: Custom elements](https://html.spec.whatwg.org/multipage/custom-elements.html)
  — naming rules, constructor requirements, upgrades, form-associated elements
  and `ElementInternals`, with worked examples.
- [DOM Standard: Shadow tree](https://dom.spec.whatwg.org/#shadow-trees)
  — shadow roots and their hosts, slots and the nodes assigned to them, and the
  steps behind `attachShadow()`.
- [HTML Standard: Scripting](https://html.spec.whatwg.org/multipage/scripting.html)
  — the `template` element with its `shadowrootmode` attribute, and the `slot`
  element.
- [CSS Shadow Parts](https://www.w3.org/TR/css-shadow-parts-1/)
  — the W3C Working Draft of 16 December 2025 that defines `part`, `exportparts`
  and the `::part()` pseudo-element.

<https://engineer.company/glossary/web-components/>
