loomPreview() is the whole product. The loom CLI is a thin wrapper around it — a Vite server with
configFile: false and this one plugin — so anything the CLI does, your own vite.config.ts gets by
calling the plugin yourself, including the parts that used to be CLI-only: the generated page, the
client-entry detection, and gallery mode.
Everything below was verified end to end against Vite 6.4.3 and pnpm 11, on a project sitting outside loom’s own workspace.
1. Dependencies
{ "name": "my-game-ui", "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" }}React and Vite are peers of @loom-dev/preview, so they are yours to install. React 18 is the only
supported major — the adapter’s react-reconciler@0.29 targets it, and the peer range says so. See
Installation for the reasoning.
2. The config
import { loomPreview } from "@loom-dev/preview/vite";import { defineConfig } from "vite";
export default defineConfig({ plugins: [loomPreview()],});That is genuinely the whole config, and there is no index.html to write either. vite and
vite build now work against a roblox-ts source tree as-is:
loomPreview()returns an array of plugins and contributes its ownesbuild,optimizeDeps,resolve.aliasandserver.fs.allowthrough theconfighook, deep-merged with — and overridable by — whatever you write.- With no
index.htmlin the project root, it generates one: a full-viewport#loom-rooton a#14161abackground plus a module script pointing at your client entry, which it finds by walking the roblox-ts conventions (src/main.client.tsxfirst — the full list is in Your first preview).
See the plugin reference for the exact contributions.
3. Options
Every option is optional; the defaults are what the CLI uses.
loomPreview({ entry: "src/ui/boot.tsx", // only if your entry isn't a conventional name targets: "src/scenes", // gallery mode: glob, directory, list, or true title: "my-game-ui", // <title> of the generated page html: false, // opt out of the generated page entirely})| Option | Type | Default | Effect |
|---|---|---|---|
entry | string | auto-detected | The client entry, root-relative (/src/boot.tsx) or relative to the project root. Only needed when none of the conventional names fit. |
targets | string | string[] | true | — | Gallery mode. Same semantics as the CLI’s --targets: a glob, a directory (expanded to <dir>/**/*.loom.tsx), a list, or true for the default glob. No client entry is needed. |
title | string | "loom preview" / "loom gallery" | <title> of the generated page. |
html | boolean | true | false keeps the plugin out of the HTML business entirely — no generated page, no entry detection, no Rollup input. Only the module plumbing stays. |
The generated page only fills in when the project root has no index.html. If it has one, Vite
serves it as usual and the plugin stays out of the way — in that case the #loom-root element is
yours to provide (see below). Gallery mode is the one exception: targets means you asked for the
sidebar shell, so its page wins over a project index.html.
4. Writing your own HTML entry
If you do keep an index.html, it needs one thing loom cares about: an element with
id="loom-root". If it is missing, createRoot() creates one and appends it to <body>, but then
it is unstyled and the preview will not fill the viewport.
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <title>my-game-ui preview</title> <style> html, body { margin: 0; height: 100%; background: #14161a; } #loom-root { position: relative; width: 100vw; height: 100vh; overflow: hidden; } </style> </head> <body> <div id="loom-root"></div> <script type="module" src="/src/main.client.tsx"></script> </body></html>That is exactly what the plugin generates, so the reason to write it yourself is wanting something else in the document — a font link, a wrapper element, your own background.
The entry itself is an ordinary roblox-ts client entry — see Your first
preview. At this point vite serves an interactive preview:
state updates, Activated handlers and text input all work.
5. vite build produces a static preview
pnpm vite build # → dist/The build emits a self-contained client-only SPA: your entry, the adapter, the renderer and the WASM
layout binary, hostable anywhere. The Roblox datatype globals are part of the bundle — the plugin
prepends import ".../globals.ts" to the page’s entry modules, so installGlobals() runs before any
module that touches UDim2 evaluates. That applies to the generated page and to your own
index.html, whose module scripts it reads out of the file.
Before 0.3.0 the globals were dev-server-only: a stock vite build compiled cleanly, shipped, and
then threw ReferenceError: UDim2 is not defined on first render unless you wrote
import "@loom-dev/preview/globals"; as the first import of your entry. That import is now
unnecessary — and still harmless if you keep it, since the installer is idempotent.
6. Gallery mode from the plugin
Pass targets and the same *.loom.tsx gallery the CLI serves comes up under vite, and
vite build emits it as a static, deep-linkable site:
export default defineConfig({ plugins: [loomPreview({ targets: "src/scenes" })],});Each target becomes its own async chunk, and the built page honors the same
?target= / ?chrome= / ?theme= / ?background= / ?base= URL contract
loom build produces — they are the same code path. The target contract itself is covered in
Gallery targets.
Next steps
Three things reliably break a working setup once it moves into a real workspace: importing the plugin by file path, pointing it at an unbuilt source checkout, and sharing a Vite config with an app that has its own React. All three are in Advanced Vite setup.
For error strings and what each one actually means, see Troubleshooting.