Web Components: Custom Elements, Shadow DOM and Reusable HTML

Web DevelopmentWeb Components: Custom Elements, Shadow DOM and Reusable HTML

What if HTML could ship its own widgets and never let your CSS break them again?
Web Components do exactly that: they let you make custom HTML tags that bundle markup, styles, and behavior so they work anywhere, in React, Vue, or plain HTML.
Shadow DOM (a private style and DOM boundary) keeps a component’s look from leaking out or being overridden.
This post shows how Custom Elements, Shadow DOM, and HTML Templates fit together to make reusable, framework-agnostic UI that stays predictable as projects grow.
You’ll build components once and drop them into any app.

Defining Web Components and Their Core Purpose

mHhFoZhhVfuVrtkQn000AQ

Web Components are native browser APIs that let you build reusable custom HTML elements with encapsulated markup, styles, and behavior. Instead of duplicating code or wrestling with framework lock-in, you define a component once and use it anywhere. Across React, Angular, Vue, or plain HTML. A Web Component looks and acts like a standard HTML element (think <video> or <select>), but you control the tag name, appearance, and logic.

Encapsulation is the superpower here. When you bundle styles inside a Web Component, those styles won’t leak out and clash with your global CSS. And global styles won’t accidentally break your component’s layout. That isolation solves the old “my button styling just broke the navigation” problem. You drop your component into a page, pass it some data or content, and it renders predictably. No surprise overrides, no framework rewrites.

Web Components rely on three core browser standards working together. Custom Elements let you register new tags, Shadow DOM creates that style boundary, and HTML Templates give you reusable markup chunks. You’ll also gain two major benefits: true reusability (ship one library to every project, no matter the stack) and long-term stability (standards evolve slower than frameworks, so your component code ages well).

The naming rule is simple but strict: every custom element name must include at least one hyphen, like <legend-header> or <zombie-profile>. That hyphen prevents your tag from colliding with future native HTML elements. If you see a hyphen in an HTML tag, it’s either a Web Component or a typo.

  • Custom Elements are the JavaScript API to define and register new HTML tags with custom behavior and lifecycle callbacks.
  • Shadow DOM is the browser feature that attaches a private, scoped DOM tree to your element, isolating styles and markup from the rest of the page.
  • HTML Templates use the <template> tag to hold inert HTML that you clone and insert into your component’s shadow root at runtime.
  • Framework-agnostic reuse means you write once, use in React today, Vue tomorrow, Angular next quarter, or plain HTML forever.
  • Predictable encapsulation keeps component styles and DOM structure inside their boundary, preventing accidental style collisions and making debugging faster.

How Web Components Work Internally (Mechanics and APIs)

WWd36xkIWUeHL6cz9PnMkQ

Creating a Web Component starts with a JavaScript class that extends HTMLElement. Inside the class constructor (or connectedCallback), you optionally call this.attachShadow({ mode: 'open' }) to create a shadow root, then clone your template’s content and append it. Finally, you register the class with customElements.define('my-widget', MyWidget), and the browser wires up every <my-widget> tag on the page to your class.

Lifecycle callbacks give you hooks into the component’s life. connectedCallback runs when the browser inserts your element into the DOM. Use it to set up event listeners or fetch data. disconnectedCallback fires on removal, so you can clean up timers or listeners. attributeChangedCallback(name, oldValue, newValue) reacts to attribute changes, but only for attributes you list in the static observedAttributes getter. This reactive pattern keeps your component in sync with the outside world without manual polling.

Content projection happens through <slot> elements. When someone writes <my-card><p>Hello</p></my-card>, that paragraph stays in the light DOM (the normal, outer DOM tree) but appears wherever your component’s template placed a <slot>. Named slots let you target specific content: <slot name="header"> pulls in children with slot="header". The shadow DOM overlays the light DOM, so slotted content renders inside your component’s boundary while remaining accessible to the main document’s styles and scripts.

