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.