WebComponents, from first element to production system.
This guide covers the Web Platform APIs first, then the WC canvas framework. Standard Web Components are DOM-based; WC adds an optional canvas renderer while preserving HTML fallbacks and browser escape hatches.
Browser-only runtime: load WC directly as an ES module in the browser. No Node runtime, server runtime, bundler, or build step is required for the CDN version.
1. The mental model
A Web Component is a browser-native custom HTML element. It is not a framework, a template language, or automatically a Shadow DOM component. The platform is made from four independent APIs: custom elements, Shadow DOM, HTML templates, and standard DOM events.
Use custom elements when you want reusable behavior with a stable HTML interface. Keep the public contract small: attributes for declarative configuration, properties for rich values, events for outputs, and slots for content owned by the consumer.
2. Custom elements
Autonomous elements
class UserBadge extends HTMLElement {
connectedCallback() {
this.textContent = `User: ${this.getAttribute("name") ?? "Anonymous"}`;
}
}
customElements.define("user-badge", UserBadge);
Names must contain a hyphen. Definitions are global and can only be registered once. Always guard registration in shared bundles or use a unique package prefix.
Customized built-ins
class FancyButton extends HTMLButtonElement with { extends: "button" } exists, but support and framework integration are less predictable. Prefer autonomous elements for portable components.
Upgrade timing
Unknown tags are still valid DOM. They upgrade when their definition is registered. Use customElements.whenDefined("user-badge") when code must wait for behavior.
3. Lifecycle
| Callback | Use |
|---|---|
constructor() | Initialize state. Do not read children or attributes that may not exist yet. |
connectedCallback() | Attach listeners, render, and start observers. |
disconnectedCallback() | Remove listeners, abort fetches, stop timers and observers. |
adoptedCallback() | React when moved between documents. |
attributeChangedCallback(name, old, value) | React to declared observed attributes. |
static observedAttributes = ["open"];
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) this.render();
}
4. Attributes, properties, and state
Attributes are strings and are ideal for serialized, declarative values. Properties can hold objects, arrays, functions, or signals. Do not reflect every property automatically: define one source of truth to avoid loops.
element.setAttribute("count", "3"); // declarative string
element.items = [{ id: 1 }]; // rich property
element.addEventListener("change", handler); // output
For booleans, presence means true: <my-dialog open>. For numbers and JSON, validate input and provide safe defaults. Never evaluate attribute strings as code.
5. Shadow DOM
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `<style>:host { display:block }</style>
<button part="trigger"><slot>Open</slot></button>`;
open exposes shadowRoot; closed hides the reference but is not a security boundary. Shadow DOM scopes styles and changes event retargeting. It does not isolate all inherited values, and it does not automatically make content accessible.
Use :host, :host-context() carefully, CSS custom properties for theming, and ::part() for intentionally public styling hooks. Avoid leaking internal class names as API.
6. Templates and slots
<template> stores inert markup in a DocumentFragment. A named slot lets the component consumer provide content while the component controls layout.
<my-card>
<span slot="title">Account</span>
Body content
</my-card>
<slot name="title">Untitled</slot>
<slot>Empty</slot>
Use slotchange when assigned content changes. Query slotted nodes with slot.assignedElements(), not from inside the shadow root with ordinary selectors.
7. Events and communication
Dispatch public events with a stable detail object. Use bubbles: true for parent delegation and composed: true when the event must cross a shadow boundary.
this.dispatchEvent(new CustomEvent("user-change", {
detail: { id: this.userId }, bubbles: true, composed: true
}));
Events are not state synchronization. Keep events as notifications and let consumers read the current property. Use AbortController to clean up listeners reliably.
8. Forms and native controls
Use real inputs, buttons, and links whenever their browser behavior is valuable. A form-associated custom element can participate in validation and submission:
class ColorField extends HTMLElement {
static formAssociated = true;
internals = this.attachInternals();
set value(value) { this._value = value; this.internals.setFormValue(value); }
get value() { return this._value; }
}
customElements.define("color-field", ColorField);
Implement labels, name, disabled, validity, reset, and restore-state behavior before replacing a native control.
9. Accessibility
Start with semantic elements and native controls. Give every interactive component a name, visible focus indicator, keyboard operation, and sensible disabled state. Do not use role="application" to hide poor semantics.
- Use headings in order and landmarks such as
main,nav, andfooter. - Use
aria-expanded,aria-controls, andaria-selectedonly when their state is maintained. - Respect
prefers-reduced-motionand forced-colors/high-contrast modes. - Test with keyboard-only navigation, a screen reader, zoom, and touch.
10. Starting with WC
<canvas id="app"></canvas>
<script type="module">
import { createApp, h } from "https://webcomponents-studio.pages.dev/cdn/wc-web.js";
const app = createApp(document.querySelector("#app"));
app.mount(() => h("main", {}, h("h1", {}, "Hello, WC")));
</script>
WC can be better than traditional Web Components in areas such as highly controlled rendering, visual consistency, custom graphics, and avoiding large DOM trees. The visible UI is painted into canvas, while its accessibility mirror and optional DOM bridge preserve browser behavior where needed.
11. Rendering, layout, and components
Use h(type, props, ...children) to create virtual nodes. Use createSignal(value) for small reactive state and call app.update(view) after changing it.
const count = createSignal(0);
const view = () => h("button", {
label: `Count: ${count()}`,
onClick: () => { count.set(count() + 1); app.update(view); }
});
Supported layout styles include padding, width, height, wrapping text, and a flex subset: display, flexDirection, gap, flex, justifyContent, and alignItems. Components support background, borderRadius, border, shadow, and depth.
Renderer semantics
- Numeric dimensions and
pxstrings are pixels.vwandvhuse the browser viewport. Percentages use the width or height reference supplied by the current layout parent. width: "100%"uses the available width. Parent padding is removed first, so a full-width child fills the padded parent's inner content width.- In a row flex container, child widths resolve against the inner width after padding and gaps. A
flexchild receives its share of the space left after fixed-width children and gaps. - Padding and margin accept one value for both axes or two values in vertical-then-horizontal order.
- A border such as
"1px solid #334"supplies a 1px stroke and the#334color. Thesolidword is accepted as part of the declaration. borderRadiusis measured in pixels for numbers andpxstrings, then clamped to half the rendered width or height. Thus999creates a capsule or fully rounded panel.fontSize,fontWeight, andfontFamilybelong insidestyle.fontis a complete canvas font declaration. Button captions use the top-levellabelprop; regular text is a child.- Clickable nodes use rectangular rendered-box hit regions, including padding. Hit testing checks regions from last to first and selects the last matching region. Because parent regions are recorded after their children, an overlapping clickable parent wins over a nested clickable child.
app.update(component)immediately evaluates a function component, replaces the vnode tree, redraws, and rebuilds the accessibility mirror. Handlers and labels therefore come from the updated tree, and the mirror refreshes on every draw whenscreenReaderis enabled.
12. Interactions and DOM escape hatches
Links, scrolling, focus, keyboard activation, clipboard callbacks, and screen-reader content work by default. Text drag selection is opt-in because selection styling is a product decision, not a framework identity.
import { dom } from "https://webcomponents-studio.pages.dev/cdn/wc-web.js";
const nativeInput = document.querySelector("input");
h("section", {}, dom(nativeInput, "focus"));
The DOM bridge intentionally translates only focus, click, select, and copy. Keep native DOM nodes for file pickers, text editing, media controls, and third-party widgets.
13. Images, fonts, and shaders
import { image, registerImageDecoder } from "https://webcomponents-studio.pages.dev/cdn/wc-web.js";
image("/assets/hero.webp", { width: 640, height: 360 });
registerImageDecoder("qoi", async (buffer) => decodeQoi(buffer));
Browser-supported image formats use Image. Truly unsupported formats require a decoder that returns an image-compatible object. Fonts can be loaded with loadFonts([{ family, source, descriptor }]). Shaders use vertex { ... } and fragment { ... } GLSL blocks and are composited from an offscreen WebGL layer.
14. SSR, progressive enhancement, and SEO
WC does not require SSR. If your site already uses SSR, render semantic HTML first, include a canvas beside it, and pass the fallback selector to createApp. WC hides the fallback only after client mount; without JavaScript, crawlers and users still get real HTML.
<main id="seo-fallback">
<h1>Product dashboard</h1>
<p>Server-rendered description and links.</p>
</main>
<canvas id="app"></canvas>
Provide a unique title, description, canonical URL, Open Graph metadata, structured data where appropriate, stable URLs, real anchor links, and an XML sitemap. Canvas pixels are not an SEO document.
15. Routing and CDN
Routes belong to the host site, not the renderer. The included _routes maps the landing page, docs, example, and /cdn/wc-web.js. Deploy the repository on a static host or serve locally:
python3 -m http.server 8080
# http://localhost:8080/
# http://localhost:8080/docs/
The browser CDN entrypoint is https://webcomponents-studio.pages.dev/cdn/wc-web.js. It can be used directly from static HTML. Pin a versioned URL when versioned releases are available and configure immutable caching for it.
16. Production checklist
- Define custom elements once and use a package prefix.
- Keep DOM fallback markup semantic and complete.
- Test keyboard, screen readers, touch, zoom, reduced motion, and high contrast.
- Use real anchors for navigation and native inputs for editing.
- Cancel fetches and observers in
disconnectedCallback. - Validate all attributes and never execute markup as code.
- Measure long lists and avoid redrawing expensive content unnecessarily.
- Preload critical fonts and provide system fallbacks.
- Set CSP, HTTPS, image policies, and cache headers.
17. Reference
| API | Purpose |
|---|---|
h(type, props, children) | Create a virtual node. |
createApp(canvas, options) | Create, mount, update, unmount, or destroy a canvas app. |
createSignal(initial) | Small reactive getter with .set() and .subscribe(). |
image(src, props) | Canvas image vnode. |
registerImageDecoder(ext, decoder) | Decode a browser-unsupported image format. |
dom(node, actions) | Pass an existing DOM node and limited native actions. |
loadFonts(fonts) | Load and wait for canvas fonts. |
web.json | Feature defaults and opt-outs generated by the compiler. |
Rule of thumb: use Web Platform semantics first, WC canvas rendering second. If a browser already provides the behavior you need, keep the native element.