API Purpose
customElements.define(‘x-name’, Class) Registers your class so the browser knows how to instantiate <x-name> tags
attachShadow({ mode: ‘open’ }) Creates a scoped shadow DOM tree for encapsulated styles and markup
observedAttributes + attributeChangedCallback Watches specific attributes and runs a callback whenever they change, enabling reactive properties
<slot> and <slot name=”…”> Projects light DOM content into specific positions inside your shadow DOM template

Types of Web Component Building Blocks and Their Roles

0D_N269DXYa7SlCIo1uQVQ

Web Components combine three primitives into one reusable package. Each piece solves a specific problem: where to store markup, how to inject user content, and how to isolate styles.

Template

The <template> tag holds inert HTML that browsers parse but don’t render until you clone it. You write your component’s markup and internal <style> block inside a <template>, give it an id, then reference it from your JavaScript class. When the component connects to the DOM, you call template.content.cloneNode(true) to get a fresh copy of that markup and attach it to your shadow root. This pattern keeps your HTML declarative and avoids building DOM trees with string concatenation or createElement loops.

Slot

Slots are the projection layer. A <slot> inside your template acts as a placeholder: any child elements in the light DOM flow into that slot at render time. Default slot content (the HTML inside <slot>Default text</slot>) appears only if the user passes nothing. Named slots (<slot name="footer">) let you organize multiple content areas. Users mark their content with a matching slot="footer" attribute. Slotted content stays in the light DOM, so the main document’s CSS can style it, but it visually renders inside your component’s shadow boundary.

Shadow DOM

Shadow DOM creates a private DOM subtree attached to your element. Styles defined inside that subtree (via <style> in your template) scope to the shadow root and won’t affect the outer page. Conversely, global styles don’t penetrate the shadow boundary unless you explicitly allow it. The shadow tree has its own querySelector scope and event retargeting, meaning clicks inside the shadow DOM bubble but appear to come from the host element, not internal nodes. This encapsulation prevents naming collisions and makes components portable across projects.

Styling constraints differ by boundary:

  • Template CSS affects only shadow DOM nodes. Styles inside your <style> tag won’t touch slotted content or external elements.
  • Main document CSS affects only light DOM. Global stylesheets can style the host element and slotted content, but can’t reach into the shadow tree.
  • Duplication is common. To style both a template element and its slotted replacement consistently, you often write duplicate rules in both places.
  • Pseudo-selectors bridge gaps. ::slotted(selector) styles slotted children from inside the shadow DOM, and ::part(name) exposes internal elements to external CSS.

Real-World Web Component Examples and Data Flow Patterns

hJPXpNw4WEqfd1phW1JqFg

Imagine building a small app to track legends in a database. You’d create <legend-header> to display a title and count, <legend-table> to render rows, and <new-legend-form> to add entries. Each component is a self-contained unit: the header reads a count attribute, the table accepts an array property, and the form emits a custom event when the user clicks submit. A top-level <legend-app> component owns the data and listens for events, then passes updated data back down to children via attributes or properties.

This data flow keeps components stateless and reusable. When the form dispatches a new-legend event (using this.dispatchEvent(new CustomEvent('new-legend', { detail: { name, power }, bubbles: true, composed: true }))), the event bubbles through the light DOM to the app element. The app’s connectedCallback adds a listener, updates the internal data array, then sets new attribute or property values on <legend-table> and <legend-header>. The components re-render or update their DOM in response, but they never manage global state themselves.

A fourth component, <auth-box>, demonstrates a light authentication pattern. It uses server sessions to restrict actions. Only logged-in users can add legends. The auth box emits login/logout events, the app element checks session state (via a small server call), and conditionally shows or hides the form. This separation of concerns means you can drop <auth-box> into other projects without rewriting logic.

Common data flow techniques:

  • Observable attributes let you list attribute names in observedAttributes, then react in attributeChangedCallback to update the internal DOM.
  • Properties for complex data work better when you need to pass objects or arrays (e.g., tableElement.data = rowsArray) since attributes only accept strings.
  • Custom events for upward communication let children emit events with bubbles: true and composed: true so they cross shadow boundaries and reach the app root.
  • Centralized state in a top-level component means one parent holds the app data and orchestrates updates, avoiding scattered state across many child elements.
  • Single-responsibility components do one thing well (display, input, formatting) and delegate complex logic to their parent.

