Loomguides

Gallery targets

Browse many scenes at once with *.loom.tsx targets — the preview export contract, discovery globs, loom.config.ts, and per-target error containment.

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.

Enable gallery mode
pnpm exec loom preview ~/code/my-ui --targets

With --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:

vite.config.ts
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:

src/scenes/Counter.loom.tsx
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:

FieldRequiredBehavior
renderyesMust 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.
titlenoA 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.tsx
target must export `const preview = { render: () => <.../>, title: "..." } as const`

Discovery

--targets (and the plugin’s targets option) accepts three shapes:

InvocationResulting 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:

loom.config.ts
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.

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 failurefailed to import <path> (a syntax error, a missing module).
  • Bad contractinvalid preview export in <path>.
  • Render throwrender 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.