defuss-shadcn / Guides / es-modules
JavaScript Modules
Interactive components ship as native ES modules on one shared runtime: core.js installs df$ - defuss-query over defuss-morph - and every component binds to it. Load them with <script type="module"> - no bundler, no build step, no framework.
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:
<script src="..." defer>'use strict' manuallydeferfile://<script type="module" src="...">defer) 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 queryfunction 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 throwexport 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.exampleApidf$.exampleStates = exampleStates; // → df$.shadcn.exampleStates// 4. structure renders through keyed morph - never innerHTMLfunction 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 changesfunction 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 });.prop() / .attr() / .val() / .text(); exact moves and mounts through .append() / .before() / .after() / .remove().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.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 markupdfDollar(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.
<script type="module"> tags (core first) and you're doneel.apiMutationObserver to watch for new elementsdata-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 loadnew 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:
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
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 :3000bun run dev # same, without makebunx vite . # bare Vite against dist/ in another projectbunx serve dist/ # any static server workspython3 -m http.server§Browser support
ES modules have been supported in all major browsers since 2018:
| Browser | Supported since |
|---|---|
| Chrome / Edge | 61 (September 2017) |
| Firefox | 60 (May 2018) |
| Safari | 11 (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