Building Web Components — A Practical Guide | manufosela.dev

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.

By manufosela~25 min readUpdated April 2026

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:

  1. 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.
  2. It provides a presentation that doesn't exist in HTML. A <my-rating-stars> component makes sense. A <my-div-with-padding> does not.
  3. It has meaningful encapsulated behaviour. It does something — handles interaction, manages state, orchestrates child elements. Pure layout is not behaviour.
  4. 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 connectedCallback runs 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.js
export 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.

PatternExampleVerdict
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.js
class 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.

dispatching events
// 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.js
connectedCallback() {
  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:src on a media player, href on a link component → throw
  • Optional:variant on a badge, icon on 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.

→ Live demo: app-modal (wraps <dialog>) → Live demo: behaviour-accordion (builds on <details>) app-modal — wrapping <dialog>
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.

TaskLibraryNative alternative
AnimationsGSAPWeb Animations API / CSS transitions
Intersection detectionjQuery waypointsIntersectionObserver
Resize detectionresize-sensorResizeObserver
DOM mutationMutationObserver
Deep clonelodash.cloneDeepstructuredClone()
Unique IDsuuidcrypto.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

  1. Title — component name, one-line description, npm badge, demo link
  2. Install — npm command, CDN snippet, import statement
  3. Usage — minimal working example in HTML and JS
  4. Attributes — table: name, type, default, description
  5. Properties — table: name, type, description
  6. Events — table: name, detail shape, when fired
  7. CSS Custom Properties — table: name, default, purpose
  8. Slots — table: name, description
  9. 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 observedAttributes only lists attributes the component actually uses
  • disconnectedCallback removes all event listeners added in connectedCallback
  • Tests for the happy path, the error path, and every public event
  • CHANGELOG.md updated on every release
  • custom-elements.json generated and committed
  • Live demo linked from the README

Manuel Fosela — manufosela

Frontend architect and web standards nerd based in Spain. Has been building web components since the v0 polyfill era, authored 50+ open-source custom elements, and still thinks the best UI framework is a well-considered HTMLElement. Writes about components, design systems, and the philosophy of web development.