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 previewed project itself: no vite.config.ts, no index.html, no entry rewrite. You 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 it does not need a node_modules of its own. The plugin aliases @rbxts/react and friends to absolute paths inside the installed @loom-dev/preview package precisely so a foreign tree resolves; server.fs.allow is widened to cover both that package’s directory 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.11.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 in the middle: 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 real 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. That generated page is minimal — a full-viewport #loom-root mount point on a #14161a background, plus a module script pointing at your entry. To find the entry it walks this list in order and takes the first hit:

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 — and an entry that follows none of them can be named explicitly with the plugin’s entry option. If your project has its own index.html, the plugin leaves it alone and Vite serves it as the entry document — in that case the <script type="module"> inside it 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, exactly 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 you would normally pass and creates its own absolutely-positioned container under #loom-root. Passing Players.LocalPlayer.WaitForChild("PlayerGui") is harmless — the argument is accepted and discarded — so you do not have to 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 you one screen. A component library wants a browsable list instead, which is what gallery mode is: 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 — one broken scene does not take the page down. No client entry is needed in gallery mode, and loom build --targets emits the same thing as a static, deep-linkable site. The target file contract, the glob rules and the URL parameters are in Gallery targets.

Flags

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

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

An optional <dir>/loom.config.ts supplies targets and port when the flags are absent; CLI flags always win. See the CLI reference for the exact precedence rules.

What you get

Standard Vite dev-server behavior, because it is a Vite dev server: HMR on save, source maps, and esbuild-speed TSX transpilation. Nothing runs rbxtsc. Input works — clicks reach Activated, typing reaches TextBox, and UserInputService fires — so state-driven UI can be exercised in the browser rather than in Studio.

Next step

Before you trust what you see, read Scope and status — it 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.