If the same interface appears on several pages, a small Web Component can package its markup, styles, and behavior without requiring a UI framework. The browser APIs are useful for shared widgets such as notices, badges, and site-specific controls, especially when they need to work in more than one application.
This guide builds a reusable notice with a custom element, a template, named and default slots, and a Shadow DOM tree. The example keeps its message in regular HTML so it remains readable if JavaScript is unavailable.
What makes a Web Component?
Web Components are browser features that can be used together:
- Custom elements let you register a new HTML element name and define its behavior.
- Shadow DOM gives an element a separate DOM tree and a boundary for its internal styles.
- Templates and slots let you reuse markup and place content supplied by the element's user.
You do not need all three for every component. A custom element can be useful on its own; add a shadow root when the component benefits from an internal style boundary, and use slots when callers should provide part of its content.
Custom element names need a hyphen, such as <sdr-notice>, so they do not collide with existing HTML elements. Define each name only once in a page's custom element registry.
Keep useful content outside the shadow root
Start with the content a reader should see, then wrap it in a custom element. Keep a basic style for the host in the page stylesheet so it still looks like a block when its JavaScript has not run:
<style>
sdr-notice {
display: block;
margin-block: 1rem;
padding: 1rem;
border: 1px solid #94a3b8;
--notice-accent: #1d4ed8;
}
sdr-notice[kind="warning"] {
border-inline-start: 0.25rem solid #b54708;
--notice-accent: #b54708;
}
</style>
<sdr-notice kind="warning">
<strong slot="title">Before deploying</strong>
<p>Make a restorable backup before changing production data.</p>
</sdr-notice>
Before the browser registers sdr-notice, the element's children are still ordinary document content. The slot attribute has no special effect outside a shadow tree, so the heading and paragraph remain visible in their original order. Once the component is defined, matching <slot> elements will place those same children into its shadow tree.
This is progressive enhancement, not a replacement for testing. If the message is essential, verify that your page remains understandable with JavaScript disabled and while the component is still loading.
Add a template and shadow root
Put a reusable template before the component markup's module script. A <template> stores inert markup; clone its contents for each component instance instead of parsing an HTML string:
<template id="sdr-notice-template">
<style>
:host {
display: block;
color: inherit;
}
.notice {
padding: 0.75rem;
border-inline-start: 0.25rem solid var(--notice-accent, #1d4ed8);
background: #f8fafc;
}
.notice[data-kind="warning"] {
background: #fffaeb;
}
.title {
display: block;
margin-block-end: 0.5rem;
}
::slotted(p) {
margin-block: 0;
}
</style>
<div class="notice" data-kind="info" role="note" part="container">
<strong class="title"><slot name="title">Notice</slot></strong>
<div><slot></slot></div>
</div>
</template>
The named slot receives the child with slot="title". The unnamed slot receives the paragraph. ::slotted(p) styles paragraphs assigned directly to a slot; it does not select arbitrary descendants inside the slotted content.
Now register the element:
<script type="module">
const template = document.querySelector("#sdr-notice-template");
if (!(template instanceof HTMLTemplateElement)) {
throw new Error("The sdr-notice template is missing.");
}
class SdrNotice extends HTMLElement {
static observedAttributes = ["kind"];
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
if (this.shadowRoot.hasChildNodes()) return;
this.shadowRoot.append(template.content.cloneNode(true));
this.renderKind(this.getAttribute("kind"));
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "kind" && oldValue !== newValue) {
this.renderKind(newValue);
}
}
renderKind(kind) {
const supportedKind = kind === "warning" ? "warning" : "info";
this.shadowRoot
.querySelector(".notice")
?.setAttribute("data-kind", supportedKind);
}
}
if (!customElements.get("sdr-notice")) {
customElements.define("sdr-notice", SdrNotice);
}
</script>
The optional check in renderKind matters because attributeChangedCallback can run before connectedCallback has cloned the template. Once connected, the component reads the current attribute again, so it also handles an initial kind="warning". Unknown kinds fall back to the informational style.
The constructor calls super() and creates the shadow root. connectedCallback() runs when an instance is added to the document; the guard prevents replacing its contents if it is later disconnected and reconnected. observedAttributes opts the element into notifications for kind, and attributeChangedCallback() updates the shadow content when that value changes. Other lifecycle callbacks, such as disconnectedCallback(), are useful when a component must remove listeners or release resources it created.
The registry check avoids defining the same name twice, which would throw an error. Use a project-specific element name and load the module once rather than trying to redefine a component during page rendering.
Decide what the shadow boundary should expose
Shadow DOM limits which selectors can reach the component's internal elements, but it is not a security boundary. Keep the public interface small and explicit:
- Use attributes or properties for values that callers need to set, such as the notice's
kind. - Use CSS custom properties for theme values such as
--notice-accent; custom properties can inherit into the shadow tree. - Use a
partattribute on an internal element when outside styles should be able to target that specific surface with::part(container). - Use slots for caller-provided content. Style assigned nodes with
::slotted()only when they are direct children assigned to that slot.
Events need similar thought. A normal event dispatched inside a shadow tree does not automatically cross every boundary. If a component emits a custom event that outside code must hear, define and document its event name, detail, bubbling behavior, and whether it should be composed across the shadow boundary. Avoid exposing internal implementation details as part of the component's API.
The notice example is deliberately non-interactive. If you add a button or another control, keep native keyboard behavior and accessible names, and test how focus and assistive technology interact with the shadow tree. Our guide to testing website accessibility has a broader checklist for evaluating those behaviors.
When the component participates in a form
A custom element does not become a form field just because it contains an input-like control. For a component that must submit a value with a form, HTML supports form-associated custom elements through static formAssociated = true and this.attachInternals(). The returned ElementInternals object provides APIs for reporting a value and validity to the browser.
Form association adds responsibilities: label association, disabled state, reset behavior, required and invalid states, and submission must all be handled correctly. Prefer native <input>, <select>, and <button> elements when they solve the problem. Only build a form-associated custom element when the custom interface provides a real benefit, and follow the current MDN form-associated custom element guidance.
Test the component as a user and as a maintainer
Before reusing a component across a site, check the states and boundaries that its callers depend on:
- Load the page with JavaScript disabled, then with the module delayed. Confirm the message remains visible and in a sensible order.
- Render more than one instance. Check that each receives its own cloned shadow content and that the shared template is not modified.
- Change
kindafter connection and try an unsupported value. Confirm the component updates and falls back predictably. - Pass empty, long, and nested content through the slots. Check wrapping, spacing, and the
::slotted()styles. - Inspect keyboard interaction and the accessibility tree. For interactive components, test focus movement, names, states, and events with your supported assistive technologies.
- Check the browser versions your users need. Custom elements, shadow roots, slots, and form-associated custom elements have separate compatibility details; consult the MDN compatibility data for each API you use.
Keep the element's required content in light DOM and use Shadow DOM only for the implementation details that benefit from isolation. That makes a component easier to adopt across pages without making its markup, styling, or behavior a surprise to the next developer.