Styling Strategies for Web Components

mSynBBq4U9Sipx1KCtPUgw

Styles inside a <template> live in the shadow DOM and scope automatically. Write a <style> block at the top of your template, and those rules apply only to elements in that shadow tree. No leakage, no collisions. Slotted content, however, remains in the light DOM, so you style it from your main stylesheet using descendant selectors like legend-table td { padding: 0.5rem; }. That creates a gap: you often need duplicate rules (one set inside the template, one set outside) to cover both the template’s internal structure and the user’s slotted replacements.

CSS variables are the bridge. Define variables in your main stylesheet (:root { --primary-color: #3b82f6; }), then reference them inside your component’s template CSS (color: var(--primary-color);). This lets you theme multiple components without breaking encapsulation. Each component reads the same variable but keeps its styling logic private. You can swap themes (light/dark) by changing a few root-level variables, and every component updates in sync.

Two pseudo-selectors help fine-tune styling across boundaries. ::slotted(selector) targets slotted children from inside the shadow DOM. Use it when you need the component to apply styles to user-provided content. ::part(name) exposes a named part of your shadow DOM to external CSS, so consumers can style specific internal elements without losing encapsulation. Both require explicit opt-in (you add part="label" to an internal element), keeping the default boundary intact while offering controlled access points.

Styling Surface Where Rules Are Written What It Affects
Shadow DOM (template internals) <style> inside <template> Elements cloned from the template into the shadow root; scoped automatically
Light DOM (slotted content and host) Main document stylesheet The custom element itself and any children passed via slots; global scope applies
Exposed Parts (opt-in styling hooks) Main document, using ::part() Specific internal elements marked with part=”name”; allows external theming without breaking encapsulation

Browser Support, Polyfills, and Tooling for Web Components

0d9-mD1iVJqA-g18Gn70-Q

Modern browsers (Chrome, Edge, Firefox, Safari) ship native Web Components support. Custom Elements, Shadow DOM, and HTML Templates work out of the box in every evergreen browser released after 2018. Legacy Internet Explorer (IE11 and older Edge versions) lack native support, so you’ll need polyfills if you must target those browsers. The webcomponentsjs polyfill bundle fills the gaps, though it adds extra JavaScript weight and some performance overhead.

Tooling makes authoring faster and safer. Lit is a lightweight library that wraps Web Components in a simpler API. You write a render() method that returns tagged template literals, and Lit handles updates and attribute/property binding. Stencil is a compiler that takes JSX and TypeScript and outputs optimized Web Components with built-in polyfills, static site generation, and a virtual DOM for efficient rendering. Both tools let you write modern code (async/await, decorators, JSX) and compile it down to standards-based components that run anywhere.

Bundlers like Webpack, Rollup, or Vite integrate smoothly with Web Components. You can code-split components so they load on demand, tree-shake unused exports, and minify the final output. If you author components in TypeScript or use advanced CSS features, the build step compiles everything to plain JavaScript and CSS that browsers understand. That setup gives you the best of both worlds: modern developer ergonomics during authoring and wide compatibility at runtime.

Popular tooling options:

  • Lit offers reactive properties, declarative templates with tagged template literals, lightweight (~5 KB), and works with any bundler.
  • Stencil is a full compiler with JSX, TypeScript, virtual DOM, SSG, polyfills for IE11, and an integrated test runner.
  • Vanilla JavaScript lets you write raw class MyElement extends HTMLElement with no dependencies, maximum control, and zero build step.
  • webcomponentsjs polyfill provides runtime shim for older browsers; include it via script tag or bundle it, adds ~30 KB gzipped to support legacy environments.

Comparing Web Components to Frameworks

NfNRyFNrWlGyj3jINbIOiw

Web Components are browser primitives; React, Vue, and Angular are libraries or frameworks that add state management, routing, and abstractions on top of the DOM. A Web Component doesn’t care about the rest of your stack. It’s just a custom HTML tag. You can drop <legend-table> into a React app, a Vue app, or a static HTML page, and it works the same way because it’s built on standards, not a framework’s internal API.

Integration usually works, but quirks exist. React’s synthetic event system doesn’t automatically capture DOM events from custom elements. If your component dispatches a submit event, you can’t catch it with onSubmit={handleSubmit}. React only wires up its own events to native elements. The workaround is to use a ref and call addEventListener directly, like useEffect(() => { ref.current.addEventListener('submit', handleSubmit); return () => ref.current.removeEventListener('submit', handleSubmit); }, []). Angular and Vue handle DOM events natively, so they integrate more smoothly out of the box.

Web Components excel when you need framework-agnostic widgets. Design-system buttons, form inputs, data tables that multiple teams consume across different stacks. Frameworks excel when you need rich state management, declarative routing, or a large ecosystem of plugins. Many teams use both: core UI components as Web Components, app logic and pages in their framework of choice.

Common integration quirks:

  • React synthetic events mean custom element events won’t trigger onEventName props; use refs with addEventListener instead.
  • Property vs attribute binding trips people up because some frameworks default to attributes (strings), so you must explicitly pass properties for objects or arrays.
  • Server-side rendering can be tricky since Web Components run in the browser; SSR frameworks need extra setup (declarative shadow DOM or hydration scripts) to render them on the server.

Performance Considerations and Optimization Patterns

R68Msb6WrKIEjHdjWHjYg

Every Web Component attaches a shadow root, clones a template, and runs lifecycle callbacks. That work is fast for individual components, but hundreds of instances on one page add up. Keep components small and focused. One component per UI concern, so each instance does minimal work. Avoid heavy DOM manipulation in connectedCallback; if you need data, fetch it once and cache it, or pass it down from a parent.

Lazy loading cuts initial bundle size. Instead of importing every component upfront, dynamically import definitions only when a component appears in the viewport or when the user navigates to a route that needs it. Bundlers can code-split automatically if you use dynamic import() syntax. For example, await import('./legend-table.js') loads the table component on demand, and the browser registers it the moment the script executes.

Centralized data reduces unnecessary updates. If every child component fetches its own data, you’ll see redundant network calls and re-renders. Instead, let a top-level component own the data and pass slices down via properties. When data changes, update the parent’s property once, and each child re-renders only if its specific slice changed. This pattern mirrors unidirectional data flow from frameworks but relies on native property setters and custom events instead of a state library.

Optimization Technique What It Does Impact
Lazy load component definitions Import and register components only when needed, using dynamic import() Smaller initial bundle; faster page load; components appear when user navigates to relevant sections
Minimize shadow DOM operations Clone templates once, reuse nodes where possible, batch DOM updates Faster connectedCallback; reduced layout thrashing; smoother component mounting
Centralize app data in top-level component One parent fetches and owns data, children receive via properties Fewer redundant fetches; predictable update flow; easier debugging
Use CSS variables for theming Define colors and sizes at :root, reference them in component styles Single source of truth for design tokens; instant theme switching without re-rendering components

Testing and Quality Assurance for Web Components

sLMgT3AFVZSpSTEqdUcagQ

Unit testing a Web Component means instantiating the element, setting attributes or properties, and asserting against the resulting DOM or emitted events. Jest works well if you pair it with a DOM environment like jsdom or happy-dom. You create an instance (document.createElement('my-button')), append it to a test container, trigger interactions (click events, attribute changes), then query the shadow root (element.shadowRoot.querySelector('.label')) to verify the output.

Specialized libraries make testing easier. Open Web Components Testing Library wraps common assertions and provides helpers for shadow DOM queries. You write tests that feel like Playwright or Testing Library tests: find elements by role or text, dispatch events, wait for updates. But the library handles the shadow DOM quirks. This approach catches regressions in component behavior without requiring a full browser.

End-to-end tools (Cypress, Playwright) run components in a real browser, so shadow DOM, slots, and events work exactly as they will in production. You can test user flows that span multiple components, verify keyboard navigation and focus management, and screenshot the component to catch visual regressions. Visual regression testing tools (Percy, Chromatic) snapshot your component in different states and flag unexpected style changes across commits.

Common tools and methods:

  • Jest with jsdom or happy-dom gives you fast unit tests; mock DOM environment; manual shadow root queries required.
  • Open Web Components Testing Library provides sugar for shadow DOM assertions; integrates with Mocha or Jest; simplifies attribute and event testing.
  • Cypress or Playwright run full browser E2E tests; real shadow DOM; supports accessibility audits and visual snapshots.
  • Stencil’s built-in test runner comes with Jest plus helpers for component rendering and property/event assertions if you use Stencil.
  • Visual regression tools (Percy, Chromatic) take automatic screenshots of component states; catch styling bugs before they ship.

Accessibility and Security in Web Components

bofY7QgtXXWTKAYdJ9lcpw

Shadow DOM doesn’t change how screen readers or keyboards interact with your component. They still read the DOM tree and follow tab order. But you must ensure semantic HTML and proper ARIA attributes inside your template. If your component wraps a button, use an actual <button> element, not a <div> with click handlers. If you build a custom dropdown, add role="listbox", aria-expanded, and keyboard event listeners for arrow keys and Enter.

Focus management is critical. When a user tabs into your component, the browser focuses the first focusable element inside the shadow root, but you control the order via tabindex. If your component has multiple interactive parts, set tabindex="0" on each to keep them in the natural tab flow. When your component opens a modal or dialog, trap focus inside it and restore focus to the triggering element when it closes.

Security matters because shadow DOM doesn’t sanitize user input. If you accept a label attribute and insert it via innerHTML, an attacker can inject <img src=x onerror=alert(1)> and execute scripts. Always sanitize or escape user-provided strings before rendering them. Use textContent for plain text, or a trusted sanitizer library (DOMPurify) if you need to support safe HTML. Shadow DOM boundaries won’t stop XSS. Treat input the same way you would in any DOM context.

Anti-patterns to avoid:

  • Skipping semantic HTML. Don’t use <div role="button">; use <button>. Screen readers and keyboards expect native elements.
  • Inserting unsanitized user content with innerHTML. This leads to XSS. Use textContent or a sanitizer library for dynamic content.
  • Ignoring keyboard navigation. Components that respond only to mouse clicks exclude keyboard users. Add keydown/keyup handlers for Enter and Space on interactive elements.

Web Components in Design Systems and Micro-Frontends

Design systems distribute UI consistency across teams and projects. Web Components are ideal building blocks because they work in any framework. Your design team publishes one library, and frontend teams consume it in React, Angular, Vue, or plain HTML without rewriting components for each stack. You maintain one source of truth for buttons, form inputs, modals, and navigation, update the library once, and every team pulls the latest version via npm or a CDN.

Micro-frontends split large apps into smaller, independently deployed pieces, often built with different frameworks. Web Components act as the integration layer: each micro-frontend exposes its UI as a custom element, and the shell app assembles them without knowing or caring about their internal stack. A checkout flow built in React can sit next to a product catalog built in Vue, both rendering inside a plain HTML shell that only imports <checkout-app> and <catalog-app>.

Storybook integrates well with Web Components. You document each component as a story: show it in different states (empty, loading, error) with live code examples and prop tables. Storybook parses your component’s attributes and properties automatically if you add JSDoc or use Stencil’s decorators, so non-developers can browse the design system and copy usage examples without reading source code.

Design System Advantage How Web Components Enable It
Cross-framework reuse One component library runs in React, Angular, Vue, and static HTML without rewrites
Single source of design truth All teams pull the same versioned package; updates propagate automatically without manual coordination
Easier onboarding and consistency New developers use familiar HTML tags; design tokens (CSS variables) enforce brand colors and spacing globally

Practical Ways to Start Using Web Components Today

Start small: pick one piece of your UI that you reuse in multiple places (a card, a badge, a tooltip) and convert it to a Web Component. Create a JavaScript file, define a class that extends HTMLElement, add a connectedCallback that builds your template, then register it with customElements.define('my-badge', MyBadge). Drop <my-badge> into your HTML, pass a label via an attribute, and verify it renders. That loop (define, register, use) is the entire workflow.

Next, add reactivity. List your attributes in static get observedAttributes() { return ['label', 'color']; } and implement attributeChangedCallback(name, oldValue, newValue) to update the DOM when someone changes <my-badge label="New">. If you need to pass complex data (objects, arrays), use properties instead of attributes. Set badgeElement.data = { count: 5 } from JavaScript and read this.data inside the component.

Custom events let components talk to parents. Inside your component, dispatch an event when something happens: this.dispatchEvent(new CustomEvent('badge-click', { detail: { id: this.id }, bubbles: true, composed: true })). The parent listens with document.addEventListener('badge-click', handleClick) or attaches a listener to a container element. This pattern keeps your component reusable. It doesn’t know or care what happens when clicked, it just reports the event and lets the parent decide.

To share your component, package it as an npm module or host it on a CDN. A simple package.json with "main": "my-badge.js" and "module": "my-badge.js" lets other developers npm install your-components and import it. If you want zero install friction, upload the file to a CDN (unpkg, jsDelivr) and link it with <script type="module" src="https://cdn.example.com/my-badge.js">. The browser fetches and registers the component automatically.

Document your component with real examples. Write a README that shows the HTML usage, lists all attributes and properties, and includes a screenshot or live demo link. If you use Storybook, every component gets an interactive playground where users can tweak props and see changes instantly. Good documentation turns a component from “I wrote this” into “anyone can use this.”

Five steps to start:

  1. Create a class extending HTMLElement. Add a constructor, optionally attach a shadow root, and define your template.
  2. Implement lifecycle callbacks. Use connectedCallback to set up the component, observedAttributes and attributeChangedCallback to react to attribute changes.
  3. Register with customElements.define. Call customElements.define('x-name', XName) to wire your class to a tag name.
  4. Emit custom events for parent communication. Use dispatchEvent(new CustomEvent('event-name', { bubbles: true, composed: true, detail: data })) so parents can listen and respond.
  5. Package and document. Publish to npm or host on a CDN, write usage examples, and integrate with Storybook or a documentation site.

Final Words

You learned how to define web components, the core APIs (Custom Elements, Shadow DOM, Templates), and how lifecycle and styling rules work.

We covered data flow patterns, tooling, testing, accessibility, performance, and practical steps to build and publish components.

Now sketch a small widget, wire events, and pick a tool like Lit or Stencil to speed work. Web components make reusable UI concrete. Keep building, and you’ll see steady wins.

FAQ

Q: What are Web Components and their core purpose?

A: Web Components are browser-native APIs that let you build reusable custom HTML elements with encapsulated markup, styles, and behavior. They mainly use Custom Elements, Shadow DOM, and HTML templates.

Q: What are the core APIs and main benefits of Web Components?

A: The core APIs are Custom Elements (define elements), Shadow DOM (encapsulate DOM and styles), and HTML templates (cloneable markup). Benefits include style encapsulation to avoid conflicts and reusable widgets across frameworks.

Q: How do custom element lifecycle callbacks and attribute observation work?

A: Lifecycle callbacks run on attach, detach, and attribute change: connectedCallback fires on attach, disconnectedCallback on detach, observedAttributes lists watched attributes, and attributeChangedCallback reacts to attribute updates.

Q: How do Shadow DOM, light DOM, and slots work together?

A: Shadow DOM creates an isolated tree; slots let light DOM content project into that tree. Slotted content stays in the light DOM and can be styled in limited ways using ::slotted from inside the shadow.

Q: How should I style Web Components and support theming?

A: Style components by embedding

Check out our other content

Check out other tags: