Theme
On this page (9)

§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
Blocks parsing without defer
IIFE wrappers needed for isolation
Generally 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 either defer or a DOMContentLoaded listener to get the same combination. With type="module", the browser handles them automatically.

§One runtime, loaded first

Every interactive component runs on the same runtime: core.js installs the callable df$ - defuss-query (selection, traversal, scalar writes, events) over defuss-morph (key-aware DOM reconciliation) - plus the shared component layer at df$.shadcn.shared. It is the only script that creates df$. Load it before any component script, or load all.js, which embeds the same core first. A component without core fails fast with one actionable load-order error, before it renders or registers anything.

<!-- modular: core first, then only the components you use -->
<script type="module" src="components/core.min.js"></script>
<script type="module" src="components/dialog/dialog.min.js"></script>
<script type="module" src="components/tabs/tabs.min.js"></script>
<!-- or everything at once (all.js embeds core first) -->
<script type="module" src="components/all.min.js"></script>

§Anatomy of a component module

Components are authored in TypeScript under src/components/ and compiled to plain ES modules in dist/. Each module imports two things from the shared layer - its registry namespace (defussGlobals()) and the query runtime (defussQuery()) - and exports its State API. The shape below is condensed, but every part of it is what the shipped components do and what verify checks:

// src/components/example/example.ts - the source (build.ts compiles it to dist/)
import { defussGlobals, defussQuery } from '../../shared/state-api.js';
const df$ = defussGlobals();     // OUR registry namespace: df$.shadcn (not the global callable)
const dfDollar = defussQuery();  // the installed runtime: defuss-query over defuss-morph
// 1. declared states - 'default' first (agents + tests drive them by name)
const exampleStates = ['default', 'open'];
// 2. the ONLY function that touches the DOM for a state change - scalar writes via query
function triggerStateChange(el, stateName) {
  const open = stateName === 'open';
  dfDollar(el).attr('data-open', open ? '' : null);              // null removes the attribute
  dfDollar(el).find('.example-trigger').attr('aria-expanded', String(open));
}
// 3. registry API - element passed explicitly, unknown names throw
export const exampleApi = {
  setState(el, stateName, config = {}) {
    if (!exampleStates.includes(stateName)) {
      throw new Error(`example: unknown state "${stateName}" (supported: ${exampleStates.join(', ')})`);
    }
    triggerStateChange(el, stateName, config);
    el.dataset.stateName = stateName;  // state lives on the ELEMENT, never in module scope
    el._stateConfig = config;
  },
  getState: (el) => ({ name: el.dataset.stateName || 'default', config: el._stateConfig ?? {} }),
};
df$.exampleApi = exampleApi;         // → df$.shadcn.exampleApi
df$.exampleStates = exampleStates;   // → df$.shadcn.exampleStates
// 4. structure renders through keyed morph - never innerHTML
function renderDays(grid, isoDays) {
  // stable ids = identity: matched nodes MOVE, so focus and listeners survive
  dfDollar(grid).morph(isoDays.map((iso) => `<button id="d${iso}">${iso.slice(-2)}</button>`).join(''));
}
// 5. per-instance binding with a double-init guard, auto-init on DOM changes
function init() {
  document.querySelectorAll('.example:not([data-init])').forEach((el) => {
    el.dataset.init = '';
    el.api = {
      setState: (stateName, config) => exampleApi.setState(el, stateName, config),
      getState: () => exampleApi.getState(el),
    };
    // native protocols stay native (listeners, showModal, showPopover, focus)
    el.querySelector('.example-trigger')?.addEventListener('click', () => {
      exampleApi.setState(el, exampleApi.getState(el).name === 'open' ? 'default' : 'open');
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });
defuss-query for writes
Flags, ARIA attributes, classes, values and literal text go through .prop() / .attr() / .val() / .text(); exact moves and mounts through .append() / .before() / .after() / .remove().
defuss-morph for structure
df$(container).morph(next) reconciles a subtree by key/id instead of replacing innerHTML: matched nodes are moved, never recreated, so focus, selection and listeners survive a re-render.
Native protocols stay native
showModal(), showPopover(), focus(), drag-and-drop and plain listeners on authored markup remain platform APIs. verify's DOM-boundary gate rejects innerHTML =, appendChild and friends in components that use the runtime.

§What ships in dist/

The shared layer is emitted once, inside core.js. The build rewrites each component's shared import into a small binding to that installed runtime, guarded by the release's ABI stamp - so no component carries its own copy of the helpers, and a component from a different release refuses to run instead of misbehaving:

// dist/components/dialog/dialog.js - generated by scripts/build.ts (do not edit)
// The shared import is rewritten into a binding to the ONE runtime core installed:
var __df$core = globalThis.df$;
var __df$shared = __df$core && __df$core.shadcn && __df$core.shadcn.shared;
if (!__df$shared || __df$shared.abi !== '<this release>') {
  throw new Error(
    'defuss-shadcn: runtime incomplete; load core before component scripts, or load all alone',
  );
}
const { defussGlobals } = __df$shared;
const df$ = defussGlobals();
// ...the component body follows, unchanged

§From the shipped components

Real lines from the current sources - how the rendering components use the runtime. Details and the full operation table: DOM Querying & Morphing; the per-element contract: State API.

// calendar - the month grid re-renders through keyed morph (ISO-date cell ids):
dfDollar(grid).morph(renderGrid(year, month, selectedDay, calId, minDate, maxDate));
// carousel - dots keyed by id, reconciled in ONE morph pass:
dfDollar(dotsContainer).morph(html);
// combobox / command - filtering is scalar writes; the listbox is never rebuilt:
dfDollar(item).prop('hidden', !match);
dfDollar(item).attr('aria-selected', 'true');
dfDollar(list).find('.command-separator').prop('hidden', !!query);
// pagination - page links that stay visible MOVE (node identity → focus survives):
dfDollar(nextLi).before(node);
// toast - mount, fill and dismiss through query's exact structural ops:
dfDollar(document.body).append(toastContainer);
dfDollar(p).text(title);            // literal user text - never parsed as markup
dfDollar(el).remove();

Components still don't depend on each other: a dialog shares no code with a tooltip, a dropdown none with a combobox. The one shared dependency is core. Drop the script tags onto the page in the right order (core, then components) and they work; remove a component and nothing else breaks. You compose at the HTML level - no bundler, no import maps, no dependency resolution between components.

§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 the <script type="module"> tags (core first) and you're done
Modules run once after the HTML is parsed
All components initialize automatically
Every instance gets its el.api
SPA / dynamic content
New HTML inserted after page load needs initialization
Each component uses a MutationObserver to watch for new elements
New elements are initialized automatically - no re-import, no re-render
Already-initialized elements are skipped via the data-init guard

In an SPA or any page that updates its HTML, each component module watches the document with a MutationObserver and re-runs its initialization. The data-init attribute on every initialized element prevents double-binding - the :not([data-init]) selector wires up only new elements, so even many observers stay cheap. Morph-rendered structure keeps its identity across re-renders, so an already-initialized element is never initialized twice:

// auto-initialization, inside every component module:
function init() {
  document.querySelectorAll('.my-component:not([data-init])').forEach((el) => {
    el.dataset.init = '';            // the guard: first line, every time
    el.api = { /* setState / getState bound to this element */ };
    // ... listeners, ARIA wiring
  });
}
init();                              // once on load
new MutationObserver(init).observe(document, { childList: true, subtree: true });

§Which components need JS?

Most components are CSS-only. JavaScript is used only where HTML and CSS can't express the behavior - keyboard navigation, focus management, state coordination and rendering. This list is generated from the component tree at build time (a component ships JavaScript when it has a {name}.ts source), so it is always current:

CSS-only - no JS needed (168)

Pure markup + CSS - native elements (details, form controls, progress, meter) carry the behavior.

About Intro · Active Filters · Address Form · Alert · Announcement · Application Form · Archive Index · Article Body · Article Header · Article Navigation · Audio Player · Author Bio · Author List · Badge · Before After · Blog · Blog Header · Blog Item · Booking Form · Brand Logos · Breadcrumb · Breadcrumbs · Bubble · Button · Button Group · Card · Cart Item · Cart Summary · Case Preview · Case Study · Category Menu · Checkbox · Code Example · Collapsible · Collection Item · Collection Pagination · Coming Soon · Comment Form · Comment Header · Comment Item · Comparison Table · Contents · Credential Item · CTA · Date Picker · Delivery Options · Discount Form · Dock · Docs Content · Docs Navigation · Download Item · Empty State · Error State · Event Countdown · Event Description · Event Header · Event Item · FAB · FAQ · Feature Details · Feedback Form · Filter Bar · Filter Sidebar · Form · Form Progress · Gallery Item · Get In Touch · Heading Anchor · Help Article · Help Category · Hero · Icon · Indicator · Input · Integration Item · Job Details · Job Item · Kbd · Label · Load More · Location Item · Location Map · Locator Search · Login Form · Maintenance · Marker · Media Gallery · Message · Code Mockup · Motion · Navbar · News Header · News Item · News Ticker · Newsletter · Notification Settings · Offer Banner · Opening Hours · Order Confirmation · Order Summary · 404 Page · Parallax · Password Reset · Payment Form · Playlist Item · Press Item · Pricing · Process Step · Product Item · Profile Form · Profile Header · Project Details · Project Item · Quick Links · Radio Group · Rating · Registration Form · Related Item · Release Header · Release Item · Reset Request · Resource Item · Roadmap Item · Scroll Area · Search Box · Search Result · Search Suggestions · Search Summary · Section Header · Security Settings · Select · Separator · Service Item · Session Item · Share Links · Signup Form · Site Footer · Site Header · Skeleton · Social Login · Social Post · Sort Control · Speaker Item · Spinner · Statistic · Stats · Success State · Support Form · Survey Question · Swap · Switch · Tag Cloud · Tag List · Team Member · Testimonials · Text Media · Text Rotate · Textarea · Timeline · Timeline Item · Table of Contents · Tracking Status · Type Badge · Typography · Use Case · Video Player · View Switcher · Wishlist Item

Ships JavaScript (51)

Keyboard models, focus management, state coordination, rendering - and every one exposes the State API.

Accordion · Alert Dialog · Animation Canvas · Avatar · Border Layout · Calendar · Carousel · Chart · Color Picker · Combobox · Command Palette · Context Menu · Cookie Consent · Countdown · Dialog · Diff · Dropdown Menu · File Input · Image · Menubar · Mermaid · Navigation Menu · Number Input · OTP Input · Pagination · Panel · Popover · Presentation · Product Showcase · Progress · Radial Progress · Resizer · Search & Filter · Session · Sheet · Sidebar · Slider · Sortable · Steps · Table · Tabs · Theme Switcher · Toast · Toggle · Toggle Group · Toolbar · Tooltip · Tree View · Typewriter · Virtual List · Window

§Serving locally

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

make dev              # this repo: Vite dev server on :3000
bun run dev           # same, without make
bunx vite .           # bare Vite against dist/ in another project
bunx serve dist/      # any static server works
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.

Comments, ideas or improvements? Edit this page's source on GitHub