Skip to content

ES Modules

This lesson explains how ES Modules are used to build, register, and distribute Web Components with modern JavaScript import and export syntax.

Why ES Modules Matter for Web Components

Modern Web Components are almost always written as ES Modules. Modules give each component its own file scope, explicit dependencies, and a standard way to import utilities, styles, and base classes before calling customElements.define().

ES Modules are part of the same modern web platform story as Custom Elements, Shadow DOM, and HTML Templates. They replace old global script tags and manual namespace management.

Feature Benefit for Web Components
import / export Clear dependencies between component files
Module scope No accidental global variable collisions
type="module" Native browser module loading
Dynamic import Lazy-load components on demand
Bundlers Optimized builds for production libraries
NPM packages Reusable component distribution

Basic Component Module

// app-alert.js
export class AppAlert extends HTMLElement {
  connectedCallback() {
    this.textContent =
      this.getAttribute("message") || "Hello from a module";
  }
}

customElements.define("app-alert", AppAlert);
<script type="module" src="./app-alert.js"></script>
<app-alert message="Saved successfully"></app-alert>

The module defines and registers the element when it loads. The page uses the custom tag after importing the module script.

Import and Export Patterns

// base-element.js
export class BaseElement extends HTMLElement {
  emit(name, detail) {
    this.dispatchEvent(
      new CustomEvent(name, {
        detail,
        bubbles: true,
        composed: true
      })
    );
  }
}
// status-badge.js
import { BaseElement } from "./base-element.js";

export class StatusBadge extends BaseElement {
  connectedCallback() {
    this.textContent = this.getAttribute("status");
  }
}

customElements.define("status-badge", StatusBadge);
// main.js
import "./status-badge.js";

Export reusable base classes and utilities, then import them into component modules. Use a single entry file to register everything the app needs.

Module Scripts in HTML

<script type="module">
  import { AppAlert } from "./app-alert.js";

  customElements.define("app-alert", AppAlert);
</script>

Module scripts are deferred by default and run in strict mode. They can use top-level import and export without a bundler during development.

Script Type Scope Modern Web Components
type="module" Module scope Recommended
Classic script Global scope Legacy only
Inline module Module scope Good for small bootstraps

Side-Effect Registration Imports

// components/index.js
import "./app-button.js";
import "./app-dialog.js";
import "./app-text-field.js";
// app.js
import "./components/index.js";

Many component files register themselves as a side effect of being imported. An index module imports every component once so the app does not need to call define() manually in multiple places.

Define Elements Once

export function defineAppButton() {
  if (!customElements.get("app-button")) {
    customElements.define("app-button", AppButton);
  }
}

In libraries and SSR apps, guard registration so the same tag is not defined twice. This is especially important when modules may load more than once during development or hydration.

Lazy Loading with Dynamic Import

async function openSettingsDialog() {
  await import("./app-settings-dialog.js");
  const dialog = document.createElement("app-settings-dialog");
  document.body.appendChild(dialog);
}

Dynamic import() loads a component module only when needed. That keeps initial bundles smaller for dialogs, charts, and other heavy widgets.

Importing Styles and Assets

import styles from "./app-card.css?inline";

class AppCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>${styles}</style>
      <article><slot></slot></article>
    `;
  }
}

Build tools such as Vite and Webpack let component modules import CSS, SVG, and other assets. Native browser modules alone do not import CSS as JS without tooling or adoptable stylesheets.

ES Modules with Lit

import { LitElement, html, css } from "lit";

export class CounterButton extends LitElement {
  static properties = {
    count: { type: Number }
  };

  render() {
    return html`
      <button @click=${this.increment}>
        ${this.count}
      </button>
    `;
  }

  increment() {
    this.count++;
  }
}

customElements.define("counter-button", CounterButton);

Lit components are standard ES Modules. You import Lit from npm, extend LitElement, and register the resulting custom element the same way as vanilla components.

Native Modules vs Bundlers

Approach Best For
Native ES Modules Prototypes, docs, small apps, no build step
Vite / Rollup / Webpack Production libraries and optimized apps
Import maps Bare specifier imports in the browser
NPM package exports Design systems consumed by many apps

Import Maps

<script type="importmap">
  {
    "imports": {
      "lit": "./vendor/lit/index.js",
      "@company/design-system": "./design-system/index.js"
    }
  }
</script>

<script type="module">
  import "@company/design-system";
</script>

Import maps let browsers resolve bare package names without a bundler. They are useful for demos and some production setups, though many teams still bundle for performance and compatibility.

Component Library Module Structure

design-system/
├── src/
│   ├── button/
│   │   └── app-button.js
│   ├── dialog/
│   │   └── app-dialog.js
│   └── index.js
├── package.json
└── README.md
// src/index.js
export { AppButton } from "./button/app-button.js";
export { AppDialog } from "./dialog/app-dialog.js";

import "./button/app-button.js";
import "./dialog/app-dialog.js";

A clean module structure separates one component per folder, uses a public index entry, and registers elements as side effects when consumers import the package.

package.json Exports

{
  "name": "@company/design-system",
  "type": "module",
  "exports": {
    ".": "./dist/index.js"
  }
}

Publishing Web Components as an npm package usually means setting "type": "module" and exposing a clear exports field so apps can import your library with standard ESM syntax.

Importing Components in Framework Apps

// React / Vue / Angular app entry
import "@company/design-system";
// Astro page frontmatter
import "../components/app-banner.js";

Framework apps import the module once during bootstrap or page load. After registration, framework templates can render the custom tags normally.

When to Use ES Modules

  • You build Web Components in multiple files.
  • You share utilities, base classes, or tokens between components.
  • You publish a reusable component library.
  • You want lazy loading for large optional widgets.
  • You integrate Lit or other ESM-based libraries.
  • You need predictable dependency boundaries in a design system.

ES Module Best Practices

  • Use type="module" for all modern component scripts.
  • Keep one primary component per module file.
  • Register custom elements in a predictable entry module.
  • Guard against duplicate customElements.define() calls.
  • Prefer explicit imports over hidden global dependencies.
  • Use dynamic import for heavy optional components.
  • Document the public import path for published libraries.

Common Module Mistakes

  • Using classic scripts and polluting the global scope.
  • Forgetting file extensions in native browser imports.
  • Calling define() before dependencies are imported.
  • Defining the same custom tag in multiple modules.
  • Assuming CSS imports work natively without build tooling.
  • Mixing CommonJS and ESM in the same component package.
  • Importing components after the page already rendered their tags.

Key Takeaways

  • ES Modules are the standard way to author Web Components today.
  • import and export organize dependencies cleanly.
  • Module scripts register elements without global namespace clutter.
  • Dynamic import enables lazy-loaded custom elements.
  • Bundlers and npm exports support production design systems.
  • Good module structure makes components easier to reuse across apps.

Pro Tip

Create one index.js entry that imports every component module for registration, then expose that entry as the public package import. Consumers only need import "@your/design-system" to activate the full library.