What is type="module"?

Adding type="module" to a <script> tag tells the browser to treat the file as an ECMAScript module instead of a classic script. The key differences:

Classic script
<script src="..." defer>
Variables leak to global scope
Must add 'use strict' manually
Executes once, synchronously
IIFE wrappers needed for isolation
Works from file://
ES module
<script type="module" src="...">
Module scope — nothing leaks
Strict mode by default
Deferred by default (like defer)
Each file is its own scope — no wrappers
Requires HTTP server (CORS policy)

Module scope, strict mode, and deferred execution — with a classic script you'd need an IIFE, a 'use strict' directive, and a DOMContentLoaded listener to get all three. With type="module", the browser handles them automatically.

No import, no export

You'll notice our component modules don't use import or export at all. This is intentional. Each .js file is a side-effect module — it wraps its DOM-querying logic in an init() function, calls it once on load, and sets up a MutationObserver to re-run it whenever the DOM changes. There's nothing to export because the module's purpose is its side effect: wiring behavior to HTML elements.

dialog.js — init() + MutationObserver pattern
// No imports — this component has no dependencies
// No exports — the side effect IS the module

function init() {
  document.querySelectorAll('[data-dialog-trigger]:not([data-init])').forEach((trigger) => {
    trigger.dataset.init = '';
    const dialog = document.getElementById(trigger.dataset.dialogTrigger);
    if (!dialog) return;
    trigger.addEventListener('click', () => {
      dialog._trigger = trigger;
      dialog.showModal();
    });
  });
}

init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

There are no import statements either, because each component is fully self-contained. A dialog doesn't depend on a tooltip. A dropdown doesn't share code with a combobox. Each component works on its own — include only the <script> tags you need.

This is the key advantage of the pattern: no bundler, no import maps, no dependency resolution. Drop a <script type="module"> tag onto the page and the component works. Remove it and nothing breaks. You're composing at the HTML level, not the JavaScript level.

Static pages vs. SPAs

Component JS works the same way on both static and dynamic pages — load it once and it handles the rest.

Static page
Add <script type="module"> tags and you're done
Modules run once after the HTML is parsed
All components initialize automatically
Nothing else to think about
SPA / dynamic content
New HTML inserted after page load needs initialization
Each component uses a MutationObserver to watch for new elements
New elements are automatically initialized — no manual re-import needed
Already-initialized elements are skipped via data-init guard

On a static page, the <script> tags run once after the DOM is ready and every component just works. No setup, no configuration.

In an SPA or any page that dynamically updates its HTML, new elements are initialized automatically. Each component module sets up a MutationObserver that watches the document for DOM changes and re-runs its initialization logic. The data-init attribute on every initialized element prevents double-binding — the :not([data-init]) selector ensures only new elements are wired up. There is nothing to call or configure:

how auto-initialization works inside each component module
// Each component module follows this pattern:

function init() {
  document.querySelectorAll('.my-component:not([data-init])').forEach((el) => {
    el.dataset.init = '';
    // ... bind event listeners, set ARIA attributes, etc.
  });
}

// Run once on load
init();

// Auto-run when the DOM changes (SPA navigation, dynamic content, etc.)
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Which components need JS?

Most components are CSS-only. JavaScript is only used when HTML and CSS can't express the behavior — things like keyboard navigation, focus management, and state coordination.

CSS-only — no JS needed
Static display
Badge, Card, Alert, Spinner, Skeleton, Separator, Progress, Image, Avatar (fallback needs JS)
Native interactive
Checkbox, Radio, Select, Switch, Slider, Input, Textarea, File Input
Native with <details>
Collapsible
Needs JavaScript
Modal coordination
Dialog, Sheet, Alert Dialog, Command
Keyboard navigation
Tabs, Dropdown, Combobox, Toolbar, Navigation Menu
Constraint logic
Accordion (single mode), Toggle, Toggle Group
Positioning & wiring
Tooltip, Popover, Context Menu, Calendar, Carousel, Sortable
Programmatic API
Toast — exposes window.toast()

Serving locally

ES modules require HTTP due to browser CORS policy — they won't load from file:// URLs. Use any local server:

any of these work
npx serve .
npx vite .
python3 -m http.server

Browser support

ES modules have been supported in all major browsers since 2018:

browser support
BrowserSupported since
Chrome / Edge61 (September 2017)
Firefox60 (May 2018)
Safari11 (September 2017)

See caniuse.com/es6-module and the MDN Modules guide for full details.