Loomguides

Static builds and embedding

Bundle a gallery into a hostable SPA with loom build, then deep-link one scene per iframe using the target/chrome/theme URL contract.

loom build runs Vite’s production build over the same gallery the dev server shows, and emits a self-contained client-only SPA. Nothing on the server side is required to host it. Its reason for existing is embedding: a docs site can iframe a single scene, and the reader gets a real interactive Roblox UI instead of a screenshot.

Build a gallery
pnpm exec loom build ~/code/my-ui --targets src/scenes --out dist-preview --base ./

Flags

loom build [dir] --targets [glob] [--out <dir>] [--base <path>]
FlagDefaultNotes
--targetsrequiredSame semantics as preview. Without it the command exits: loom: build requires --targets [glob] (the static gallery is target-driven).
--outdist-previewResolved against the current working directory, not the project dir.
--base./Public path. The relative default is what makes the output host-anywhere.

Unlike preview, build does not read loom.config.ts. Pass --targets explicitly every time.

What comes out

dist-preview/
index.html
assets/index-<hash>.js # shell + runtime + adapter
assets/index-<hash>.css # gallery chrome
assets/loom_layout_wasm_bg-<hash>.wasm
assets/CardScene.loom-<hash>.js # one async chunk per target
assets/CounterScene.loom-<hash>.js
assets/roboto-latin-400-normal-<hash>.woff2 # …and ~110 more, since 0.9.0

Each target becomes its own lazily-imported chunk, so a gallery with fifty scenes still loads one scene’s code per view. The .wasm binary dominates the transfer size — about 178 kB, roughly 68 kB gzipped, from the wasm-opt-processed build published packages ship — and it is fetched once and cached. A binary built locally with the default dev profile skips wasm-opt and is roughly four times larger.

As of 0.9.0 the engine’s typefaces are part of that output: ~2.8 MB of woff2 across the whole set, in about 110 files. What a page downloads is unchanged — Fontsource declares per-script unicode-range subsets, so a browser fetches only the families and scripts a scene actually paints with.

Under the hood the plugin hands Rollup a genuine module graph rather than the dev server’s virtual modules: a generated index.html as the build input, whose script is a generated entry that imports the globals installer first, then a generated target map of relative import()s — one async chunk each — then the shell. Globals-first is why a built gallery never throws UDim2 is not defined.

rbxassetid:// in a static build

A page served from a bucket has no dev server to resolve asset ids for it, so until 0.7.0 they simply did not paint outside loom preview. The build now does the work up front: it works out which ids the page will ask for, resolves each one through Roblox’s thumbnail API, downloads the image into the output, and writes a manifest the page reads on first use.

dist-preview/
__loom/assets.json # { "6031075938": "__loom/asset/6031075938.png" }
__loom/asset/6031075938.png # the downloaded image

Nothing else changes: Image="rbxassetid://6031075938" paints from your own origin, with no runtime request to Roblox.

Working out which ids takes two passes, because reading the bundle is not enough on its own.

  • The scan reads the emitted output for rbxassetid://<digits>, which finds every id a source spells out.
  • The prerender (0.9.5) covers the ids a source builds at runtime. A component library composes them — `rbxassetid://${iconId}` over a table of ids — and after bundling that is a prefix and a few hundred bare numbers no scan can tell from any other number, so a gallery built from a real UI library used to paint none of its icons. Instead, the build runs your scenes: every gallery target is mounted in node and its live tree is read for the images it actually holds. A 700-icon set contributes the dozen icons your scenes render, not all 700.

Three things are worth knowing before you rely on it:

  • The first render is what gets baked. An image the initial mount never reaches — one behind a hover state, or supplied by a later fetch — stays unresolved. Give those a real URL, or install your own setImageResolver.
  • The prerender needs targets. It runs for gallery builds — loom build, a plugin configured with targets, the Astro and Next embeds. A build configured with a single client entry keeps the scan alone, since that entry mounts itself on import rather than exposing a scene to mount.
  • A build never fails over an image. An id that will not resolve — offline, deleted, moderated — is warned about and left out of the manifest, and so is a target that will not render during the prerender. Composition the prerender could not account for is warned about as well, rather than leaving a blank image unexplained.

To keep a build off the network entirely, turn the bake off wherever the build is configured — loomPreview({ assets: false }), loom build --no-assets, or assets: false on buildGallery and withLoomGallery (0.7.1; earlier versions only had the plugin option).

The URL contract

The built page reads six query parameters. This is the whole embedding API.

ParameterValuesEffect
targeta target’s relative path, URL-encodedSelects and mounts that target on load, without a hash.
chromenoneHides the sidebar and renders one target full-bleed. Any other value (or absent) keeps the full chrome.
themelight | darkSets the stage background and seeds PlayerGui.LoomTheme. Anything other than light is treated as dark.
backgrounda CSS colour(0.6.2) Paints the stage backdrop, overriding the one theme would have used and leaving the rest of the palette alone.
basepx (bare ?base is 960), or none / off / 0(0.6.4) Keeps this logical viewport and scales the stage down to fit, instead of letting the scene reflow into the real one. Off unless asked for as of 0.10.2 — see Previews on a phone.
debugpresent, or 0/false/off/no/none for off(0.10.0) Opens the debug panel on load. A bare ?debug counts as on, so a host page can template a boolean straight through.
Deep-link one scene, chromeless, light
/loom-preview/index.html?target=src%2Fscenes%2FCounter.loom.tsx&chrome=none&theme=light

