# Loom
> Render a roblox-ts UI tree as a live web DOM preview, driven by Vite.
- Docs: https://docs.astra-void.xyz/loom/
- Source: https://github.com/astra-void/loom
# Loom
> Render a roblox-ts UI tree as a live web DOM preview, driven by Vite.
Source: https://docs.astra-void.xyz/loom/
Loom takes the `@rbxts/react` (or `vide`) UI tree you already ship to Roblox and renders it in a
browser, with Roblox layout semantics rather than CSS ones. `UIListLayout`, `UIPadding`,
`AutomaticSize`, `UISizeConstraint` and text measurement are computed by a Rust layout engine
compiled to WASM; the result is painted into plain DOM nodes. Clicks, keyboard input and hover
route back through `UserInputService` and instance signals, so a preview is interactive, not a
screenshot.
The delivery mechanism is Vite. A single plugin — `loomPreview()` — aliases `@rbxts/react`,
`@rbxts/react-roblox` and `@rbxts/services` onto browser adapters, installs the Roblox datatype
globals (`UDim2`, `Color3`, `Enum`, `game`) and the Luau standard library before your entry
evaluates, rewrites roblox-ts
`import X = require(...)` statements to ESM, and resolves roblox-ts packages whose `main` points at
uncompiled Luau straight to their TypeScript source. There is no roblox-ts build step in the loop:
esbuild transpiles the same TSX, and HMR works.
One plugin is the whole setup — it generates the page too, so a roblox-ts source tree needs no
`index.html` and no entry wiring:
```ts title="vite.config.ts"
import { loomPreview } from "@loom-dev/preview/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [loomPreview()] });
```
Or skip the config file entirely with `loom preview .` — the CLI is the same plugin with a server
around it.
---
# Installation
> Add loom to a project — the two packages, their peer requirements, the WASM layout engine, and what a source checkout needs instead.
Source: https://docs.astra-void.xyz/loom/getting-started/installation/
Loom ships as two packages, and **you rarely need both**. Pick the row that matches how you want to
run the preview:
| How you want to run it | Install |
| --- | --- |
| **CLI only** — `loom preview` / `loom build`, no Vite project of your own | `loom-dev` |
| **Plugin only** — `loomPreview()` in your own `vite.config.ts` | `@loom-dev/preview` + `react@18` + `vite` |
| **Both** — the CLI *and* your own Vite config | all of the above |
Most projects start on the first row. It is one dependency and no config file:
```bash
pnpm add -D loom-dev
```
Adding the plugin to a Vite project you already own means installing its peers yourself:
```bash
pnpm add -D @loom-dev/preview react@^18.3.1 vite
```
**What is actually in each package**
| Package | What it is | Bin |
| --- | --- | --- |
| `@loom-dev/preview` | The Vite plugin (`loomPreview()`) — generated page, entry detection, gallery mode — the `@rbxts/react-roblox` / `@rbxts/services` browser shims, and the Roblox globals installer. | — |
| `loom-dev` | The `loom preview` / `loom build` CLI: the plugin with a Vite server around it, no config file needed. | `loom` |
`loom-dev` depends on `@loom-dev/preview`, React and Vite outright — not as peers — so the CLI is
self-contained.
Both packages are ESM-only (`"type": "module"`) and published under the MIT license. All
`@loom-dev/*` packages are versioned in lockstep — the changesets config marks them `fixed`, so a
bump to one bumps them all. Mixing versions across `@loom-dev/*` is never a supported combination.
Node 24 or newer is expected (`engines: { node: ">=24" }`).
> **React 18 only**
>
> The React peer is `^18.3.1` and nothing wider. The adapter drives `react-reconciler@^0.29.2`, which
> reads React 18 internals that React 19 renamed, so a React 19 install fails at evaluation time:
>
> ```bash
> pnpm add -D react@^18.3.1 @types/react@^18.3.12
> ```
**Where the React pin bites, and the peers the plugin declares**
Loom `0.4.0` narrowed the range to match the reconciler — before that it advertised `|| ^19.0.0`, and
the failure surfaced at first render instead of at install.
This is also why the plugin aliases bare `react` by absolute path rather than using `resolve.dedupe` —
dedupe would re-anchor React at your project root and could pick a different major. If you see
`Warning: Invalid hook call` with a "more than one copy of React" hint, a React version mismatch is
the first thing to check.
Only the plugin has peers at all; the CLI vendors its own copies. `@loom-dev/preview` declares two:
```json title="From @loom-dev/preview's package.json"
{
"peerDependencies": {
"react": "^18.3.1",
"vite": "^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"vite": { "optional": true }
}
}
```
`vite` is optional because the package's non-Vite entry points (`/client`, `/globals`, `/services`)
are useful without it — but `loomPreview()` obviously is not, so install Vite if you are using the
plugin. The CLI brings its own.
**What you do not install: no roblox-ts step, no Rust, no layout dependency**
There is no roblox-ts compile step in the preview loop, and no separate layout dependency. The Rust
layout engine is compiled to WebAssembly and shipped inside `@loom-dev/layout` as a prebuilt
`.wasm` — you never need a Rust toolchain to *consume* loom, only to develop it.
Published packages ship the `wasm-opt`-processed binary — about 178 kB, roughly 68 kB gzipped.
Irrelevant for local previewing, worth knowing if you [ship a static gallery
build](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md).
**Pre-1.0: what a minor bump is allowed to do**
Loom is published from `latest` and versioned in lockstep across the scope, but it is still pre-1.0:
minor bumps carry behavior changes. `0.3.0` is the first release where the plugin owns the whole
preview — the generated page, entry detection and gallery mode — so anything written against `0.2.x`
that hand-rolled an `index.html` or a globals import still works, but no longer has to.
## Verify
```bash title="Smoke test"
pnpm exec loom preview .
```
Pointed at a roblox-ts project with a client entry, that serves an interactive preview on port 5173.
If you get a dark page with your UI on it, the WASM engine, the runtime, the adapter, the renderer,
and the plugin are all working. If you get a blank page, check the console — see [Your first
preview](https://docs.astra-void.xyz/loom/getting-started/first-preview.md).
## Working from a source checkout
Contributing to loom, or tracking `main` ahead of a release, means consuming the repo instead of the
registry. Two things differ: the toolchain widens, and you link rather than install.
```bash title="Set up a checkout"
git clone https://github.com/astra-void/loom.git
cd loom
pnpm install # a prepare hook runs build:native
```
```json title="package.json in your project"
{
"dependencies": {
"@loom-dev/preview": "link:../loom/packages/preview"
}
}
```
**What the checkout builds, and why link: rather than install**
**The toolchain widens.** The layout engine is built from source, so you need Rust via **rustup**
with the `wasm32-unknown-unknown` target (pinned in `rust-toolchain.toml`) and `wasm-pack`, alongside
Node 24 and pnpm 11.
`pnpm install` builds the WASM engine through `scripts/build-wasm.sh`, emitting into
`packages/layout/pkg/`. Rebuild it with `pnpm build:native`, or
`pnpm build:native:release` for the `wasm-opt`-processed binary the release workflow ships. `pnpm
build:packages` then builds the TypeScript packages with `tsdown`.
**`link:` symlinks rather than copies**, so the package's own `workspace:*` dependencies resolve
through the real path from the checkout's `node_modules` — you never install loom's dependency tree
into your project. Importing the plugin from an *unbuilt* checkout has one extra wrinkle, covered in
[Advanced Vite setup](https://docs.astra-void.xyz/loom/guides/advanced-vite-setup.md#consuming-an-unbuilt-source-checkout).
**On macOS, rustup — not Homebrew — must win**
Homebrew's `rustc` ships no `wasm32-unknown-unknown` standard library, and Homebrew's bin directory
usually sits ahead of rustup's in `PATH`. `scripts/build-wasm.sh` compensates by prepending
`$HOME/.cargo/bin` to `PATH` itself, so the build works on a machine with both. If you invoke
`wasm-pack` by hand, you have to do that yourself.
## Next step
Point loom at a project and get a preview on screen: [Your first
preview](https://docs.astra-void.xyz/loom/getting-started/first-preview.md).
---
# Your first preview
> Point the loom CLI at a roblox-ts project with no config, and understand what it generates.
Source: https://docs.astra-void.xyz/loom/getting-started/first-preview/
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.
```bash title="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.
> **The same thing from a vite.config.ts**
>
> 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:
>
> ```ts title="vite.config.ts"
> 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](https://docs.astra-void.xyz/loom/guides/vite-integration.md).
## 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*:
```text title="Project layout"
my-preview/
├── package.json
├── vite.config.ts
└── src/
└── main.client.tsx
```
```json title="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"
}
}
```
```ts title="vite.config.ts"
import { loomPreview } from "@loom-dev/preview/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [loomPreview()] });
```
```tsx title="src/main.client.tsx"
import { useState } from "@rbxts/react";
import { createRoot } from "@rbxts/react-roblox";
function App() {
const [count, setCount] = useState(0);
return (
setCount((n) => n + 1) }}
>
);
}
createRoot().render();
```
```bash title="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.
> **This project does not typecheck yet**
>
> 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](https://docs.astra-void.xyz/loom/guides/typescript-setup.md).
## 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:
```text title="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 `