Web Components are Custom Elements, Shadow DOM, and HTML Templates: the platform primitives for reusable, framework-agnostic components whose markup and styles are encapsulated from the document. Use them for design systems, embeddable widgets, and self-contained UI.
The rules below are cross-cutting. Retrieve the specific guide for the task at hand; each restates the context it needs and documents the APIs and pitfalls unique to it.
Decide whether you even need a custom element
Reach for a custom element only when you need encapsulated behavior, lifecycle, or a reusable JS-backed API on a tag. Do not build one when:
- A plain HTML element with CSS would do. A styled
<button>or<details>is more accessible and cheaper than a re-implementation. - The thing is purely presentational with no behavior. A class on a native element is more performant, and scopable with
@layeror@scope. - You only need style scoping on the server with no interactivity; a scoped stylesheet or Declarative Shadow DOM (without an upgrade) may be enough.
Rebuilding native semantics (a custom <my-button> that re-creates focus, activation, and ARIA) is the most common misuse: you inherit the platform's accessibility for free only if you reuse the native element. When a <my-button>-style wrapper is warranted — usually for shared styling hooks, extra markup, or behavior a native element can't express — wrap the real <button> rather than re-implementing it (see Styling Web Components for forwarding styles to an internal native element).
The test is whether you need encapsulated state, a scriptable API, or behavior the native element can't express — not merely a different look. A like/favorite toggle qualifies: it carries latched domain state, exposes that state as a styling hook for the surrounding page, and dispatches its own change event — none of which a styled <button> gives you. A button that only changes color when pressed does not: reach for <button> + CSS. When you do build it, model its state with a custom state or attribute as appropriate (see Custom Elements).
Match the tool to the codebase
Whether something should be a custom element depends on what the project already uses. Agents tend to over-abstract and reach for a new element too eagerly — when in doubt, the lighter option (native element, CSS, a server template) is usually right, and you can promote to a custom element later if reuse or behavior actually demands it. Heuristics, in rough priority:
- The codebase already has a component model (React, Vue, Svelte, …). Default to that framework's component for in-app UI. Reach for a custom element only when the thing must escape or outlive the framework: a design-system primitive shared across apps or frameworks, a widget dropped into markup the framework doesn't control, or output that must work as plain HTML (email, CMS, SSR fragments). Don't stand up a parallel component system for ordinary in-app views.
- No tooling, mostly static markup. Prefer plain HTML + CSS, or a server template/partial. A custom element earns its keep only where you genuinely need behavior, lifecycle, or a JS-backed API on the tag — not merely to share markup, which a server include or
<template>does without shipping any JS. - Static site generator with islands of interactivity. Render the markup statically; reserve the custom element for the interactive part. For "lots of markup, a little interactivity," keep the markup in Light DOM (or Declarative Shadow DOM) and let a small custom element progressively enhance it, rather than re-rendering everything from JS.
- You could push a built-in element further instead. Taking a native element as far as it goes (CSS, a few listeners, the occasional pseudo-element) is usually preferable to a custom element, because you keep the platform's accessibility and behavior for free. Cross over to a custom element when the workarounds stop being maintainable — when you'd be fighting the element's built-in behavior, or the authoring experience becomes worse than a clean component API would be. Needing a little JS on a built-in element is not, by itself, a reason to replace it.
Cross-cutting rules
These hold across every guide below:
- MANDATORY: the tag name must be kebab-case with at least one hyphen (e.g.
my-element). This is what distinguishes custom elements from current and future built-ins. - MANDATORY: call
super()first in the constructor, before touchingthis; calling it after any statement that reads or writesthisthrows. - MANDATORY: use semantic elements (
<button>,<nav>,<ul>) inside shadow templates. Most accessibility failures in shadow trees come from non-semantic markup; a slot does not change the role of what it projects. - MANDATORY: ARIA
idreferences (aria-labelledby,aria-describedby,aria-controls) cannot cross a shadow boundary; the referencing element and theidit points to must live in the same tree. - Prefer
attachShadow({ mode: 'open' })so dev tools, page scripts, and server hydration can reachelement.shadowRoot. Use'closed'only when you must hide internal structure from page scripts. - Avoid customized built-in elements (
extends HTMLButtonElementwithis="…"); Safari has permanently declined to ship them.
Use-case guide matrix
Identify the task and retrieve its guide:
| If you need to… | Retrieve |
|---|---|
| Define a new semantic tag with behavior and lifecycle | Custom Elements |
| Scope styles and markup to a component, and project consumer content into it | Shadow DOM, Templates & Slots |
| Render a component's shadow tree on the server (SSR) without a flash of unstyled content | Pre-rendering custom elements |
Style across the shadow boundary (theming, ::part, custom properties) |
Styling Web Components |
Submit a custom control's value to a surrounding <form> |
Form-Associated Custom Elements |
| Make a custom element accessible (roles, focus, ARIA across shadow roots) | Accessible Web Components |