A single-entry preview shows one tree. Gallery mode shows a sidebar of every scene in the project and
mounts one at a time, each in its own root with its own error boundary. It is the mode you want while
building a component library, and it is the only mode loom build supports.
pnpm exec loom preview ~/code/my-ui --targetsWith --targets present, loom stops looking for a client entry entirely — the gallery shell does the
mounting — and serves its own generated page even if the project ships an index.html.
The same mode is one option away in a project that owns its Vite config:
export default defineConfig({ plugins: [loomPreview({ targets: "src/scenes" })],});vite then serves the gallery and vite build emits it as a static site — the same pipeline loom preview --targets and loom build run.
The target contract
A target is any file matching the discovery glob that exports a preview object:
import { useState } from "@rbxts/react";
function CounterScene() { const [count, setCount] = useState(0); return ( <screengui Name="CounterScene"> <frame Size={UDim2.fromOffset(280, 0)} AutomaticSize={Enum.AutomaticSize.Y} Position={UDim2.fromScale(0.5, 0.5)} AnchorPoint={Vector2.new(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(28, 32, 38)} > <uicorner CornerRadius={UDim.new(0, 12)} /> <uilistlayout Padding={UDim.new(0, 12)} /> <textlabel Text={`count: ${count}`} /* ... */ /> <textbutton Text="click me" Event={{ Activated: () => setCount((c) => c + 1) }} /* ... */ /> </frame> </screengui> );}
export const preview = { render: () => <CounterScene />, title: "Counter (Activated)",} as const;Two fields matter:
| Field | Required | Behavior |
|---|---|---|
render | yes | Must be a function returning a React element. The shell renders it as a function component (React.createElement(preview.render)), so hooks inside it work and React re-renders it on its own schedule. |
title | no | A non-empty string replaces the sidebar’s relative-path label. Titles are loaded lazily on idle, so the sidebar shows paths first and upgrades in place. |
Anything else — a default export, a bare component export, preview without a callable render —
produces an inline error panel rather than a broken page:
invalid preview export in src/scenes/Counter.loom.tsxtarget must export `const preview = { render: () => <.../>, title: "..." } as const`Because the shell passes preview.render to React.createElement, a render function that closes over
mutable module state will re-run whenever React re-renders. Put stateful logic in hooks inside the
scene component, not in the render thunk.
Discovery
--targets (and the plugin’s targets option) accepts three shapes:
| Invocation | Resulting glob |
|---|---|
--targets (no value), or targets: true | **/*.loom.tsx |
--targets src/scenes (no *) | src/scenes/**/*.loom.tsx |
--targets "apps/*/src/**/*.scene.tsx" | used verbatim |
The option also takes an array of either shape; the CLI flag does not.
The matcher is a small purpose-built one, not picomatch: **/ matches any depth including none,
** matches anything, * matches anything except /, and every other character is literal. There is
no brace expansion, no ?, no negation, no extglob. The walk skips node_modules and any directory
starting with ., and results are sorted, so sidebar order is stable and path-alphabetical.
Discovery re-runs on the fly: adding or deleting a *.loom.tsx file invalidates the virtual module
and triggers a full page reload. The URL hash survives that reload, so the selected target stays
selected. Editing a target’s contents is ordinary HMR.
loom.config.ts
When you get tired of typing flags, drop a config file in the project root:
export default { targets: "src/scenes", port: 5204,};Only two fields are read: targets (a string or non-empty string array) and port (a number). CLI
flags always win over the file. The file is imported through tsx, so TypeScript and ESM both work,
and an import failure is downgraded to a warning rather than aborting the preview.
The CLI validates that targets is present and well-typed before honoring anything in the file. A
config that has, say, a server.port and a targetDiscovery object — the shape an older loom used —
is ignored entirely, including its port, and you get:
loom: loom.config.ts found, but its default export has no `targets` field — skipping it(legacy config?). Use `--targets [glob]` or export`{ targets: string | string[], port?: number }` to enable gallery mode.This is a hint, not an error: the preview continues in single-entry mode. If you see it, the file is doing nothing.
Error containment
The gallery chrome is deliberately plain DOM — no React outside a single error boundary — so a target that explodes cannot white-screen the page. Three failure classes are caught separately and rendered into an inline red panel with the stack, while the sidebar stays interactive:
- Import failure —
failed to import <path>(a syntax error, a missing module). - Bad contract —
invalid preview export in <path>. - Render throw —
render error in <path>, caught by the boundary around the target.
Switching to another target clears the panel. A target that throws on purpose is a reasonable thing to keep in a gallery as a regression check for exactly this behavior.
Inspecting a target
The sidebar header carries a debug button (also Ctrl+Alt+D, also ?debug=1) that opens a
panel over the stage reporting what the mounted target is actually doing — import and first-frame
timings, the logical viewport it laid out against, the live instance tree, which typefaces really
loaded, and a hover inspector for the scene. It is off by default and runs nothing while closed. See
The debug panel.
Routing
In full-chrome mode the shell uses hash routing: selecting a target sets #/<relPath>, and the hash
is the source of truth across reloads. An initial ?target=<relPath> seeds the selection on first
load without a hash — that is the deep-link contract the static build and docs-site iframes use, and
it is documented in Static builds and
embedding.