Loomgetting started

How it works

From a roblox-ts component to DOM nodes — Scene IR, the WASM layout engine, the renderer and the plugin.

Four stages, each with one job.

@rbxts/react ─┐
vide ├─► Frontend adapter ─► Scene IR ─► Layout engine ─► DOM renderer ─► Browser DOM
luau (later)┘ (TypeScript) (contract) (Rust → WASM) (TypeScript)

The Scene IR in the middle is the design. It is a framework-agnostic description of a Roblox GUI instance tree, mirrored byte-for-byte between a Rust crate, loom-scene, and a TypeScript package, @loom-dev/scene. The layout engine and the renderer only ever see the IR, so neither knows React exists. That is how the vide adapter was added without touching either.

1. The frontend adapter

@loom-dev/react is a custom react-reconciler host config. It produces no DOM. It creates and mutates live loom instances, an in-browser model of Roblox’s Instance tree with real properties, children and signals, exactly as @rbxts/react-roblox would in Studio.

The vide adapter does the same job with fine-grained signals and no reconciler, and lands on the same IR.

2. Scene IR

The instance tree is serialized into plain, untagged data: UDim, UDim2, Vector2, Color3 (0–1 channels), enum items as { enumType, name }. Nothing is a class and nothing carries identity, so it all crosses the WASM boundary cleanly.

3. Layout

@loom-dev/layout is a thin async wrapper over loom-layout-wasm:

await initLayout(); // load the wasm module once (idempotent)
const result = computeLayout(scene, viewport); // synchronous after that

The Rust engine implements Roblox’s own layout rules, not CSS’s: UIListLayout and UIGridLayout flow, UIPadding, UISizeConstraint and UIAspectRatioConstraint, AutomaticSize, anchor points, scale-plus-offset sizing, and text measurement. It computes an absolute rect for every node.

This is the reason loom exists: approximating UIListLayout with flexbox gets close enough to be misleading, and running the real algorithm gets a preview you can trust.

4. DOM rendering

@loom-dev/renderer walks the IR alongside the layout result and emits nested, absolutely-positioned <div>s. It reproduces the layout engine’s id scheme, a positional path counting only layout-participating children, so nodes and rects line up without a side channel.

Two entry points share the per-node CSS mapping:

  • renderScene — one-shot full rebuild via replaceChildren. Used by the vide adapter and anything that only needs a static picture.
  • createDomSession — keyed incremental patching plus pointer-input delegation. Elements persist across frames, so listeners and focus survive a re-render. This is the React path.

Four fidelity rules the renderer honours. The layout root and any LayerCollector are transparent containers and never painted. Visible = false hides via CSS while the node keeps its rect. Text is painted in an aligned overlay layer. And UICorner and UIStroke become border-radius and box-shadow.

Input

Events do not reach your React handlers directly. The renderer hit-tests the DOM event, maps it back to a live LoomInstance through data-loom-id, and dispatches through the instance’s signals. Activated, InputBegan and UserInputService fire the way they do in Roblox.

Pointer capture mirrors Roblox’s input sinking rather than the DOM’s: only TextButton, ImageButton, TextBox, ScrollingFrame and GuiObjects with Active = true sink pointer events. Active governs sinking, not hearing, so a slider handle that is a plain Frame with an InputBegan handler still gets its events. Frames and labels with no listeners are click-through, so a transparent full-screen positioning frame does not swallow clicks meant for the control underneath. A modal scrim, in practice a TextButton, still blocks.

What the Vite plugin does

None of the above runs without the specifiers being rewritten first. loomPreview() does four jobs, plus a fifth. With no index.html in the project, it generates one around the detected client entry, or around the gallery shell when targets is set, in serve and build alike.

  1. Aliasing. @rbxts/react-roblox becomes the browser client, with createRoot and createPortal. @rbxts/services becomes service singletons backed by game.GetService. @rbxts/react becomes a shim over React that adds the React.Event and React.Change keyed-prop namespaces, and @rbxts/vide becomes the vide adapter. Bare react and both JSX runtimes are pinned to one absolute path, so exactly one React instance exists.

  2. Globals injection. installGlobals() runs before your entry, defining UDim2, Color3, Enum, game and the Luau standard library, which roblox-ts code references with no import. Under the dev server that is a script tag in <head>. Under vite build the import is prepended to the page’s entry modules. The same module pulls in the engine’s typefaces.

  3. Import-equals rewriting. roblox-ts sources use import X = require("m"), which esbuild lowers to a bare require() that throws in a browser. A pre-transform rewrites it to import * as X from "m", in serve and build alike.

  4. Luau-main fallback. A roblox-ts package points "main" at compiled Luau. The plugin redirects such packages to their src/index.ts(x) before resolution, so it works even when the package was never compiled. That is what lets loom consume a source-only workspace.

What it does not do is compile roblox-ts: esbuild transpiles the TSX and discards types, with no rbxtsc in the loop and no Luau in the browser.

Next step

Scope and status — what this pipeline covers today, and what it does not.