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.

Works today

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.

Not published yet

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.

index.html
<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.

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.

React 18 only

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.

React 18 and 19

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)

index.html
<!-- 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)

index.html
<!-- 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

ScenarioRenderInteractionsExportsPage errors
iife-r187/76/64100
esm-r187/76/64100
esm-r197/76/64100

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.

index.html
<!-- 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.

console
// 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()
}));

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">

Building a real app?

Install the packages with npm and get your own Tailwind build.

Pro setup outside CDN

The bundler route has a real, productized license flow.