In chromeless mode there is no hash routing — the mount is fixed to ?target=. In full-chrome mode the shell uses #/<relPath> and ?target= only seeds the initial selection.

?debug=1 is the only way a debug panel appears in a chrome=none embed: the toggle is remembered per tab so an HMR reload does not close it, and an embed deliberately opts out of that memory so a panel never turns up unasked on somebody’s docs page.

?background= for a backdrop that is not either theme

theme picks a whole palette — chrome, text, and one of loom’s two backdrops (#14161a or #f6f9fc). background overrides just the backdrop, so a plain white stage under the light palette is ?theme=light&background=white, and transparent lets the host page show through the iframe. It applies in both gallery modes and to the static build.

Only colours are accepted, through an allowlist: hex, a bare identifier, and the functional forms (rgb(), hsl(), oklch(), color(), light-dark(), …) with arguments that cannot open a nested function. A gradient, a url(...) — anything that could turn a query parameter into a network fetch — is ignored and the theme’s own backdrop stands. A misspelled colour name passes the shape test and simply fails to apply in the browser, which lands in the same place.

The backdrop is decided in the page’s <head> from location.search alone, before the first paint, so an embed no longer flashes the dark default while the bundle, the WASM engine and the target chunk load. That was up to half a second of black on a light or custom-coloured frame, repeated every time a host control changed a parameter and reloaded the iframe.

Live theme switching

A host page can flip an already-loaded frame’s theme without reloading it, by posting a message:

From the embedding page
// GALLERY_ORIGIN is where the built gallery is served from — same-origin
// embeds can use window.location.origin.
frame.contentWindow.postMessage(
{ type: "loom-theme", theme: "dark" },
GALLERY_ORIGIN,
);

The backdrop moves the same way, next to it — so a docs page that switches theme at runtime need not reload the frame to re-point the stage:

Re-point the backdrop live
frame.contentWindow.postMessage(
{ type: "loom-background", background: "#0b0d10" },
GALLERY_ORIGIN,
);
// With no colour, the backdrop goes back to whatever the theme paints.
frame.contentWindow.postMessage({ type: "loom-background" }, GALLERY_ORIGIN);

Both write to the same element the first-paint script did, which makes the shell’s own first pass a no-op rather than a second paint.

The shell listens for exactly { type: "loom-theme", theme: "light" | "dark" } and { type: "loom-background", background?: string }, and ignores everything else. Applying a theme does two things: it toggles a class on the gallery’s root element, and it writes LoomTheme onto Players.LocalPlayer.PlayerGui. That second part is the useful one — a scene can read the host page’s theme through plain Roblox APIs, including GetPropertyChangedSignal("LoomTheme"), with no DOM access from roblox-ts code.

A scene that follows the host page's theme
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui");
const [theme, setTheme] = useState(playerGui.LoomTheme ?? "dark");
useEffect(() => {
const conn = playerGui
.GetPropertyChangedSignal("LoomTheme")
.Connect(() => setTheme(playerGui.LoomTheme));
return () => conn.Disconnect();
}, []);

A working embed pipeline

Shelling out to loom build before every site build works, but it buys a generated artifact that goes stale the moment a scene changes, and no HMR while you edit one. If the host is a Vite-based framework, mount the gallery instead: loom-dev/embed exposes both pipelines as functions — a middleware-mode server for dev, a static build for the output directory. (A Next.js host gets the same two pipelines pre-wired through one config wrapper — see Next.js integration.)

These docs do exactly that, from a ~40-line Astro integration:

src/integrations/loom-preview.ts (abridged)
import { buildGallery, createGalleryServer, findGalleryTargets } from "loom-dev/embed";
export default function loomPreview(): AstroIntegration {
let gallery;
return {
name: "loom-preview",
hooks: {
// Dev: Loom's own Vite server, mounted under the site's base.
"astro:server:setup": async ({ server }) => {
gallery = await createGalleryServer({
root: latticeApp,
targets: "src/preview-targets",
base: "/loom-preview/",
});
server.middlewares.use(gallery.middleware);
},
"astro:server:done": async () => await gallery?.close(),
// Build: the same gallery, emitted next to the site's own output.
"astro:build:done": async ({ dir }) => {
await buildGallery({
root: latticeApp,
targets: "src/preview-targets",
outDir: fileURLToPath(new URL("loom-preview/", dir)),
});
},
},
};
}

Editing a scene in the library checkout now hot-reloads the frame in the docs page — no regeneration step, nothing to keep in sync, and no generated bundle in the repo.

Three details are worth stealing:

  • Mount under the host’s own base, not a hard-coded /loom-preview/, so the previews travel with the site when it moves to a subpath.
  • Make a missing checkout a graceful skip, not a hard failure — CI and fresh clones should still build the site, just without previews. findGalleryTargets() answers “is there anything to serve?” without starting anything. Gate strictness behind an env var for deploy builds.
  • Pin the checkout path. These docs link the component library into .preview-src/, which is also where CI checks it out, so one path convention covers local and CI alike. (Loom itself is an ordinary dependency — @loom-dev/layout ships its wasm engine prebuilt, so the host needs no Rust toolchain.)

The component that renders each frame sets src lazily through an IntersectionObserver (a gallery page can hold a dozen frames), appends &theme= from the site’s current theme at load, and pushes later theme toggles into loaded frames with the postMessage above.

Error containment survives the build

The per-target error boundary is part of the shell, not the dev server. A target that throws in a built, deployed gallery still renders an inline error panel with its stack while the rest of the page keeps working — which is what you want in a docs site, where one broken example should not take down the page it lives on.