# 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 ` ``` 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](https://docs.astra-void.xyz/loom/getting-started/first-preview.md). At this point `vite` serves an interactive preview: state updates, `Activated` handlers and text input all work. ## 5. `vite build` produces a static preview ```bash 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. > **Older versions needed a manual globals import** > > 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: ```ts title="vite.config.ts" 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](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#the-url-contract) `loom build` produces — they are the same code path. The target contract itself is covered in [Gallery targets](https://docs.astra-void.xyz/loom/guides/gallery-targets.md). ## 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](https://docs.astra-void.xyz/loom/guides/advanced-vite-setup.md). For error strings and what each one actually means, see [Troubleshooting](https://docs.astra-void.xyz/loom/guides/troubleshooting.md). --- # Advanced Vite setup > The three things that bite once loomPreview() is in a real workspace — import specifiers, unbuilt source checkouts, and sharing a Vite config with a React app. Source: https://docs.astra-void.xyz/loom/guides/advanced-vite-setup/ [Vite integration](https://docs.astra-void.xyz/loom/guides/vite-integration.md) covers the setup that works. This page covers the three ways a working setup stops working: importing the plugin the wrong way, pointing it at an unbuilt checkout, and sharing a Vite config with an app that has its own React. ## Always import the plugin by its bare specifier `loomPreview()` locates the modules it aliases — the `@rbxts/react-roblox` client shim, the `@rbxts/services` shim, the globals installer, the React shim, the gallery shell — **relative to its own file**, as `../src` from the module that is executing. That is what lets it hand Vite absolute paths that work whether loom is installed in your project, linked from a checkout, or pointed at a completely different workspace by the CLI. It also means the import specifier you write is load-bearing. Import `@loom-dev/preview/vite` and nothing else: ```ts // correct import { loomPreview } from "@loom-dev/preview/vite"; // broken — do not do this import { loomPreview } from "../loom/packages/preview/src/vite.ts"; ``` A path import lets Vite's config bundler inline the plugin into a temporary file inside *your* project, which moves the anchor. The config then loads fine and the failure surfaces much later, during the build, as something that looks unrelated: ```text [commonjs--resolver] Failed to resolve entry for package "@loom-dev/layout". The package may have incorrect main/module/exports specified in its package.json. ``` ## Consuming an unbuilt source checkout If you `link:` a loom checkout and have not run `pnpm build:packages` there, the package's `exports` point at `dist/` files that do not exist yet, and Vite's default config loader — which externalizes the package and hands it to Node — cannot load the plugin at all: ```text Cannot find module '.../packages/preview/dist/vite.js' ``` Building the checkout is the real fix: ```bash title="In the loom checkout" pnpm build:packages ``` Failing that, `vite --configLoader runner` (and `vite build --configLoader runner`) processes the config through Vite's module runner instead of pre-bundling it, which can resolve TypeScript sources directly. It is marked experimental in Vite's CLI help but has been reliable across dev and build. See [Installation](https://docs.astra-void.xyz/loom/getting-started/installation.md#working-from-a-source-checkout) for what a checkout needs in the first place — it adds a Rust toolchain to the requirements. ## Don't merge this into an app that also uses React `loomPreview()` aliases bare `react`, `react/jsx-runtime` and `react/jsx-dev-runtime` to loom's own React 18 copy, and sets `esbuild.jsx: "automatic"` globally. Those are not scoped to Roblox files — they apply to every module Vite processes in that config. So if you drop `loomPreview()` into the Vite config of an existing React web app, that app's React gets replaced with loom's React 18 too. If the app is on React 19, it breaks. If it also runs `@vitejs/plugin-react`, you now have two JSX transforms fighting. Keep the preview in its own Vite project. Three arrangements work well: - **A dedicated preview app** in your workspace (`apps/preview/`) with its own `vite.config.ts` and its own dependencies — a `vite.config.ts` holding nothing but `loomPreview()` is enough. This is what loom's `apps/interactive` and `apps/gallery-demo` are. - **No Vite project at all** — use `loom preview` and `loom build`, pointed at your source directory. The CLI runs with `configFile: false`, so it cannot collide with your project's own Vite config even if one exists. - **Mounted inside your app's dev server**, via [`loom-dev/embed`](https://docs.astra-void.xyz/loom/reference/cli.md#loom-devembed--the-programmatic-api). The gallery still gets its own Vite instance — your app just forwards requests under a base path to it, so the two module graphs never meet. The third is what these docs themselves use: an Astro integration mounts the gallery in dev and emits the static bundle at build, and each scene is iframed per preview. See [Static builds and embedding](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#a-working-embed-pipeline). > **The same rule applies to the docs you are reading** > > `loomPreview()` in this site's `astro.config.mjs` would hijack the docs' own React 19. The gallery > runs in a separate Vite instance for exactly that reason. ## Where to go when something breaks Every error string above, plus the ones that come from the runtime rather than the config, is in [Troubleshooting](https://docs.astra-void.xyz/loom/guides/troubleshooting.md) with the reasoning behind it. --- # Gallery targets > Browse many scenes at once with *.loom.tsx targets — the preview export contract, discovery globs, loom.config.ts, and per-target error containment. Source: https://docs.astra-void.xyz/loom/guides/gallery-targets/ A single-entry preview shows one tree. Gallery mode shows a sidebar of every scene in the project and mounts one at a time, each in its own root with its own error boundary. It is the mode you want while building a component library, and it is the only mode `loom build` supports. ```bash title="Enable gallery mode" pnpm exec loom preview ~/code/my-ui --targets ``` With `--targets` present, loom stops looking for a client entry entirely — the gallery shell does the mounting — and serves its own generated page even if the project ships an `index.html`. The same mode is one option away in a project that owns its Vite config: ```ts title="vite.config.ts" export default defineConfig({ plugins: [loomPreview({ targets: "src/scenes" })], }); ``` `vite` then serves the gallery and `vite build` emits it as a static site — the same pipeline `loom preview --targets` and `loom build` run. ## The target contract A target is any file matching the discovery glob that exports a `preview` object: ```tsx title="src/scenes/Counter.loom.tsx" import { useState } from "@rbxts/react"; function CounterScene() { const [count, setCount] = useState(0); return ( setCount((c) => c + 1) }} /* ... */ /> ); } export const preview = { render: () => , title: "Counter (Activated)", } as const; ``` Two fields matter: | Field | Required | Behavior | | --- | --- | --- | | `render` | yes | Must be a function returning a React element. The shell renders it *as a function component* (`React.createElement(preview.render)`), so hooks inside it work and React re-renders it on its own schedule. | | `title` | no | A non-empty string replaces the sidebar's relative-path label. Titles are loaded lazily on idle, so the sidebar shows paths first and upgrades in place. | Anything else — a default export, a bare component export, `preview` without a callable `render` — produces an inline error panel rather than a broken page: ```text invalid preview export in src/scenes/Counter.loom.tsx target must export `const preview = { render: () => <.../>, title: "..." } as const` ``` > **`render` is called as a component, not invoked once** > > Because the shell passes `preview.render` to `React.createElement`, a render function that closes over > mutable module state will re-run whenever React re-renders. Put stateful logic in hooks inside the > scene component, not in the `render` thunk. ## Discovery `--targets` (and the plugin's `targets` option) accepts three shapes: | Invocation | Resulting glob | | --- | --- | | `--targets` (no value), or `targets: true` | `**/*.loom.tsx` | | `--targets src/scenes` (no `*`) | `src/scenes/**/*.loom.tsx` | | `--targets "apps/*/src/**/*.scene.tsx"` | used verbatim | The option also takes an array of either shape; the CLI flag does not. The matcher is a small purpose-built one, not `picomatch`: `**/` matches any depth *including none*, `**` matches anything, `*` matches anything except `/`, and every other character is literal. There is no brace expansion, no `?`, no negation, no extglob. The walk skips `node_modules` and any directory starting with `.`, and results are sorted, so sidebar order is stable and path-alphabetical. Discovery re-runs on the fly: adding or deleting a `*.loom.tsx` file invalidates the virtual module and triggers a full page reload. The URL hash survives that reload, so the selected target stays selected. Editing a target's *contents* is ordinary HMR. ## `loom.config.ts` When you get tired of typing flags, drop a config file in the project root: ```ts title="loom.config.ts" export default { targets: "src/scenes", port: 5204, }; ``` Only two fields are read: `targets` (a string or non-empty string array) and `port` (a number). CLI flags always win over the file. The file is imported through `tsx`, so TypeScript and ESM both work, and an import failure is downgraded to a warning rather than aborting the preview. > **A config without `targets` is skipped whole** > > The CLI validates that `targets` is present and well-typed before honoring anything in the file. A > config that has, say, a `server.port` and a `targetDiscovery` object — the shape an older loom used — > is ignored entirely, including its port, and you get: > > ```text > loom: loom.config.ts found, but its default export has no `targets` field — skipping it > (legacy config?). Use `--targets [glob]` or export > `{ targets: string | string[], port?: number }` to enable gallery mode. > ``` > > This is a hint, not an error: the preview continues in single-entry mode. If you see it, the file is > doing nothing. ## Error containment The gallery chrome is deliberately plain DOM — no React outside a single error boundary — so a target that explodes cannot white-screen the page. Three failure classes are caught separately and rendered into an inline red panel with the stack, while the sidebar stays interactive: - **Import failure** — `failed to import ` (a syntax error, a missing module). - **Bad contract** — `invalid preview export in `. - **Render throw** — `render error in `, caught by the boundary around the target. Switching to another target clears the panel. A target that throws on purpose is a reasonable thing to keep in a gallery as a regression check for exactly this behavior. ## Inspecting a target The sidebar header carries a **`debug`** button (also **Ctrl+Alt+D**, also `?debug=1`) that opens a panel over the stage reporting what the mounted target is actually doing — import and first-frame timings, the logical viewport it laid out against, the live instance tree, which typefaces really loaded, and a hover inspector for the scene. It is off by default and runs nothing while closed. See [The debug panel](https://docs.astra-void.xyz/loom/guides/debug-panel.md). ## Routing In full-chrome mode the shell uses hash routing: selecting a target sets `#/`, and the hash is the source of truth across reloads. An initial `?target=` seeds the selection on first load without a hash — that is the deep-link contract the static build and docs-site iframes use, and it is documented in [Static builds and embedding](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md). --- # Animation > Three ways to move a preview — bindings, TweenService, and the @rbxts/ripple springs — and why an animation costs zero React renders. Source: https://docs.astra-void.xyz/loom/guides/animation/ Everything animated in a preview runs on loom's own frame loop — the scheduler's `RunService` signals, the same ones your Roblox code connects to. There is no CSS transition anywhere in the renderer, so what you see is the interpolation your code actually computes. Three layers, from lowest to highest: | Layer | Use it for | | --- | --- | | [Bindings](#bindings) | A value you drive yourself, per frame, without re-rendering. | | [`TweenService`](#tweenservice) | The engine's own tween API, with the same easing enums. | | [`@rbxts/ripple`](#rbxtsripple) | Springs, tweens and motion as React hooks. | ## Bindings `createBinding`, `useBinding` and `joinBindings` are exported from `@rbxts/react`, and **any host prop accepts either a plain value or a `Binding` of one**: ```tsx import { useBinding } from "@rbxts/react"; function Follower() { const [position, setPosition] = useBinding(UDim2.fromOffset(0, 0)); return ( setPosition(UDim2.fromOffset(input.Position.X, input.Position.Y)), }} /> ); } ``` A bound prop is written **straight onto the live instance** by the renderer, bypassing React entirely. A 60fps animation is 60 property writes and zero renders. There is exactly one kind of binding in a preview: the implementations come from `@loom-dev/react`, which is what the renderer resolves, so a binding minted by `useBinding` and one minted by ripple's `useSpring` are the same object as far as the renderer is concerned. ## `TweenService` The engine's tween API, imported the way you already import it: ```tsx import { useEffect, useRef } from "@rbxts/react"; import { TweenService } from "@rbxts/services"; function SlideIn() { const ref = useRef(); useEffect(() => { const frame = ref.current; if (!frame) return; const info = new TweenInfo(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out); const tween = TweenService.Create(frame, info, { Position: UDim2.fromScale(0.5, 0.5), }); tween.Play(); return () => tween.Cancel(); }, []); return ; } ``` `TweenInfo`'s positional arguments are the engine's, in the engine's order: `(Time, EasingStyle, EasingDirection, RepeatCount, Reverses, DelayTime)`. | Surface | Supported | | --- | --- | | `TweenService` | `Create`, `GetValue`. | | `Tween` | `Play`, `Pause`, `Cancel`, `Completed`, `PlaybackState`. | | `TweenInfo` | `DelayTime`, `RepeatCount`, `Reverses`, and every `EasingStyle` / `EasingDirection`. | | Interpolated types | `number`, `Color3`, `UDim`, `UDim2`, `Vector2`. | Tweens advance on the scheduler's frame signal, so a tweened write flushes to the DOM like any other property write — and pausing the scheduler pauses the tween. ## `@rbxts/ripple` `@rbxts/ripple` and `@rbxts/react-ripple` both work with **no configuration** — loom answers them with a port of the published implementation rather than a stub, because the package ships a Luau runtime a browser cannot execute. See [Package compatibility](https://docs.astra-void.xyz/loom/guides/package-compatibility.md#rbxtsripple-and-rbxtsreact-ripple) for why. ```tsx import { config, useSpring } from "@rbxts/react-ripple"; function AnimatedButton() { const [offset, spring] = useSpring(0, config.stiff); return ( UDim2.fromOffset(200 + value, 50 + value))} Event={{ MouseEnter: () => spring.setGoal(10), MouseLeave: () => spring.setGoal(0), }} /> ); } ``` The spring integrator, the easing curves, the Oklab colour interpolation and the rest thresholds all follow the Luau source, so a component animates the way it does in Roblox. ### Exports `createSpring`, `createTween`, `createMotion`, `config`, `easing`, `springScheduler`, `tweenScheduler`, `motionScheduler`, and the `useSpring` / `useTween` / `useMotion` hooks — which also re-export the core, as the real package does. Every published `config` preset and every published `easing` curve is implemented. ### Controller methods Matching the published `.d.ts`: | Controller | Methods | | --- | --- | | All three | `getPosition` `getGoal` `setPosition` `setGoal` `onChange` `onComplete` `step` `idle` `configure` `start` `stop` `destroy` | | `Spring` / `Motion` | `getVelocity` `setVelocity` | | `Spring` | `impulse` `halt` | | `Tween` | `getFrom` `setFrom` | | `Motion` | `spring` `tween` | Every documented option is honoured: `start`, `tension`, `friction`, `mass`, `dampingRatio`, `frequency`, `precision`, `restVelocity`, `position`, `velocity`, `impulse` for springs; `start`, `easing`, `duration`, `repeats`, `reverses`, `position` for tweens; `start`, `spring`, `tween` for motion. ### Values `number`, `Vector2`, `Vector3`, `Color3`, `UDim`, `UDim2`, `Rect`, and records of numbers — which accept partial goals, so keys you leave out do not move. `Color3` interpolates through Oklab, and `UDim` / `UDim2` offsets round to integers, both as Roblox does. `CFrame` **throws** rather than animating: ```text [loom] Ripple compatibility does not yet support animating CFrame ``` Loom's `CFrame` carries position only and the Scene IR has no property slot for one, so an interpolation could not reach the screen. Anything else — a string, a record of non-numbers — throws by name too, instead of freezing or producing a corrupt value. Subpaths like `@rbxts/ripple/foo` are not covered. > **Two deliberate differences from upstream** > > Both in loom's favour: > > - **`destroy()` also drops the controller's `onChange` / `onComplete` callbacks.** Upstream only stops > it, which leaves a torn-down controller able to call into an unmounted component. > - **The hooks ignore a changed `initialOptions` after mount** — exactly as upstream does. Recreating > a controller mid-animation would drop its velocity and its subscribers. Call > `controller.configure(...)` to retune one in place. ## One frame loop Every controller — spring, tween and motion alike — shares a single `RunService.Heartbeat` connection, released the moment the last one settles. Values are pushed into a binding, so the renderer writes them onto the live instance and React never re-renders. > **Frozen animations in a background tab** > > Loom's scheduler is driven by `requestAnimationFrame`, which browsers do not fire for a tab that is > not visible. An animation in a hidden or backgrounded preview is not broken, it is not being stepped > — it resumes where it left off when the tab comes forward. --- # Fonts and text metrics > The preview loads the engine's own typefaces and measures text the way the engine does — which families ship, how to register the proprietary ones, why TextSize is not a font size, and where a line breaks. Source: https://docs.astra-void.xyz/loom/guides/fonts/ Roblox ships its own typefaces. A browser has none of them. Loom names the Roblox families in CSS — `font-family: "Gotham", system-ui, …` — and until `0.6.4` loaded nothing behind them, so on a machine without the font installed every family resolved to `system-ui`: SF Pro on macOS, Segoe UI on Windows, Roboto on Linux. Three typefaces, three sets of advance widths. That is not only a paint difference: `AutomaticSize` and `TextWrapped` are driven by [measuring those widths](https://docs.astra-void.xyz/loom/reference/supported-properties.md#automaticsize-on-text), so the same scene laid out differently on each machine, and nothing pointed at the font as the reason. **As of `0.9.0` the preview loads the faces itself.** The import sits in the globals module, which is injected ahead of your entry whichever frontend you use, so a `vide` preview gets the same faces as a React one and neither needs a line of configuration. > **Nothing to install, nothing to import** > > `0.6.4` shipped the faces but left the import opt-in, and the preview never made it — so out of the > box every family still fell through to `system-ui`, and four families were all that had a face at > all. Both halves are fixed in `0.9.0`. If you are on an older version, `import > "@loom-dev/renderer/fonts";` as the first line of your entry does the same job. ## The families that ship a face Fontsource `woff2` in the bundle — no CDN, nothing installed on the machine. These are the *actual* fonts the engine draws with, so the metrics are the engine's rather than an approximation of them. | Roblox family | Typeface | | --- | --- | | `SourceSans*` | Source Sans 3 | | `Roboto*` | Roboto | | `RobotoMono*` | Roboto Mono | | `RobotoCondensed` | Roboto Condensed | | `Inconsolata` / `Code` | Inconsolata | | `Arimo` | Arimo | | `Jura` `Merriweather` `Nunito` `Oswald` `Ubuntu` `TitilliumWeb` | their own | | `JosefinSans` `GrenzeGotisch` `Sarpanch` `Michroma` | their own | | `AmaticSC` `Bangers` `Creepster` `DenkOne` `Fondamento` | their own | | `IndieFlower` `Kalam` `LuckiestGuy` `PatrickHand` `PermanentMarker` `SpecialElite` | their own | | `FredokaOne` | Fredoka — Google folded "Fredoka One" into Fredoka's heavier weights, so this is the one approximation | Twenty-eight families, all OFL-1.1 apart from Ubuntu, which is under the Ubuntu Font Licence. `Arial` and `Legacy` need nothing: every machine has Arial, and Arimo — which *is* registered — is metric-compatible with it, so those stacks land on the right advance widths either way. ### Named, but not loaded The rest of the engine's list resolves to a stack that leads with the right typeface and warns instead of drifting in silence: `Gotham` and `BuilderSans` (proprietary), and `Bodoni`, `Garamond`, `Cartoon`, `SciFi`, `Arcade`, `Fantasy`, `Antique`, `Highway` — faces either licensed to Roblox or with no identity loom can vouch for. On a machine that happens to have the real font installed the family's own name heads its stack, so it is used; otherwise it falls back and says so. `Enum.Font` carries the engine's whole enum, all 53 items in its own order. Before `0.9.0` it held the sixteen loom happened to paint, so `Enum.Font.Jura` was `undefined` and a scene that named it crashed before drawing anything. > **What this costs a page** > > A static gallery build emits ~2.8 MB of `woff2` across the whole set. What a *page* downloads is > unchanged: Fontsource declares per-script `unicode-range` subsets, so a browser fetches only the > families and scripts a scene actually paints with. ## Registering a face yourself `registerFont` installs a typeface for one Roblox family. A project that has the Gotham files makes exactly the call the shipped module makes for the open families: ```ts import { registerFont } from "@loom-dev/renderer"; import gotham from "./fonts/Gotham-VF.woff2"; registerFont("Gotham", { family: "Gotham", faces: [{ src: gotham, weight: "100 900" }], }); ``` ```bash title="The renderer is a transitive dependency; import it directly and it needs declaring" pnpm add -D @loom-dev/renderer ``` Omit `faces` when the page already provides the family — a `` to a font CDN, your own `@font-face`, or a font the machine has installed. Then the registration only has to name it: ```ts registerFont("SourceSans", { family: "Source Sans 3 Variable" }); ``` A registration replaces whatever loom shipped for that family, so this is also how you swap one out. ### The registration shape | Field | Type | Notes | | --- | --- | --- | | `family` | `string` | **Required.** The CSS family to paint and measure with. When `faces` is given, this is also the name they are declared under. | | `faces` | `{ src, weight?, style?, display? }[]` | `@font-face` rules to declare. `src` is a URL the page can load — usually a bundler-emitted import. `weight` defaults to `400` and takes a range (`"200 900"`) for a variable font; `style` defaults to `normal`; `display` defaults to `swap`. | | `fallback` | `string` | Appended behind `family`. Defaults to that family's own default stack. | ### One key per family Roblox names a family several ways: the legacy `Enum.Font` item folds the weight in (`GothamBold`, `ArimoBold`), and a `FontFace` carries the asset's own family, which is sometimes a different word entirely (`GothamSSm`, `SourceSansPro`, and `Enum.Font.Code` is the Inconsolata family). Every spelling is matched by **prefix**, longest first so `RobotoMono` is never read as `Roboto`, so one registration covers every spelling and the weight suffixes come along for free. The aliases worth knowing: `GothamSSm` → `Gotham`, `SourceSansPro` → `SourceSans`, `Code` → `Inconsolata`, `Fredoka` → `FredokaOne`, `LegacyArial` → `Legacy`, `HighwayGothic` → `Highway`. A name outside the family table falls to the generic sans stack and is not registrable. ## A late face re-lays-out Text bounds are measured against whatever the browser has *at the time*, so a face that arrives after the first paint would otherwise leave a layout measured in the fallback. Registering late is therefore fine and expected: a registration — or a `@font-face` finishing its download — invalidates every `AutomaticSize` bound that came out of the old stack, and both adapters re-measure. > **Why `0.9.2` matters if you saw this go wrong** > > A registered face is never loaded at the moment it is registered: nothing has asked the browser for > it, and the canvas loom measures with never will, since `measureText` paints nothing and so starts no > download. The face only loads when text first paints in it. > > Loom used to hear about that through `document.fonts.ready`, which is one promise for the cycle *in > flight when it is read*. Under a dev server the app boots through a graph of separate module > requests, long after the document settled — so the promise was already resolved, the listeners fired > at once against the fallback, and the face that downloaded seconds later notified nobody. The layout > stayed measured for a typeface that was no longer being painted until something unrelated, a resize, > forced a re-measure. A static build, where one bundle registers everything before the document is > done, came out right — which is why the same scene at the same version could render correctly > deployed and wrongly under `npm run dev`, and worst on Windows, where the fallback's metrics are > furthest from the registered face. > > `0.9.2` listens for `loadingdone` on `document.fonts` instead, which fires at the end of *every* > cycle and has no such window. ```ts title="If you need the hook yourself" import { onFontsChanged } from "@loom-dev/renderer"; const stop = onFontsChanged(() => { // every text measurement taken before this is stale }); ``` `clearRegisteredFonts()` takes every registration back out, along with the `@font-face` rules loom added — useful in tests, and rarely otherwise. ## `TextSize` is not a font size Having the right typeface is half of matching the engine. The other half is drawing it at the right size, and the two properties do not mean the same thing. **Roblox fits the whole face into `TextSize`** — ascender to descender, which is why a one-line label measures exactly `TextSize` tall. CSS `font-size` sets the em square instead, and a face's ascent plus descent runs well past 1em. Painting `font-size: TextSize` therefore drew every glyph too big by that font's own ratio: 17% for Roboto, 18% for Jura, 25% for Merriweather, 47% for Oswald. Everything downstream inherited it — text measured that much wider, so it wrapped that much earlier, so `AutomaticSize` boxes came out taller and wider, and a card sized to its text overran the column meant to hold it. All of it looked like a wrap bug and none of it was one. Since `0.9.3` loom divides by the face box, so `TextSize` means what the engine means by it. `LineHeight` follows: the pitch the engine spends is `TextSize`-relative rather than font-size relative, so it is set in pixels off `TextSize`, and a `` run inside `RichText` converts through the metrics of the face that run lands in. **Which box to divide by is itself a measurement.** The browser reports one (`fontBoundingBoxAscent + Descent`) and it is not the number Roblox divides by — Roboto reports 1.17 where the engine sizes it as though it were 1.14, so every glyph came out about 2.6% small. Since `0.9.5` loom carries the engine's own ratio for each family [`@loom-dev/renderer/fonts` registers](#the-families-that-ship-a-face), solved against `TextService:GetTextBoundsAsync` per-glyph advances at `TextSize` 18. Twenty-four of the twenty-eight reproduce all 24 sampled glyphs exactly; `FredokaOne`, `Merriweather`, `Nunito`, `Oswald` and `DenkOne` do not, and their fitted ratio is still closer than the browser's. **A family with no entry — anything you registered yourself, `Gotham` included — keeps the measured box**, which is the honest answer when nobody has solved the engine's. > **A calibration only applies to a face the browser really has** > > A registration is a claim about a file the page still has to fetch, and until `0.9.6` the engine ratio > was applied on the strength of that claim alone. When the fetch failed the browser painted the > fallback while loom went on sizing the text as though the registered face were there — every advance > off the wrong glyphs, wrapping in places the engine does not, `AutomaticSize` reporting a box that does > not fit the text drawn in it. > > Only a dev server can land there: a static build carries its font files in its own output, so the face > is always present. That is what made it read as a dev-only rendering bug rather than a font that > failed to load. Against the same target at one width, a dev server whose face 404s wrapped a paragraph > to ten lines where the build of the same source took nine. > > `0.9.6` applies the calibration only when the browser can actually paint the family and otherwise > measures the face it really has, which is self-correcting — a font-loading cycle drops the metric > caches and the label re-measures against whatever just landed. `familyIsAvailable` is exported for > hosts that register their own faces and want the same answer. ## Where a line breaks The engine does not measure a string the way a browser does, in two ways that compound. **It spends each displayed grapheme on a half-pixel boundary.** Canvas measurement shapes and kerns a whole run with fractional advances, which can come out a few percent narrower and wrap a long paragraph at different words. Since `0.9.4` the renderer caches half-pixel grapheme advances per font, invalidates them when a face changes, and keeps the fractional result rather than rounding it to a whole pixel. React and Vide take their measurements from the same place, so a live preview and a compiled scene no longer disagree — before that, the React adapter measured `TextBounds` itself while a compiled scene went through the renderer. **It kerns.** `AV` is 19.5 units wide where its glyphs are 10.5 and 10 alone, so since `0.9.5` `shapedTextWidth` adds the run's kerning, quantized once for the run. **And one function decides every wrap.** A label's box used to come from the measurer while the glyphs inside it were left to CSS, which wraps on its own kerned run widths — so a label could reserve nine lines and paint eight, ending short of a box built for it and breaking at different words than Studio. Since `0.9.5` `wrapLines` is the single place a wrap is decided: measurement asks it how many lines a label needs and the text layer asks it where to put the breaks it paints, keeping them in `white-space: pre`. `RichText` runs go through the same wrap with the line carried across runs, each measured in the font its `` tag gave it. > **Newlines and runs of spaces survive the paint** > > A newline in `Text` breaks the line in Roblox — wrapped or not, `RichText` or not, exactly as `
` > does — and a run of spaces stays a run of spaces. Loom measured it that way and then painted through > HTML's defaults, `white-space: normal` and `nowrap`, which fold both away. A label written with line > breaks in it could measure as twenty-three lines and paint as seventeen: a box a hundred pixels taller > than the text inside it, with every sibling below pushed down by room nothing occupies. > > `0.9.3` paints `pre-wrap` when the label wraps and `pre` when it does not, so the paint has the breaks > the measurement counted. Text with no newlines and no double spaces — most text — is unaffected. ## The warning A family some text actually asked for, with nothing loaded behind it, is reported once: ```text loom: no face is loaded for the Roblox font family "Gotham" — its text is painted and measured in the system fallback instead, which is a different typeface per OS and does not match the engine's layout. Register one with registerFont("Gotham", { family, faces }), or import "@loom-dev/renderer/fonts" for the families Roblox licenses openly. ``` Two details make it trustworthy rather than noisy: - **It waits for the font loading cycle to finish**, and re-queues while one is still in flight. A face that is downloading is late, not missing — and since the warning is raised while the text is being encoded, the paint that asks for the face may only just have started. - **Availability is decided by probe-string width**, not `document.fonts.check()`. That method answers "would this font specification resolve", and an unknown family resolves — to the fallback — so it returns `true` for a family nobody has. Loom measures a probe string against two generics with wildly different metrics instead: a real family shifts at least one of them, a missing one leaves both exactly where the generic put them. This is what `familyIsAvailable` exposes. - **A registration is not a face.** Until `0.9.6` the audit skipped any family that had one, on the assumption that a registration means a face. A registered face whose file never arrived is now reported like any other missing one, rather than being quietly mis-measured. `Arial` and `Legacy` are exempt. Both land on a font every machine has, so there is nothing to load and nothing to warn about. ## What is still approximate Loading the right face pins the *typeface*, and `0.9.3`–`0.9.6` pinned the size, the advances and the wrap. What is left is that a browser's text engine is still not Roblox's: shaping, hinting and sub-pixel rounding differ. Measured against `TextService:GetTextBoundsAsync` — Roboto at `TextSize` 18, a long paragraph laid out at 50 widths from 320 to 1300 — the painted line count matches the engine at 49 of the 50. String widths are exact on 6 of 10 sampled strings and never off by more than half a unit; they were off by up to 9 before `0.9.5`, and the wrapped line count matched 34 times when CSS was doing the wrapping. What remains is that the measurement runs about a percent roomy, so text wraps a hair early rather than overflowing its box — the safe direction to be wrong in. Before `0.9.0` this was not a rounding difference at all; it was a different font. --- # The debug panel > What a preview is actually doing — the mounted target's timings, the logical viewport, the live instance tree, which typefaces really loaded, and a stage inspector that reaches click-through frames. Source: https://docs.astra-void.xyz/loom/guides/debug-panel/ A preview that renders the wrong thing rarely tells you why. The tree is a WASM layout result painted into a DOM that does not resemble it, so a browser inspector shows you `div`s and CSS transforms rather than a `Frame` with a `Size`. As of `0.10.0` every gallery carries a panel that reports the scene in its own terms. ## Opening it Three ways, all equivalent: - the **`debug` button** in the sidebar header, - **Ctrl+Alt+D**, - **`?debug=1`** on any gallery URL. It is off by default, and while it is closed **nothing it does runs at all** — no observers, no timers, no tree walks. Everything it reads is read-only and outside the render path, so a scene behaves the same with the panel open as without it. The `?debug=` parameter takes the spellings a flag usually gets typed with: a bare `?debug` is on, `?debug=0`, `false`, `off`, `no` and `none` are off, and anything else present is on — so a host page templating the parameter can pass a boolean straight through. > **The toggle survives an HMR reload, except in an embed** > > The gallery takes a full reload on every edit, so the state is remembered for the tab — otherwise the > panel would close behind you on each save. An embed (`chrome=none`) deliberately does *not* remember > it: a debug panel on somebody's docs page should only ever be one the URL asked for. See > [static builds and embedding](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md). ## What it reports Each section folds away and keeps reporting its one headline number while folded. **target** — the target's path and title, how long its `import()` took, and how long from the mount to the first frame on screen. The panel's `↻` button re-mounts the active target, which is how you take those timings for a target that was already up when the panel opened. **viewport** — four numbers that agree unless the page asked for a `?base=` viewport, which is the reason all four are shown: the stage in real pixels, the **logical viewport the scene actually laid out against**, `Workspace.CurrentCamera.ViewportSize`, and the scale factor with the `?base=` width behind it. Plus the device pixel ratio and the theme. Together they tell a scene that *laid out* small from one that was *painted* small — see [Previews on a phone](https://docs.astra-void.xyz/loom/guides/mobile-previews.md). **scene** — live instances, how many are GuiObjects, and how many of *those* are invisible, which is usually the answer when a scene looks empty. Then tree depth, the DOM nodes it became, a count per class, and each layer with its `DisplayOrder` and size. **fonts** — every typeface the scene's text resolved to, the weights asked for, and whether the browser really **loaded** it or fell back to another face. A family that never arrived still paints, just at the wrong metrics, so this is the row that explains a layout which only differs on one machine. See [Fonts and text metrics](https://docs.astra-void.xyz/loom/guides/fonts.md). **frame** — frame rate, DOM patches committed since the panel opened, and a count of what loom logged. Its warnings usually explain a scene, and nobody reads the console. **inspect** — covered below. ## Inspecting the stage Hover the stage and the panel names the `GuiObject` under the pointer, outlines it with its size, and lists its ancestry, absolute geometry, `UI*` modifiers, resolved typeface and properties — colors as swatches, everything else typed and colored. - **Alt+click pins** the selection so it stops following the pointer. Escape releases it. - The **ancestry trail** and the **under** rows — everything else the pointer is over — are clickable, so the tree can be walked from the panel. - The hit test is the scene's own `PlayerGui:GetGuiObjectsAtPosition`, **not** the browser's. A click-through frame that no browser inspector can reach is still inspectable here. ## Taking a snapshot out `copy` puts the readout on the clipboard as text. `json` downloads the whole thing as a file — every section as data, plus the complete instance tree with each object's absolute position and size, which is what a bug report wants instead of a screenshot of a panel. The same object is available while the panel is open as `loomDebug.snapshot()`, so a devtools session or a headless harness can take one too: ```js title="In the devtools console, panel open" copy(loomDebug.snapshot()); ``` ## See also - [Gallery targets](https://docs.astra-void.xyz/loom/guides/gallery-targets.md) — what the panel's **target** section is reporting on. - [Mobile previews](https://docs.astra-void.xyz/loom/guides/mobile-previews.md) — the logical viewport and `?base=`. - [Troubleshooting](https://docs.astra-void.xyz/loom/guides/troubleshooting.md) — symptom-first, and most entries have a row in this panel. --- # Static builds and embedding > Bundle a gallery into a hostable SPA with loom build, then deep-link one scene per iframe using the target/chrome/theme URL contract. Source: https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding/ `loom build` runs Vite's production build over the same gallery the dev server shows, and emits a self-contained client-only SPA. Nothing on the server side is required to host it. Its reason for existing is embedding: a docs site can iframe a single scene, and the reader gets a real interactive Roblox UI instead of a screenshot. ```bash title="Build a gallery" pnpm exec loom build ~/code/my-ui --targets src/scenes --out dist-preview --base ./ ``` > **Or plain `vite build`** > > The command is `loomPreview({ targets })` under `vite build` and nothing else. A project whose > `vite.config.ts` passes `targets` gets the identical bundle from `pnpm vite build`, with `outDir` and > `base` coming from its own config. See [Vite > integration](https://docs.astra-void.xyz/loom/guides/vite-integration.md#6-gallery-mode-from-the-plugin). ## Flags ```text loom build [dir] --targets [glob] [--out ] [--base ] ``` | Flag | Default | Notes | | --- | --- | --- | | `--targets` | **required** | Same semantics as `preview`. Without it the command exits: `loom: build requires --targets [glob] (the static gallery is target-driven)`. | | `--out` | `dist-preview` | Resolved against the current working directory, not the project dir. | | `--base` | `./` | Public path. The relative default is what makes the output host-anywhere. | Unlike `preview`, `build` does **not** read `loom.config.ts`. Pass `--targets` explicitly every time. > **`--out` is emptied before writing** > > The build runs with `emptyOutDir: true`, because the output directory normally sits outside Vite's > root and Vite would otherwise refuse to clear it. Point `--out` at a directory that belongs to loom > and nothing else. Aiming it at, say, your site's whole `public/` would delete the rest of that > directory's contents. ## What comes out ```text title="dist-preview/" index.html assets/index-.js # shell + runtime + adapter assets/index-.css # gallery chrome assets/loom_layout_wasm_bg-.wasm assets/CardScene.loom-.js # one async chunk per target assets/CounterScene.loom-.js assets/roboto-latin-400-normal-.woff2 # …and ~110 more, since 0.9.0 ``` Each target becomes its own lazily-imported chunk, so a gallery with fifty scenes still loads one scene's code per view. The `.wasm` binary dominates the transfer size — about 178 kB, roughly 68 kB gzipped, from the `wasm-opt`-processed build published packages ship — and it is fetched once and cached. A binary built locally with the default `dev` profile skips `wasm-opt` and is roughly four times larger. As of `0.9.0` the [engine's typefaces](https://docs.astra-void.xyz/loom/guides/fonts.md) are part of that output: ~2.8 MB of `woff2` across the whole set, in about 110 files. What a *page* downloads is unchanged — Fontsource declares per-script `unicode-range` subsets, so a browser fetches only the families and scripts a scene actually paints with. Under the hood the plugin hands Rollup a genuine module graph rather than the dev server's virtual modules: a generated `index.html` as the build input, whose script is a generated entry that imports the globals installer *first*, then a generated target map of relative `import()`s — one async chunk each — then the shell. Globals-first is why a built gallery never throws `UDim2 is not defined`. ## `rbxassetid://` in a static build A page served from a bucket has no dev server to resolve asset ids for it, so until `0.7.0` they simply did not paint outside `loom preview`. The build now does the work up front: it works out which ids the page will ask for, resolves each one through Roblox's thumbnail API, downloads the image into the output, and writes a manifest the page reads on first use. ```text title="dist-preview/" __loom/assets.json # { "6031075938": "__loom/asset/6031075938.png" } __loom/asset/6031075938.png # the downloaded image ``` Nothing else changes: `Image="rbxassetid://6031075938"` paints from your own origin, with no runtime request to Roblox. Working out *which* ids takes two passes, because reading the bundle is not enough on its own. - **The scan** reads the emitted output for `rbxassetid://`, which finds every id a source spells out. - **The prerender** *(`0.9.5`)* covers the ids a source builds at runtime. A component library composes them — `` `rbxassetid://${iconId}` `` over a table of ids — and after bundling that is a prefix and a few hundred bare numbers no scan can tell from any other number, so a gallery built from a real UI library used to paint none of its icons. Instead, the build *runs* your scenes: every gallery target is mounted in node and its live tree is read for the images it actually holds. A 700-icon set contributes the dozen icons your scenes render, not all 700. Three things are worth knowing before you rely on it: - **The first render is what gets baked.** An image the initial mount never reaches — one behind a hover state, or supplied by a later fetch — stays unresolved. Give those a real URL, or install your own `setImageResolver`. - **The prerender needs targets.** It runs for gallery builds — `loom build`, a plugin configured with `targets`, the Astro and Next embeds. A build configured with a single client `entry` keeps the scan alone, since that entry mounts itself on import rather than exposing a scene to mount. - **A build never fails over an image.** An id that will not resolve — offline, deleted, moderated — is warned about and left out of the manifest, and so is a target that will not render during the prerender. Composition the prerender could not account for is warned about as well, rather than leaving a blank image unexplained. To keep a build off the network entirely, turn the bake off wherever the build is configured — `loomPreview({ assets: false })`, `loom build --no-assets`, or `assets: false` on `buildGallery` and `withLoomGallery` *(`0.7.1`; earlier versions only had the plugin option)*. ## The URL contract The built page reads six query parameters. This is the whole embedding API. | Parameter | Values | Effect | | --- | --- | --- | | `target` | a target's relative path, URL-encoded | Selects and mounts that target on load, without a hash. | | `chrome` | `none` | Hides the sidebar and renders one target full-bleed. Any other value (or absent) keeps the full chrome. | | `theme` | `light` \| `dark` | Sets the stage background and seeds `PlayerGui.LoomTheme`. Anything other than `light` is treated as `dark`. | | `background` | a CSS colour | *(`0.6.2`)* Paints the stage backdrop, overriding the one `theme` would have used and leaving the rest of the palette alone. | | `base` | px (bare `?base` is 960), or `none` / `off` / `0` | *(`0.6.4`)* Keeps this logical viewport and scales the stage down to fit, instead of letting the scene reflow into the real one. Off unless asked for as of `0.10.2` — see [Previews on a phone](https://docs.astra-void.xyz/loom/guides/mobile-previews.md#base-keeps-a-wide-composition). | | `debug` | present, or `0`/`false`/`off`/`no`/`none` for off | *(`0.10.0`)* Opens the [debug panel](https://docs.astra-void.xyz/loom/guides/debug-panel.md) on load. A bare `?debug` counts as on, so a host page can template a boolean straight through. | ```text title="Deep-link one scene, chromeless, light" /loom-preview/index.html?target=src%2Fscenes%2FCounter.loom.tsx&chrome=none&theme=light ``` In chromeless mode there is no hash routing — the mount is fixed to `?target=`. In full-chrome mode the shell uses `#/` and `?target=` only seeds the initial selection. `?debug=1` is the only way a debug panel appears in a `chrome=none` embed: the toggle is remembered per tab so an HMR reload does not close it, and an embed deliberately opts out of that memory so a panel never turns up unasked on somebody's docs page. ### `?background=` for a backdrop that is not either theme `theme` picks a whole palette — chrome, text, and one of loom's two backdrops (`#14161a` or `#f6f9fc`). `background` overrides just the backdrop, so a plain white stage under the light palette is `?theme=light&background=white`, and `transparent` lets the host page show through the iframe. It applies in both gallery modes and to the static build. > **Hex, without the `#`** > > A literal `?background=#ffffff` never reaches the gallery: `#` opens the URL fragment, which is also > where the gallery keeps its route. Both spellings that survive the trip are accepted — percent-encoded > (`%23ffffff`) and bare digits (`ffffff`). No CSS named colour is spelled with hex digits alone, so > the bare form is unambiguous. Only colours are accepted, through an allowlist: hex, a bare identifier, and the functional forms (`rgb()`, `hsl()`, `oklch()`, `color()`, `light-dark()`, …) with arguments that cannot open a nested function. A gradient, a `url(...)` — anything that could turn a query parameter into a network fetch — is ignored and the theme's own backdrop stands. A misspelled colour name passes the shape test and simply fails to apply in the browser, which lands in the same place. The backdrop is decided in the page's `` from `location.search` alone, before the first paint, so an embed no longer flashes the dark default while the bundle, the WASM engine and the target chunk load. That was up to half a second of black on a light or custom-coloured frame, repeated every time a host control changed a parameter and reloaded the iframe. ## Live theme switching A host page can flip an already-loaded frame's theme without reloading it, by posting a message: ```js title="From the embedding page" // GALLERY_ORIGIN is where the built gallery is served from — same-origin // embeds can use window.location.origin. frame.contentWindow.postMessage( { type: "loom-theme", theme: "dark" }, GALLERY_ORIGIN, ); ``` The backdrop moves the same way, next to it — so a docs page that switches theme at runtime need not reload the frame to re-point the stage: ```js title="Re-point the backdrop live" frame.contentWindow.postMessage( { type: "loom-background", background: "#0b0d10" }, GALLERY_ORIGIN, ); // With no colour, the backdrop goes back to whatever the theme paints. frame.contentWindow.postMessage({ type: "loom-background" }, GALLERY_ORIGIN); ``` Both write to the same element the first-paint script did, which makes the shell's own first pass a no-op rather than a second paint. The shell listens for exactly `{ type: "loom-theme", theme: "light" | "dark" }` and `{ type: "loom-background", background?: string }`, and ignores everything else. Applying a theme does two things: it toggles a class on the gallery's root element, and it writes `LoomTheme` onto `Players.LocalPlayer.PlayerGui`. That second part is the useful one — a scene can read the host page's theme through plain Roblox APIs, including `GetPropertyChangedSignal("LoomTheme")`, with no DOM access from roblox-ts code. > **Name the target origin, not a wildcard** > > `postMessage(msg, "*")` delivers to whatever document currently occupies that frame. A theme string > is not worth stealing, but `"*"` is a habit that costs nothing to avoid and gets copied into > messages that *are* worth stealing. Pass the origin you actually built the gallery for. > > The shell's own listener does **not** check `event.origin` — it accepts a well-formed `loom-theme` or > `loom-background` message from any parent. That is deliberate for a dev tool meant to be iframed by > arbitrary hosts, and it is a reason to keep the message vocabulary at exactly these two, both of > which only repaint. If you fork the shell and add messages with side effects, add an origin check > with them. ```tsx title="A scene that follows the host page's theme" const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui"); const [theme, setTheme] = useState(playerGui.LoomTheme ?? "dark"); useEffect(() => { const conn = playerGui .GetPropertyChangedSignal("LoomTheme") .Connect(() => setTheme(playerGui.LoomTheme)); return () => conn.Disconnect(); }, []); ``` ## A working embed pipeline Shelling out to `loom build` before every site build works, but it buys a generated artifact that goes stale the moment a scene changes, and no HMR while you edit one. If the host is a Vite-based framework, mount the gallery instead: [`loom-dev/embed`](https://docs.astra-void.xyz/loom/reference/cli.md#loom-devembed--the-programmatic-api) exposes both pipelines as functions — a middleware-mode server for dev, a static build for the output directory. (A Next.js host gets the same two pipelines pre-wired through one config wrapper — see [Next.js integration](https://docs.astra-void.xyz/loom/guides/nextjs-integration.md).) These docs do exactly that, from a ~40-line Astro integration: ```ts title="src/integrations/loom-preview.ts (abridged)" import { buildGallery, createGalleryServer, findGalleryTargets } from "loom-dev/embed"; export default function loomPreview(): AstroIntegration { let gallery; return { name: "loom-preview", hooks: { // Dev: Loom's own Vite server, mounted under the site's base. "astro:server:setup": async ({ server }) => { gallery = await createGalleryServer({ root: latticeApp, targets: "src/preview-targets", base: "/loom-preview/", }); server.middlewares.use(gallery.middleware); }, "astro:server:done": async () => await gallery?.close(), // Build: the same gallery, emitted next to the site's own output. "astro:build:done": async ({ dir }) => { await buildGallery({ root: latticeApp, targets: "src/preview-targets", outDir: fileURLToPath(new URL("loom-preview/", dir)), }); }, }, }; } ``` Editing a scene in the library checkout now hot-reloads the frame in the docs page — no regeneration step, nothing to keep in sync, and no generated bundle in the repo. Three details are worth stealing: - **Mount under the host's own base**, not a hard-coded `/loom-preview/`, so the previews travel with the site when it moves to a subpath. - **Make a missing checkout a graceful skip**, not a hard failure — CI and fresh clones should still build the site, just without previews. `findGalleryTargets()` answers "is there anything to serve?" without starting anything. Gate strictness behind an env var for deploy builds. - **Pin the checkout path.** These docs link the component library into `.preview-src/`, which is also where CI checks it out, so one path convention covers local and CI alike. (Loom itself is an ordinary dependency — `@loom-dev/layout` ships its wasm engine prebuilt, so the host needs no Rust toolchain.) The component that renders each frame sets `src` lazily through an `IntersectionObserver` (a gallery page can hold a dozen frames), appends `&theme=` from the site's current theme at load, and pushes later theme toggles into loaded frames with the `postMessage` above. > **Non-Vite hosts** > > `loom build` is still the right tool when the host isn't a Vite app (a Hugo/Jekyll site, a CI job > that publishes the gallery on its own). The output is identical — `buildGallery()` is the same > pipeline behind a function call. ## Error containment survives the build The per-target error boundary is part of the shell, not the dev server. A target that throws in a built, deployed gallery still renders an inline error panel with its stack while the rest of the page keeps working — which is what you want in a docs site, where one broken example should not take down the page it lives on. --- # Previews on a phone > What a preview does on a 390px screen — the stage is the viewport and the scene reflows, the ?base= opt-out for a wide composition in a narrow frame, pointer coordinate mapping, touch scrolling, and the gallery chrome on a narrow viewport. Source: https://docs.astra-void.xyz/loom/guides/mobile-previews/ A preview lays the scene out against the stage it is given: whatever the mount measures **is** the Roblox viewport, mirrored onto `Workspace.CurrentCamera.ViewportSize` and fed to the layout engine. That holds at every width and on every device, so a phone gets a phone-sized viewport and the scene reflows into it exactly as the engine reflows there. Nothing here needs configuration. The generated pages also size with `dvh`, so a full-height stage does not hang underneath a mobile browser's toolbars. ## The stage is the viewport Text re-wraps at the narrow width, a `UIListLayout` with `Wraps` re-flows onto more lines, `AutomaticSize` re-measures, and a scene that branches on `ViewportSize` takes its narrow path. A layout built out of offsets that is wider than the screen runs off the edge and is clipped — which is what it does on a real device, and is usually the thing you opened the preview on a phone to find out. > **Loom does not make your UI responsive** > > Roblox does not, so neither does loom. If a scene should adapt to a small screen, it adapts in the > scene — a `UIScale`, a breakpoint on `ViewportSize`, an `AbsoluteSize` binding. The preview's job is > to show you what the engine would show, including when that is a layout hanging off the edge. ## Why zooming is not the default Until `0.10.2`, a coarse-pointer device below 960px wide kept a **960-wide logical viewport** and painted the whole stage scaled down by `hostWidth / 960`. It read as the same layout drawn smaller. It was not: it was a *different* layout, the one the engine gives at 960, shrunk to fit. `TextWrapped` text kept the line breaks it had at 960 instead of re-wrapping, a `UIListLayout` with `Wraps` kept its row count, `AutomaticSize` settled at the wide measurement, and scale-vs-offset mixes re-proportioned against the wrong number. Everything *looked* fine — just small — and was wrong in exactly the places a narrow viewport is the thing being checked. (`0.6.4` had already scoped the zoom off desktop for the same reason: an author dragging a window narrow is asking to see the reflow. `0.10.2` finished the job.) ## `?base=` keeps a wide composition The zoom is still there, because a page embedding a preview at a fixed narrow width sometimes wants the composition rather than the reflow — a thumbnail of a wide dashboard in a docs column, say. It just has to be asked for: | Value | Effect | | --- | --- | | *absent* | **The default.** The scene lays out against the real viewport at every width, on every device. | | `?base=` | Keep this logical width. Below it the stage scales down to fit; at or above it nothing is applied. | | `?base` (bare) | The same, at 960. A value that cannot be read means this too — the param was typed to turn the zoom *on*. | | `?base=none` (or `off`, or `0`) | The default, spelled out, for a host page templating the param. | ```text title="A 1280-wide logical viewport, whatever the frame is" /loom-preview/index.html?target=src%2Fscenes%2FCounter.loom.tsx&chrome=none&base=1280 ``` 960 is the bare-`?base` fallback rather than a constant with one right answer — the wider the base, the more desktop layout survives intact and the smaller everything is drawn. It is wide enough that a two-column or fixed-panel layout still has room, and small enough that a phone renders at roughly 40% rather than the ~30% a 1280 base would give, where body text stops being readable. With a base in effect the world still reads the mount's **untransformed** layout size, so the scene sees the logical viewport and you are previewing the wide layout, drawn smaller. The [debug panel](https://docs.astra-void.xyz/loom/guides/debug-panel.md) shows both boxes and the factor between them, which is how you tell a scene that laid out small from one that was painted small. `?base=` sits alongside the rest of the [URL contract](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#the-url-contract). ## Pointer coordinates follow the scale Whenever `?base=` is scaling the stage, an on-screen pixel is not a layout pixel. The renderer converts back by reading the mount's own rendered-to-layout ratio, so hit testing lands where the scene *looks* like it is. That covers: - `MouseEnter` / `Activated` / `InputChanged` positions - `GetMouseLocation` - Wheel deltas Unscaled — the default — the ratio is 1 and the conversion is a no-op. ## ScrollingFrames scroll from a touch drag There is no wheel on a phone. A drag inside a `ScrollingFrame` moves `CanvasPosition` with the same clamping the wheel path uses, and past a small slop threshold it stops counting as a tap — so dragging past a button does not activate the button under your finger. Two deliberate limits: - **Only `ScrollingFrame`s opt out of native touch panning.** A preview embedded in a docs page never traps the reader's scroll; the page keeps scrolling normally everywhere else. - **Taps do not wait for the double-tap-zoom timeout.** A tap registers immediately rather than after the browser's ~300ms delay. ## The gallery chrome stacks In full-chrome gallery mode, the 248px sidebar becomes a top bar with a `targets` button. Opening it shows the target list; picking one closes it again, leaving the rest of the screen to the stage. `?chrome=none` — what the [docs-site iframes](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md) use — is unaffected, since there is no chrome to stack. Embedded previews get the touch handling and nothing else. --- # Next.js integration > Serve the loom gallery from a Next.js app with withLoomGallery() — a live proxied gallery under next dev, an automatic static build under next build, and scene iframes in any page, Fumadocs included. Source: https://docs.astra-void.xyz/loom/guides/nextjs-integration/ Next.js owns its bundler and its dev server, so neither of loom's other entry points fits: the [Vite plugin](https://docs.astra-void.xyz/loom/guides/vite-integration.md) rewrites `react` and `@rbxts/*` for the whole config it lives in — dropped into Next's webpack or Turbopack config it would hijack the app's own React — and [`loom-dev/embed`](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#a-working-embed-pipeline) hands you a Connect middleware that `next dev` has no hook to mount. `loom-dev/next` closes the gap with the same isolation the embed API is built on: the gallery keeps a Vite instance of its own, and Next only ever forwards HTTP to it. Everything below was verified end to end against two apps: Next 15.5 (pages router, webpack, React 18) and Next 16.2 (App Router, Turbopack, React 19) — the second running Fumadocs 16. ## How it fits together One wrapper, three behaviors — `withLoomGallery` returns Next's *function config* form (`(phase, ctx) => config`), and the phase Next passes in decides everything: | Command | Phase | Gallery comes from | What the wrapper injects | | --- | --- | --- | --- | | `next dev` | `phase-development-server` | A loom Vite server on an ephemeral loopback port, booted lazily | `beforeFiles` rewrites proxying `/loom-preview/*` to it | | `next build` | `phase-production-build` | Freshly emitted into `public/loom-preview` before the app compiles | The same `afterFiles` rewrite `next start` uses | | `next start` | — | The static build sitting in `public/loom-preview` | An `afterFiles` rewrite mapping `/loom-preview` onto its `index.html` | In dev, two servers run — but you only ever start one: ```text browser ──► next dev (:3000) ── beforeFiles rewrite ──► loom Vite server (127.0.0.1:) │ │ └────────────── HMR WebSocket (direct, bypasses Next) ◄──┘ ``` Your app's React never meets the gallery's. The loom aliases (`react` pinned to 18, `@rbxts/*` → the loom adapters) live entirely inside the gallery's Vite instance; Next's own bundler config is untouched, which is why webpack and Turbopack behave identically — the integration sits at Next's routing layer, not its bundler. ## 1. Dependencies ```bash npm i -D loom-dev ``` `withLoomGallery` ships in `loom-dev` 0.4.0 and newer. Nothing else to install: your app keeps whatever React and Next versions it already has, and no `@loom-dev/*` packages enter its dependency tree. > **If you prune devDependencies in production** > > `next start` evaluates `next.config` too, and the config imports `loom-dev/next` — so `loom-dev` > must be resolvable wherever `next start` runs. If your deploy prunes devDependencies, list > `loom-dev` under `dependencies` instead. The heavy part stays cheap either way: Vite is loaded > lazily, only when the dev server boots or the build-time gallery is emitted — never under > `next start`. ## 2. The config ```ts title="next.config.ts" import { withLoomGallery } from "loom-dev/next"; export default withLoomGallery( { reactStrictMode: true }, // your Next config { root: "../my-ui", // the roblox-ts project, relative to the app dir targets: "src/scenes", // same semantics as loom preview --targets }, ); ``` That is the entire setup — no prebuild script, no manual `loom build`, no second terminal. > **Compose it outermost** > > The wrapper accepts *your* config as an object or a function, but what it returns is always the > function form — object-shaped wrappers can't wrap that. So when composing, `withLoomGallery` goes > on the outside: `withLoomGallery(withMDX(config), options)`, not the other way around. ## 3. What each command does ### `next dev` The first time Next resolves its rewrites, the wrapper boots the gallery: the embed API's middleware behind a loopback HTTP server on an OS-assigned free port. Booting *inside* the `rewrites()` call is deliberate — Next evaluates `next.config` in more than one process, but only the process that actually serves resolves rewrites, so exactly one Vite instance starts however many times the config file is loaded. From there: - **Target discovery, per-scene HMR, and the error panel** all work exactly as under `loom preview`. The HMR WebSocket connects straight to Vite's own port and never passes through Next. - **The proxy rules are `beforeFiles` rewrites**, which Next consults ahead of `public/` — so a stale static gallery committed to `public/loom-preview` can never shadow the live one during development. - **A gallery that fails to boot does not take `next dev` down.** The error is logged (`loom: gallery dev server failed to start`), your own rewrites are kept, the proxy rules are skipped — `/loom-preview` 404s — and the boot is retried the next time Next asks for the rewrites (a config reload) rather than the failure being cached forever. ### `next build` The wrapper emits the static gallery into `public/loom-preview` — the identical bundle [`loom build`](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md) produces, per-scene async chunks, `wasm-opt`-processed binary and all — and then lets the app build proceed. Next re-evaluates the config in the worker processes it spawns during a build; an environment marker (`LOOM_NEXT_GALLERY_BUILT`, inherited by child processes) keeps them from rebuilding the gallery, so it is emitted exactly once per build. Two edge cases are handled asymmetrically, on purpose: - **Zero targets** warns and skips — adding the wrapper before writing your first `*.loom.tsx` must not brick `next build`. - **A real gallery build error fails the app build**, the same contract an Astro integration's build hook has. A broken `/loom-preview` should not ship silently. Add the output to `.gitignore` — it is a build artifact: ```text title=".gitignore" public/loom-preview/ ``` For a CI that runs `loom build` itself (or a repo that commits the static gallery), pass `staticBuild: false` and the build phase touches nothing. ### `next start` Nothing boots and nothing is built — the app serves the static gallery out of `public/`. The one piece Next won't do on its own is serve an `index.html` for a *directory* under `public/`, so the wrapper's `afterFiles` rewrite maps the bare `/loom-preview` onto `/loom-preview/index.html`. Asset URLs inside the bundle are absolute under the mount path (the gallery is built with `--base /loom-preview/`), so deep links and iframes work identically to dev. ## 4. Options | Option | Type | Default | Notes | | --- | --- | --- | --- | | `root` | `string` | **required** | Project dir whose targets are served. Resolved against the app dir — where `next dev` / `next build` run. | | `targets` | `string \| string[] \| true` | `true` | A glob, a directory, a list of either, or `true` for the default `**/*.loom.tsx`. Same semantics as `loom preview --targets`. | | `base` | `string` | `/loom-preview/` | Public path the gallery is mounted under. Normalized to the `/…/` shape; also decides the build output dir (`public/`). | | `port` | `number` | ephemeral | Pin the dev gallery's port instead of picking a free one per boot. | | `hmrPort` | `number \| false` | ephemeral | Vite's HMR WebSocket port; `false` disables HMR (edits then need a frame reload). | | `staticBuild` | `boolean` | `true` | `false` skips the `next build`-time gallery emit entirely. | | `shims` | `Record` | `{}` | Package redirects for roblox-ts packages loom can't run. Paths are relative to `root`, not to the Next app, so dev and the static build agree. See [Package compatibility](https://docs.astra-void.xyz/loom/guides/package-compatibility.md#shims). | | `assets` | `boolean` | `true` | `false` stops the `next build` gallery downloading the `rbxassetid://` images it mentions *(`0.7.1`)*. The dev gallery is unaffected — it has a server to resolve ids with. | ## 5. Embedding scenes in pages The [URL contract](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#the-url-contract) — `target`, `chrome`, `theme`, `background`, `base` — is identical against the proxied dev gallery and the static build, so an iframe written once works in both: ```tsx