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.
pnpm exec loom preview ~/code/my-gameThe 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.
If the project would rather own its Vite config, a config containing nothing but the plugin gets you exactly this — including the generated page and the entry detection below:
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.11.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 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.
esbuild strips types without reading a tsconfig.json, so it renders regardless. Your editor will
still 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.
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:
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 — 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:
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:
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.
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 single most common first-run problem: an entry file that exports a component instead of rendering one. Check the browser console before assuming the layout engine failed.
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 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.
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 — 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.