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.
pnpm exec loom preview ~/code/my-gameThe 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 config containing nothing but the plugin gets you exactly this, generated page and entry detection included:
import { loomPreview } from "@loom-dev/preview/vite";import { defineConfig } from "vite";
export default defineConfig({ plugins: [loomPreview()] });vite then serves the preview and vite build bundles it. See Vite
integration.
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:
my-preview/├── package.json├── vite.config.ts└── src/ └── main.client.tsx{ "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" }}import { loomPreview } from "@loom-dev/preview/vite";import { defineConfig } from "vite";
export default defineConfig({ plugins: [loomPreview()] });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 />);pnpm install && pnpm viteOpen 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.
esbuild strips types without reading a tsconfig.json, so it renders regardless. Your editor will redline @rbxts/react, UDim2 and the lowercase intrinsics until you add the ambient declarations — see TypeScript setup.
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:
src/main.client.tsxsrc/main.client.tssrc/client/main.client.tsxsrc/client/main.client.tssrc/main.tsxsrc/main.tssrc/index.tsxsrc/client.tsxThese 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:
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:
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.
Two seconds after load, if #loom-root still has no child elements, loom logs:
[loom] nothing mounted into #loom-root after 2s — does your entry call createRoot().render(<App />) at the top level?
That is the most common first-run problem: an entry that exports a component instead of rendering one.
For vide the same rule applies with mount:
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.
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;pnpm exec loom preview . --targetsEvery 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.