Loomguides

Static builds and embedding

Bundle a gallery into a hostable SPA, then deep-link one scene per iframe.

loom build runs Vite’s production build over the same gallery the dev server shows and emits a self-contained client-only SPA, with nothing needed server-side. It exists for 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 fifty-scene gallery loads one scene’s code per view. The .wasm binary dominates transfer size at about 178 kB, or 68 kB gzipped, and is fetched once and cached. The engine’s typefaces are in there too, ~2.8 MB across ~110 files. Fontsource declares per-script unicode-range subsets, so a browser fetches only what a scene paints with.

The build input is a generated index.html. Its script imports the globals installer first, then a target map of relative import()s, then the shell. That 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. The build works out which ids the page will ask for and resolves each through Roblox’s thumbnail API. It downloads the image into the output, then 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.

  • The scan reads the emitted output for rbxassetid://<digits>, finding every id a source spells out.
  • The prerender covers ids a source builds at runtime, like `rbxassetid://${iconId}`. After bundling those are bare numbers no scan can distinguish, so the build runs your scenes. Every target is mounted in node and its live tree read for the images it holds.

Three things are worth knowing before you rely on it:

  • The first render is what gets baked. An image the initial mount never reaches stays unresolved. That covers one behind a hover state and one that arrives from a later fetch. Give those a real URL, or install your own setImageResolver.
  • The prerender needs targets. It runs for gallery builds only. A build configured with a single client entry keeps the scan alone, since that entry mounts itself on import.
  • A build never fails over an image. An id that will not resolve is warned about and left out of the manifest. So is a target that will not render.

To keep a build off the network entirely, turn the bake off wherever the build is configured. That is loomPreview({ assets: false }), loom build --no-assets, or assets: false on buildGallery and withLoomGallery.

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 opts out of that memory.

?background= for a backdrop that is not either theme

theme picks a whole palette: chrome, text, and one of loom’s two backdrops. background overrides just the backdrop, so a white stage under the light palette is ?theme=light&background=white. transparent lets the host page show through.

Only colours are accepted, through an allowlist: hex, a bare identifier, and the functional forms with arguments that cannot open a nested function. A gradient or a url(...) is ignored and the theme’s backdrop stands. The backdrop is decided in the page’s <head> from location.search before the first paint, so an embed never flashes the dark default while the bundle loads.

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. 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, so the shell’s first pass is a no-op rather than a second paint. The shell listens for exactly those two messages and ignores everything else. Applying a theme toggles a class on the gallery root and writes LoomTheme onto Players.LocalPlayer.PlayerGui. That second half is the useful one. A scene can read the host page’s theme through plain Roblox APIs, GetPropertyChangedSignal("LoomTheme") included, 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 the artifact goes stale the moment a scene changes, and there is no HMR while you edit one. If the host is Vite-based, mount the gallery instead: loom-dev/embed exposes both pipelines as functions. (A Next.js host gets them 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 hot-reloads the frame in the docs page — nothing to keep in sync, and no generated bundle in the repo. Three details 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. One 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 rendering each frame sets src lazily through an IntersectionObserver and appends &theme= from the site’s current theme. Later toggles are pushed 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 deployed gallery still renders an inline error panel with its stack, while the rest of the page keeps working.