Loomgetting started

Your first preview

Point the loom CLI at a roblox-ts project with no config, and understand what it generates.

The lowest-friction way to see your UI in a browser adds nothing to the project: no vite.config.ts, no index.html, no entry rewrite. Run the CLI and hand it a directory.

Preview a project
pnpm exec loom preview ~/code/my-game

The directory does not have to be the one loom-dev is installed in, and needs no node_modules of its own. The plugin aliases @rbxts/react and friends to absolute paths inside @loom-dev/preview. It also widens server.fs.allow to cover both that package and the previewed project’s workspace root.

A complete project from scratch

If you do not have a roblox-ts tree to point at yet, this is the whole thing — three files, no index.html, no tsconfig.json needed to render:

Project layout
my-preview/
├── package.json
├── vite.config.ts
└── src/
└── main.client.tsx
package.json
{
"name": "my-preview",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@loom-dev/preview": "^0.12.0",
"react": "^18.3.1",
"vite": "^6.0.0"
}
}
vite.config.ts
import { loomPreview } from "@loom-dev/preview/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [loomPreview()] });
src/main.client.tsx
import { useState } from "@rbxts/react";
import { createRoot } from "@rbxts/react-roblox";
function App() {
const [count, setCount] = useState(0);
return (
<screengui>
<frame
AnchorPoint={new Vector2(0.5, 0.5)}
Position={UDim2.fromScale(0.5, 0.5)}
Size={UDim2.fromOffset(260, 140)}
BackgroundColor3={Color3.fromRGB(28, 30, 36)}
>
<uicorner CornerRadius={new UDim(0, 12)} />
<uipadding
PaddingTop={new UDim(0, 20)}
PaddingBottom={new UDim(0, 20)}
PaddingLeft={new UDim(0, 20)}
PaddingRight={new UDim(0, 20)}
/>
<uilistlayout
FillDirection={Enum.FillDirection.Vertical}
HorizontalAlignment={Enum.HorizontalAlignment.Center}
VerticalAlignment={Enum.VerticalAlignment.Center}
Padding={new UDim(0, 12)}
/>
<textlabel
Text={`clicked ${count} times`}
Size={new UDim2(1, 0, 0, 24)}
BackgroundTransparency={1}
Font={Enum.Font.GothamBold}
TextSize={18}
TextColor3={Color3.fromRGB(236, 238, 244)}
/>
<textbutton
Text="click me"
Size={new UDim2(1, 0, 0, 40)}
BackgroundColor3={Color3.fromRGB(64, 118, 246)}
Font={Enum.Font.GothamBold}
TextSize={15}
TextColor3={Color3.fromRGB(255, 255, 255)}
Event={{ Activated: () => setCount((n) => n + 1) }}
>
<uicorner CornerRadius={new UDim(0, 8)} />
</textbutton>
</frame>
</screengui>
);
}
createRoot().render(<App />);
Run it
pnpm install && pnpm vite

Open the printed URL and you get a dark full-viewport page with a rounded card. It holds a label, a blue button, and a count that goes up when you click. UDim2, Color3, Enum and Vector2 are never imported — the plugin installs them as globals before your entry evaluates. Editing main.client.tsx hot-reloads.

What the plugin looks for

With no index.html in the project root, the plugin generates one and serves it from middleware. It is a full-viewport #loom-root mount point on a #14161a background, plus a module script pointing at your entry. To find the entry it takes the first hit from this list:

Entry candidates, in priority order
src/main.client.tsx
src/main.client.ts
src/client/main.client.tsx
src/client/main.client.ts
src/main.tsx
src/main.ts
src/index.tsx
src/client.tsx

These are roblox-ts client-entry conventions, so a normal project already satisfies one. An entry that follows none can be named with the plugin’s entry option. A project with its own index.html keeps it, and the <script type="module"> inside decides what loads.

When neither exists, the CLI exits with the full candidate list rather than serving a blank page:

No entry found
loom: no index.html and no client entry found in /Users/you/code/my-game
looked for: src/main.client.tsx, src/main.client.ts, src/client/main.client.tsx, ...
(or pass --targets to browse *.loom.tsx files as a gallery)

The entry must mount itself

Loom does not call your component for you — the entry has to mount at module top level, the way a roblox-ts client script does:

src/main.client.tsx
import { createRoot } from "@rbxts/react-roblox";
import { App } from "./App";
createRoot().render(<App />);

@rbxts/react-roblox is aliased to loom’s browser client, whose createRoot() ignores the Roblox target instance and creates its own container under #loom-root. Passing Players.LocalPlayer.WaitForChild("PlayerGui") is harmless, so you need not fork your entry for the preview.

For vide the same rule applies with mount:

src/main.client.ts (vide)
import { mount } from "@rbxts/vide";
import { App } from "./App";
mount(App);

Preview many components at once

One entry gives one screen. Gallery mode gives a browsable list: name any file *.loom.tsx, export a preview object from it, and add --targets.

src/scenes/Button.loom.tsx
export const preview = {
render: () => (
<textbutton
Text="click me"
Size={new UDim2(0, 160, 0, 40)}
BackgroundColor3={Color3.fromRGB(64, 118, 246)}
TextColor3={Color3.fromRGB(255, 255, 255)}
/>
),
title: "Button",
} as const;
Browse every scene under the project
pnpm exec loom preview . --targets

Every match gets a sidebar entry, mounts lazily, and is wrapped in its own error boundary. No client entry is needed, and loom build --targets emits the same thing as a static, deep-linkable site. The file contract, glob rules and URL parameters are in Gallery targets.

Flags

loom preview [dir] [--port <n>] [--host] [--targets [glob]]

dir defaults to .. --port takes a number and errors if it is not numeric. --host binds all interfaces, unless followed by a non-flag value used as the bind address. --targets switches to gallery mode.

An optional <dir>/loom.config.ts supplies targets and port when the flags are absent. CLI flags win. See the CLI reference.

What you get

Standard Vite dev-server behaviour, because it is a Vite dev server: HMR on save, source maps, esbuild-speed TSX transpilation, and nothing running rbxtsc. Input works too — clicks reach Activated, typing reaches TextBox, UserInputService fires.

Next step

Scope and status lists what loom renders faithfully and what it accepts and ignores.

After that: Vite integration when you want the preview to live inside your own project, or How it works for the architecture behind it.