Web Components Guide
Building Web Components —
A Practical Guide
Principles, patterns, and battle-tested best practices from building 50+ production components. Framework-agnostic, standard-first, and unapologetically opinionated.
Tip: On desktop, a sticky table of contents appears on the left.
01 When (and When Not) to Build a Web Component
The biggest mistake developers make isn't building bad components — it's building unnecessary ones.
Before you reach for customElements.define, ask yourself four questions.
The Four Criteria
A piece of UI deserves to be a custom element if it meets at least one of these:
- It's genuinely reusable. It will appear in more than one place, in more than one project, or it serves a sufficiently generic purpose that other teams could use it unchanged.
- It provides a presentation that doesn't exist in HTML. A
<my-rating-stars>component makes sense. A<my-div-with-padding>does not. - It has meaningful encapsulated behaviour. It does something — handles interaction, manages state, orchestrates child elements. Pure layout is not behaviour.
- It needs to abstract its design from its context. It can be dropped into any page or any framework without knowing anything about the host application.
The Framework Trap
Most frontend education teaches us how to use a framework, not when to use one. The mental model becomes: every UI is a component, every component needs a framework. Web components are the standard-library answer to this: they run natively in every browser, ship without a runtime, and interoperate with React, Vue, Angular, Svelte — or no framework at all.
Many sites don't need a framework. A product page, a documentation site, a portfolio — these can be built with HTML, CSS, and a handful of custom elements. Start with the platform. Add abstractions only when the platform falls short.
02 One Component, One Responsibility
The Single Responsibility Principle applies to web components with particular force, because components are exposed as public API. A component that does too much is hard to use, hard to test, and impossible to reason about.
Recognising Bloat
The clearest sign a component has grown too large is file length. If your component file exceeds ~300 lines, it's almost certainly doing more than one thing. Other signals:
- It has more than five or six observed attributes.
- Its
connectedCallbackruns 20+ lines of setup logic. - You find yourself writing conditional rendering branches for fundamentally different states.
- Its README has more than three usage examples, all significantly different.
Splitting Strategies
When a component grows too complex, split it at natural seams. Common patterns:
Split: container / presentational<!-- Bad: one component handles data fetching AND rendering -->
<user-profile-card user-id="42"></user-profile-card>
<!-- Better: separate concerns -->
<user-data-provider user-id="42">
<user-profile-card></user-profile-card>
</user-data-provider>Mixins for Shared Behaviour
If multiple components share a behaviour (drag-and-drop, lazy loading, a specific ARIA pattern), extract it into a mixin rather than a base class or a utility import. Mixins keep the component's class hierarchy flat and the shared logic decoupled.
mixin-focusable.jsexport const FocusableMixin = (Base) => class extends Base {
connectedCallback() {
super.connectedCallback?.();
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '0');
}
this.addEventListener('keydown', this.#handleKeydown);
}
disconnectedCallback() {
super.disconnectedCallback?.();
this.removeEventListener('keydown', this.#handleKeydown);
}
#handleKeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.click();
}
};
};03 Naming Your Components
Custom element tag names are global and cannot be redefined. Choose them carefully, because a conflict in the registry will silently break one of the two colliding elements.
Use a Namespace Prefix
Tag names must contain a hyphen (that's the spec's way of distinguishing custom elements from future HTML elements). Use your organisation name or design system name as the prefix:
<!-- Bad: likely to collide -->
<calendar></calendar>
<modal></modal>
<!-- Good: namespaced -->
<manufosela-calendar></manufosela-calendar>
<my-ds-modal></my-ds-modal>
The prefix doubles as a discovery mechanism — developers grep for manufosela-
to find all components from a given library.
Be Descriptive, Not Clever
The name should communicate what the component is, not how it's implemented.
<mf-accordion> is good. <mf-collapsible-details-wrapper> is too
implementation-specific. <mf-thing> is useless.
| Pattern | Example | Verdict |
|---|---|---|
| No namespace | <calendar> | Collision risk — avoid |
| Org prefix | <mf-calendar> | Good |
| Design system prefix | <manufosela-calendar> | Best — unambiguous |
| Version in name | <mf-calendar-v2> | Only if truly needed alongside v1 |
04 Data Flow: Attributes In, Events Out
This is the golden rule of web component API design. Data flows downward through attributes and properties. Events flow upward. Break this rule and you'll fight your component forever.
Attributes vs Properties — Know the Difference
An attribute is part of the HTML markup — it lives in the DOM tree, is serialised to a string, and can be set before the element is registered. A property is on the JavaScript object — it's set programmatically and can hold any value.
// Attribute — set in HTML or via setAttribute
<mf-badge count="5" variant="info"></mf-badge>
element.setAttribute('count', '5');
// Property — set via JS, ideal for complex data
element.items = [{ id: 1, label: 'Apple' }, { id: 2, label: 'Banana' }];
element.config = { debounce: 300, threshold: 0.5 };Use the following heuristics:
- Attributes: booleans, strings, numbers, enums, IDs. Anything you'd write in HTML.
- Properties: arrays, objects, functions, binary data. Anything that can't round-trip through a string without loss.
Reflecting Attributes to Properties
Mirror your observed attributes as JS properties with getters/setters so consumers can use either API:
mf-badge.jsclass MfBadge extends HTMLElement {
static observedAttributes = ['count', 'variant'];
get count() {
return Number(this.getAttribute('count') ?? 0);
}
set count(val) {
this.setAttribute('count', String(val));
}
get variant() {
return this.getAttribute('variant') ?? 'default';
}
set variant(val) {
this.setAttribute('variant', val);
}
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal === newVal) return;
this.#render();
}
#render() {
// ...
}
}
customElements.define('mf-badge', MfBadge);Custom Events for Outbound Communication
When a component needs to tell the outside world something happened, dispatch a
CustomEvent. Use a consistent naming convention: lowercase, hyphen-separated,
prefixed with the component name if there's any risk of collision.
// In your component — something happened
this.dispatchEvent(new CustomEvent('mf-select', {
bubbles: true, // propagate up the DOM tree
composed: true, // cross shadow DOM boundaries
detail: {
value: this.#selectedValue,
label: this.#selectedLabel,
},
}));
// Consumer side
document.querySelector('mf-combobox')
.addEventListener('mf-select', (e) => {
console.log('Selected:', e.detail.value);
});Don't Fire Events in Response to Property Sets
If the parent sets element.value = 'foo', the parent already knows
what the new value is. Firing a change event in response creates a feedback loop
and surprises consumers. Reserve events for user interactions and internal async completions —
things the parent couldn't have initiated.
05 Component Communication
Components should be autonomous. They shouldn't know about their siblings. They shouldn't walk the DOM tree looking for parents. Communication happens through events.
Events Over Hierarchy
When component A needs to tell component B something, the temptation is to write
parent.querySelector('mf-b').doSomething(). Resist it. The moment you introduce
a direct reference, you've coupled two components that should be independent.
Instead, dispatch an event upward and let the application (or a coordinating container) relay the data downward as an attribute:
// mf-search dispatches upward
searchEl.addEventListener('mf-search', (e) => {
// Application layer — the only one that knows about both components
resultsEl.setAttribute('query', e.detail.query);
resultsEl.setAttribute('loading', '');
fetchResults(e.detail.query).then((data) => {
resultsEl.removeAttribute('loading');
resultsEl.items = data;
});
});Phenotypic Thinking
Atomic Design organises components by size: atoms, molecules, organisms. It's a useful mental model, but it encourages thinking about components as a family tree (parents containing children who contain grandchildren). This leads to tight coupling.
A more useful frame is phenotypic: classify components by their visible
traits and how they interact with their environment. A <mf-tooltip> is
defined by how it appears (near a trigger, floating, transient) and how it
responds to its environment (focus, hover, scroll). It doesn't care whether
its trigger is a button atom or a complex form molecule.
Components should be symbiotic, not familial. They coexist and cooperate through shared conventions (events, attributes, slots), not through direct knowledge of each other.
06 Use the Light DOM
Shadow DOM is powerful, but it's opaque — to search engines, to screen readers (partially), and to the application layer. Use it for implementation; use slots and light DOM for content.
Shadow DOM Is Invisible to Crawlers
Content inside a shadow root is not indexed by search engines (as of 2026, Google can partially index it but the situation is inconsistent). Content that needs to be discovered — headings, body text, product names, navigation labels — belongs in the light DOM.
Design your components to accept semantic HTML as their content via slots, and let the component apply the visual treatment:
mf-card.html (consumer)<!-- Light DOM content — indexable, accessible, semantic -->
<mf-card>
<h2 slot="title">Product Name</h2>
<p slot="description">A short description of this product.</p>
<img slot="image" src="/product.jpg" alt="Product photo" />
<a slot="action" href="/buy">Buy now</a>
</mf-card>mf-card.js (component)class MfCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: block; border-radius: 0.5rem; overflow: hidden; }
.card { display: grid; gap: 1rem; padding: 1.5rem; }
/* style slots — the light DOM content inherits parent styles too */
::slotted([slot="title"]) { font-size: 1.25rem; margin: 0; }
::slotted([slot="action"]) { color: var(--primary); }
</style>
<div class="card">
<slot name="image"></slot>
<slot name="title"></slot>
<slot name="description"></slot>
<slot name="action"></slot>
</div>
`;
}
}Never Pass JSON as Attributes
This pattern appears everywhere and it's wrong on multiple levels:
<!-- DO NOT DO THIS -->
<mf-list items='[{"id":1,"label":"Apple"},{"id":2,"label":"Banana"}]'>
</mf-list>The issues: it's not indexable, it breaks when the JSON contains quotes, it's inaccessible to assistive technologies, and it leaks data structure into markup.
The right approach is to put the data in the light DOM as semantic HTML, and let the component read and enhance it at render time:
the right way<mf-list>
<li data-id="1">Apple</li>
<li data-id="2">Banana</li>
</mf-list>
<!-- component reads it at connectedCallback -->mf-list.jsconnectedCallback() {
const items = [...this.querySelectorAll('li')].map((li) => ({
id: li.dataset.id,
label: li.textContent.trim(),
}));
this.#render(items);
}The content was already in the light DOM. It's indexed by search engines. Screen readers can access it even before JavaScript loads. The component enhances it — it doesn't replace it.
07 Error Handling: Fail Loud
Silent failures are the worst kind. If a required attribute is missing, if an API call returns garbage, if configuration is invalid — say so clearly. Your future self will thank you.
Throw on Critical Requirements
If a component cannot function without a particular attribute or property, throw
an error in connectedCallback. Don't render a broken empty state. Don't log
a warning that gets buried in the console. Throw.
connectedCallback() {
const src = this.getAttribute('src');
if (!src) {
throw new Error(
`<mf-video-player> requires a "src" attribute. ` +
`Example: <mf-video-player src="/video.mp4"></mf-video-player>`
);
}
this.#init(src);
}Validate, Don't Assume
When an attribute value comes from outside (i.e., from a consumer), validate it against the expected set of values before using it:
static #VALID_VARIANTS = new Set(['default', 'info', 'success', 'warning', 'error']);
attributeChangedCallback(name, _, newVal) {
if (name === 'variant') {
if (!MfBadge.#VALID_VARIANTS.has(newVal)) {
console.warn(
`<mf-badge>: Unknown variant "${newVal}". ` +
`Valid values: ${[...MfBadge.#VALID_VARIANTS].join(', ')}`
);
return; // don't render an invalid state
}
}
this.#render();
}Graceful Degradation vs. Hard Failures
Distinguish between truly critical requirements (missing = broken) and optional enhancements (missing = reduced functionality):
- Critical:
srcon a media player,hrefon a link component → throw - Optional:
varianton a badge,iconon a button → warn + use default - Enhancement:
prefers-reduced-motion, dark mode → detect + adapt silently
08 Accessibility First
Accessibility is not a checklist item. It's a quality metric. A component that can't be used with a keyboard or a screen reader is a broken component.
Use Native Elements Where Possible
The single biggest accessibility win is not re-implementing what the browser
already does. If you need a dialog, wrap <dialog>. If you need a disclosure
widget, build on <details> and <summary>. The browser gives you
keyboard handling, focus management, and ARIA roles for free.
class AppModal extends HTMLElement {
#dialog;
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: contents; }
dialog { border: none; border-radius: 0.75rem; padding: 0; }
dialog::backdrop { background: rgba(0,0,0,0.6); }
</style>
<dialog part="dialog">
<slot></slot>
</dialog>
`;
this.#dialog = shadow.querySelector('dialog');
}
show() { this.#dialog?.showModal(); }
close() { this.#dialog?.close(); }
}ARIA Where Native Falls Short
When there's no native element for the pattern you're building, add ARIA explicitly.
Apply it to the host element via :host or set it in connectedCallback:
connectedCallback() {
// Set ARIA role on the host element
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'tablist');
}
// Manage focus
this.setAttribute('tabindex', '0');
this.addEventListener('keydown', this.#handleKeyNav);
}CSS Custom Properties for Theming
Use var() with sensible fallbacks so your component participates in the host
page's design system without requiring configuration:
:host {
display: block;
/* The host page can override these; fallbacks ensure the component works standalone */
--_bg: var(--mf-surface, #1c1b1b);
--_text: var(--mf-on-surface, #f4f5f2);
--_accent: var(--mf-primary, #00e0b3);
--_radius: var(--mf-radius, 0.5rem);
background: var(--_bg);
color: var(--_text);
border-radius: var(--_radius);
}Responsive & Motion-Sensitive
Components should work at any viewport size. If you have animations, respect
prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
:host, :host * {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
@media (prefers-color-scheme: dark) {
:host {
--_bg: var(--mf-surface, #131313);
}
}Images: SVG Over Base64
Inline SVG scales crisply at any resolution, has no extra HTTP request, supports CSS
animation, and keeps file sizes small. Base64-encoded images are none of those things.
Use SVG for icons and simple graphics. Use <img> with descriptive alt
for photographs.
09 Performance
Here's something the framework ecosystem doesn't emphasise enough: you don't need a library to build web components. The browser ships everything you need.
No Framework Required
Web components are part of the HTML specification. HTMLElement, customElements,
attachShadow, MutationObserver, IntersectionObserver
— all standard browser APIs, zero dependencies, zero runtime cost.
// This ships as ~2KB minified + gzipped
// No React, no Vue, no Angular, no Lit (though Lit is great too)
class MfTooltip extends HTMLElement {
static observedAttributes = ['text', 'placement'];
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>/* ... */</style>
<slot></slot>
<span class="tooltip" part="tooltip"></span>
`;
this.#setup();
}
// ...
}First Paint Matters
Define your components in a way that avoids layout shift. Set display on
:host to a non-inline value so the element takes up space before its
JavaScript runs. Use :not(:defined) to hide unconfigured components
gracefully:
/* In your component styles */
:host { display: block; }
/* In the host page CSS — hide undefined components to prevent FOUC */
mf-card:not(:defined),
mf-modal:not(:defined) {
visibility: hidden;
}Lazy Load Where Appropriate
Components that are below the fold or conditionally shown don't need to be in the critical
path. Use dynamic import() and IntersectionObserver together:
// Only load the component definition when it enters the viewport
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
import('./mf-heavy-chart.js');
observer.unobserve(entry.target);
}
}
});
document.querySelectorAll('mf-heavy-chart')
.forEach((el) => observer.observe(el));Minimise External Dependencies
Every dependency you add is a dependency your consumers must download. Ask for each one: does this genuinely need a library, or is there a browser API that does the same job? Today, that answer is "browser API" more often than ever.
| Task | Library | Native alternative |
|---|---|---|
| Animations | GSAP | Web Animations API / CSS transitions |
| Intersection detection | jQuery waypoints | IntersectionObserver |
| Resize detection | resize-sensor | ResizeObserver |
| DOM mutation | — | MutationObserver |
| Deep clone | lodash.cloneDeep | structuredClone() |
| Unique IDs | uuid | crypto.randomUUID() |
10 Documentation
An undocumented component is a broken component. If someone can't figure out how to use it in five minutes, they won't use it at all — they'll rewrite it.
The README Template
Every component package should have a README that follows this structure:
Recommended README sections
- Title — component name, one-line description, npm badge, demo link
- Install — npm command, CDN snippet, import statement
- Usage — minimal working example in HTML and JS
- Attributes — table: name, type, default, description
- Properties — table: name, type, description
- Events — table: name, detail shape, when fired
- CSS Custom Properties — table: name, default, purpose
- Slots — table: name, description
- Notes — browser support, known issues, migration notes
custom-elements.json (the manifest)
The Custom Elements Manifest is a machine-readable JSON file that describes your component's API. Tools like Storybook, VS Code extensions, and documentation generators consume it automatically.
# Generate from JSDoc comments using the analyser
npx @custom-elements-manifest/analyzer analyze --globs "src/**/*.js"Annotate your component with JSDoc so the analyser can extract the manifest:
documented component/**
* A badge component for displaying short status labels.
*
* @element mf-badge
*
* @attr {string} [variant=default] - Visual variant: default | info | success | warning | error
* @attr {number} [count] - Numeric count to display
*
* @cssprop [--mf-badge-bg] - Badge background color
* @cssprop [--mf-badge-radius=9999px] - Border radius
*
* @fires mf-dismiss - Fired when the dismiss button is clicked
*/
class MfBadge extends HTMLElement { /* ... */ }11 Maintainability
The best component is one that still makes sense six months later when a different developer opens the file at 9pm to fix a production bug.
Semantic Versioning
Follow semver strictly.
Adding a new optional attribute is a minor. Renaming an existing one is a
major (it's a breaking change). Fixing a bug is a patch.
Maintain a CHANGELOG.md.
Private by Default
Use JavaScript private class fields (# prefix) for internal methods and state.
Anything that isn't part of the public API should be private. This keeps the documented
surface minimal and prevents consumers from accidentally depending on implementation details.
class MfAccordion extends HTMLElement {
// Private state — not accessible from outside
#isOpen = false;
#panel = null;
// Private methods
#toggle() {
this.#isOpen = !this.#isOpen;
this.#panel?.setAttribute('aria-hidden', String(!this.#isOpen));
}
// Public API — documented and stable
open() { this.#isOpen = true; this.#render(); }
close() { this.#isOpen = false; this.#render(); }
toggle() { this.#toggle(); }
get open() { return this.#isOpen; }
}Testing: 80%+ Coverage
Web components are testable with standard DOM APIs. Use
Vitest with
happy-dom or
@open-wc/testing
for a real browser testing environment:
import { fixture, html, expect } from '@open-wc/testing';
import '../src/mf-badge.js';
describe('mf-badge', () => {
it('reflects the count attribute', async () => {
const el = await fixture(html`<mf-badge count="5"></mf-badge>`);
expect(el.count).to.equal(5);
});
it('fires mf-dismiss when the dismiss button is clicked', async () => {
const el = await fixture(html`<mf-badge dismissible>Label</mf-badge>`);
let fired = false;
el.addEventListener('mf-dismiss', () => { fired = true; });
el.shadowRoot.querySelector('[part="dismiss"]').click();
expect(fired).to.be.true;
});
});Modern JavaScript — Always
Write against the current ECMAScript standard. Private class fields, optional chaining,
nullish coalescing, structuredClone, crypto.randomUUID, top-level await —
these are all available in evergreen browsers. Code that targets IE11 or legacy
environments is not within scope of this guide.
// Modern, clean, idiomatic
class MfSelect extends HTMLElement {
#options = [];
#selected = null;
get value() { return this.#selected?.value ?? null; }
#findOption = (val) =>
this.#options.find(({ value }) => value === val) ?? null;
select(val) {
this.#selected = this.#findOption(val);
this.dispatchEvent(new CustomEvent('mf-change', {
bubbles: true,
composed: true,
detail: { value: this.value },
}));
}
}Quick Checklist
- Private fields (
#) for all internal state and methods - JSDoc on every public attribute, property, event, CSS custom property, and slot
static observedAttributesonly lists attributes the component actually usesdisconnectedCallbackremoves all event listeners added inconnectedCallback- Tests for the happy path, the error path, and every public event
CHANGELOG.mdupdated on every releasecustom-elements.jsongenerated and committed- Live demo linked from the README