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.
pnpm exec loom build ~/code/my-ui --targets src/scenes --out dist-preview --base ./The command is loomPreview({ targets }) under vite build and nothing else, so a project whose vite.config.ts passes targets gets the identical bundle from pnpm vite build. See Vite integration.
Flags
loom build [dir] --targets [glob] [--out <dir>] [--base <path>]| Flag | Default | Notes |
|---|---|---|
--targets | required | Same semantics as preview. Without it the command exits: loom: build requires --targets [glob] (the static gallery is target-driven). |
--out | dist-preview | Resolved 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.
The build runs with emptyOutDir: true, so point --out at a directory that belongs to loom and nothing else. Aiming it at your site’s whole public/ would delete the rest.
What comes out
index.htmlassets/index-<hash>.js # shell + runtime + adapterassets/index-<hash>.css # gallery chromeassets/loom_layout_wasm_bg-<hash>.wasmassets/CardScene.loom-<hash>.js # one async chunk per targetassets/CounterScene.loom-<hash>.jsassets/roboto-latin-400-normal-<hash>.woff2 # …and ~110 more, since 0.9.0Each 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.
__loom/assets.json # { "6031075938": "__loom/asset/6031075938.png" }__loom/asset/6031075938.png # the downloaded imageNothing 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
entrykeeps 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.
| Parameter | Values | Effect |
|---|---|---|
target | a target’s relative path, URL-encoded | Selects and mounts that target on load, without a hash. |
chrome | none | Hides the sidebar and renders one target full-bleed. Any other value (or absent) keeps the full chrome. |
theme | light | dark | Sets the stage background and seeds PlayerGui.LoomTheme. Anything other than light is treated as dark. |
background | a CSS colour | (0.6.2) Paints the stage backdrop, overriding the one theme would have used and leaving the rest of the palette alone. |
base | px (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. |
debug | present, 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. |
/loom-preview/index.html?target=src%2Fscenes%2FCounter.loom.tsx&chrome=none&theme=lightIn 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.
A literal ?background=#ffffff never reaches the gallery, because # opens the URL fragment. Both spellings that survive the trip are accepted: percent-encoded (%23ffffff) and bare digits (ffffff).
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:
// 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:
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.
postMessage(msg, "*") delivers to whatever document currently occupies that frame. A theme string is not worth stealing, but the habit gets copied into messages that are. Pass the origin you built the gallery for. The shell’s own listener does not check event.origin. That is deliberate for a dev tool meant to be iframed by arbitrary hosts. It is also a reason to keep the vocabulary at exactly these two messages, both of which only repaint. If you fork the shell and add messages with side effects, add an origin check with them.
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:
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/layoutships 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.
loom build is still the right tool when the host isn’t a Vite app (a Hugo/Jekyll site, a CI job
that publishes the gallery on its own). The output is identical — buildGallery() is the same
pipeline behind a function call.
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.