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.
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. A project whose
vite.config.ts passes targets gets the identical bundle from pnpm vite build, with outDir and
base coming from its own config. 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, because the output directory normally sits outside Vite’s
root and Vite would otherwise refuse to clear it. Point --out at a directory that belongs to loom
and nothing else. Aiming it at, say, your site’s whole public/ would delete the rest of that
directory’s contents.
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 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.
__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, 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 withtargets, the Astro and Next embeds. A build configured with a single cliententrykeeps 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.
| 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 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.
A literal ?background=#ffffff never reaches the gallery: # opens the URL fragment, which is also
where the gallery keeps its route. Both spellings that survive the trip are accepted — percent-encoded
(%23ffffff) and bare digits (ffffff). No CSS named colour is spelled with hex digits alone, so
the bare form is unambiguous.
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:
// 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:
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.
postMessage(msg, "*") delivers to whatever document currently occupies that frame. A theme string
is not worth stealing, but "*" is a habit that costs nothing to avoid and gets copied into
messages that are worth stealing. Pass the origin you actually built the gallery for.
The shell’s own listener does not check event.origin — it accepts a well-formed loom-theme or
loom-background message from any parent. That is deliberate for a dev tool meant to be iframed by
arbitrary hosts, and it is a reason to keep the message vocabulary at exactly these two, 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 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:
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/layoutships 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.
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 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.