CDN
Use MoonUI straight from a script tag — no bundler, no build step. There are two routes, and they are not interchangeable. Pick the one that matches what you are building.
Which route should you take?
One works today with the published packages. The other is the supported path we own — and it is not published yet.
Route A — esm.sh
Zero setup, zero build changes
Runs against the already published packages through the third-party esm.sh service. Nothing on our side needs to change.
Choose it for: prototypes, CodePen-style demos, embedded snippets, trying MoonUI in five minutes.
The cost: a third-party service sits on your critical path.
Route B — MoonUI CDN artifacts
The supported path, shipped by us
Prebuilt bundles and a stylesheet published inside the packages themselves, so the artifact and its determinism belong to MoonUI.
Choose it for: long-lived embeds, and any Pro usage where the license gate must live in our own bundle.
The catch: available from the next release of each package — not published at the time of writing.
Route A — esm.sh
Zero setup. Works today with the published packages.
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.3",
"react/": "https://esm.sh/react@19.2.3/",
"react-dom": "https://esm.sh/react-dom@19.2.3",
"react-dom/": "https://esm.sh/react-dom@19.2.3/",
"@moontra/moonui": "https://esm.sh/@moontra/moonui@6.19.2?bundle&external=react,react-dom&target=es2022"
}
}
</script>
<div id="root"></div>
<script type="module">
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { Button } from "@moontra/moonui";
createRoot(document.getElementById("root")).render(
createElement(Button, { variant: "primary" }, "Click me")
);
</script>There is no JSX transform in a plain module script, so the example calls createElement directly.
All three query parameters are mandatory
This is not tuning. Drop any one of them and the setup breaks in a measured, reproducible way. The thing worth documenting here is not "fetch it from esm.sh" — it is the exact URL.
external=react,react-dom
Without it, all 7 of the 7 attempted component scenarios crash, because the page ends up with two Reacts. Measured errors:
React 19: Cannot read properties of null (reading 'useContext') React 18: Minified React error #31
When external is not supplied, esm.sh resolves the package's peer range itself and loads a React copy that is independent of the one in your importmap — canary builds included.
bundle
Without it, Pro does not load at all. Pro has a CSS side-effect import (react-grid-layout/css/styles.css) which, in per-module mode, stays a CSS URL. The browser refuses it:
Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of text/css
That single rejection silently kills the whole module graph. On the free side, dropping bundle issues 464 HTTP requests instead of 17, and doubles the bytes on the wire.
target=es2022
Without it the response carries vary: User-Agent, so the output you receive changes from browser to browser. Pin it.
What was verified
Chromium 140, Firefox 141 and WebKit 26: 7/7 render, 6/6 interactions, 0 console errors — on React 18 and React 19.
?bundle, which Pro depends on entirely.Route B — MoonUI CDN artifacts
The supported path. Artifacts we build, ship and own.
Free package artifacts
@moontra/moonui
dist/cdn/moonui.global.js
IIFE, global name window.MoonUI — 635 285 B
dist/cdn/moonui.esm.js
ESM — 633 712 B
dist/cdn/moonui.css
109 483 B (gzip 18 150 B)
package.json
The unpkg and jsdelivr fields point at dist/cdn/moonui.global.js
Pro package artifacts
@moontra/moonui-pro
dist/cdn/index.global.js
IIFE, global name window.MoonUIPro
dist/cdn/index.esm.js
ESM
dist/cdn/index.css
Includes the design tokens as well, so this single file is enough
Two formats, two different React stories
The format you pick decides which React versions you can use. This is the single most important choice in Route B.
IIFE — .global.js
Expects window.React and window.ReactDOM to already exist on the page, which means it needs React's UMD build.
React 19 does not publish a UMD build, so this format cannot serve React 19 users.
ESM — .esm.js
Loaded with <script type="module"> plus an importmap, so React comes from wherever your importmap points.
If you are on React 19, the ESM route is mandatory.
ESM setup (React 18 or 19)
<!-- Example only: these artifacts are not published yet -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.3",
"react/": "https://esm.sh/react@19.2.3/",
"react-dom": "https://esm.sh/react-dom@19.2.3",
"react-dom/": "https://esm.sh/react-dom@19.2.3/",
"@moontra/moonui": "https://cdn.jsdelivr.net/npm/@moontra/moonui/dist/cdn/moonui.esm.js"
}
}
</script>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@moontra/moonui/dist/cdn/moonui.css">
<script type="module">
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { Button } from "@moontra/moonui";
createRoot(document.getElementById("root")).render(
createElement(Button, { variant: "primary" }, "Click me")
);
</script>IIFE setup (React 18 only)
<!-- Example only: these artifacts are not published yet -->
<!-- IIFE requires React's UMD build, which exists for React 18 only -->
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@moontra/moonui/dist/cdn/moonui.css">
<script src="https://cdn.jsdelivr.net/npm/@moontra/moonui/dist/cdn/moonui.global.js"></script>
<div id="root"></div>
<script>
const { Button } = window.MoonUI;
ReactDOM.createRoot(document.getElementById("root")).render(
React.createElement(Button, { variant: "primary" }, "Click me")
);
</script>Measured in a real browser
Chromium, against the generated artifacts
| Scenario | Render | Interactions | Exports | Page errors |
|---|---|---|---|---|
| iife-r18 | 7/7 | 6/6 | 410 | 0 |
| esm-r18 | 7/7 | 6/6 | 410 | 0 |
| esm-r19 | 7/7 | 6/6 | 410 | 0 |
Components tested: Card + Button, Switch, Tooltip, Accordion, Dialog, Select, Tabs.
Style verification: Button background rgb(16, 103, 244), border-radius 12px, --primary = 217.2 91.2% 51%.
Styling
Applies to both routes
Why a separate CSS artifact is mandatory
The utility classes MoonUI components rely on — bg-primary, border-border and the rest — are normally generated in the consumer's Tailwind build. A CDN consumer has no such build, so those rules would never exist. That is why a separate CSS artifact is required.
In Route B that artifact is moonui.css for the free package and index.css for Pro.
The boundary this does not close
This CSS is generated by scanning our source. Measured consequence: the Tailwind classes you write in your own markup — mt-[37px], text-[13px] — are not in it and will not work.
Two ways around it: add the Tailwind Play CDN to your page (measured, works), or use inline styles.
<!-- Quick start only. Tailwind marks the Play CDN as not for production. -->
<!-- Load the MoonUI tokens first: the theme below maps onto these variables. -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@moontra/moonui/src/styles/tokens.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Same mapping @moontra/moonui/tailwind-preset applies in a real build.
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
}
},
borderRadius: { lg: "var(--radius)" }
}
}
};
</script>Pro licensing on CDN
What works, and what is still only a design
The gate holds
The Pro CDN bundle ships with a license gate. Without a license, Pro components render ProLockScreen instead. Measured: in the unlicensed scenarios, real Pro content appeared in none of 120 frames — no leak.
How you grant a license today
The only measured way to supply a license over CDN today is to put the moonui_license_token key into localStorage by hand.
// The only measured way to grant a Pro license on a CDN page today.
// This is NOT a productized flow - the value is a JSON token payload,
// not a license key, and readLicenseTokenClient() JSON.parses it.
localStorage.setItem("moonui_license_token", JSON.stringify({
valid: true,
hasProAccess: true,
plan: "lifetime",
expiresAt: Date.now() + 86400000,
domain: location.hostname,
timestamp: Date.now()
}));data-moonui-license script attribute flow is at the design stage and has not been implemented.Browser support
Documented per MDN and caniuse, not measured by us.
Chrome / Edge 89+
<script type="importmap">
Safari 16.4+
<script type="importmap">
Firefox 108+
<script type="importmap">
es-module-shims. That polyfill has not been tested by MoonUI.