# 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 (
    <screengui>
      <frame
        AnchorPoint={new Vector2(0.5, 0.5)}
        Position={UDim2.fromScale(0.5, 0.5)}
        Size={UDim2.fromOffset(260, 140)}
        BackgroundColor3={Color3.fromRGB(28, 30, 36)}
      >
        <uicorner CornerRadius={new UDim(0, 12)} />
        <uipadding
          PaddingTop={new UDim(0, 20)}
          PaddingBottom={new UDim(0, 20)}
          PaddingLeft={new UDim(0, 20)}
          PaddingRight={new UDim(0, 20)}
        />
        <uilistlayout
          FillDirection={Enum.FillDirection.Vertical}
          HorizontalAlignment={Enum.HorizontalAlignment.Center}
          VerticalAlignment={Enum.VerticalAlignment.Center}
          Padding={new UDim(0, 12)}
        />

        <textlabel
          Text={`clicked ${count} times`}
          Size={new UDim2(1, 0, 0, 24)}
          BackgroundTransparency={1}
          Font={Enum.Font.GothamBold}
          TextSize={18}
          TextColor3={Color3.fromRGB(236, 238, 244)}
        />
        <textbutton
          Text="click me"
          Size={new UDim2(1, 0, 0, 40)}
          BackgroundColor3={Color3.fromRGB(64, 118, 246)}
          Font={Enum.Font.GothamBold}
          TextSize={15}
          TextColor3={Color3.fromRGB(255, 255, 255)}
          Event={{ Activated: () => setCount((n) => n + 1) }}
        >
          <uicorner CornerRadius={new UDim(0, 8)} />
        </textbutton>
      </frame>
    </screengui>
  );
}

createRoot().render(<App />);
```

```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 `<script type="module">` inside it decides what loads.

When neither exists, the CLI exits with the full candidate list rather than serving a blank page:

```text title="No entry found"
loom: no index.html and no client entry found in /Users/you/code/my-game
      looked for: src/main.client.tsx, src/main.client.ts, src/client/main.client.tsx, ...
      (or pass --targets to browse *.loom.tsx files as a gallery)
```

## The entry must mount itself

Loom does not call your component for you. The entry has to mount at module top level, exactly the
way a roblox-ts client script does:

```tsx title="src/main.client.tsx"
import { createRoot } from "@rbxts/react-roblox";
import { App } from "./App";

createRoot().render(<App />);
```

`@rbxts/react-roblox` is aliased to loom's browser client, whose `createRoot()` ignores the Roblox
target instance you would normally pass and creates its own absolutely-positioned container under
`#loom-root`. Passing `Players.LocalPlayer.WaitForChild("PlayerGui")` is harmless — the argument is
accepted and discarded — so you do not have to fork your entry for the preview.

> **A blank preview usually means the entry only exports**
>
> Two seconds after load, if `#loom-root` still has no child elements, loom logs:
>
> `[loom] nothing mounted into #loom-root after 2s — does your entry call createRoot().render(<App />) at the top level?`
>
> That is the single most common first-run problem: an entry file that exports a component instead of
> rendering one. Check the browser console before assuming the layout engine failed.

For `vide` the same rule applies with `mount`:

```ts title="src/main.client.ts (vide)"
import { mount } from "@rbxts/vide";
import { App } from "./App";

mount(App);
```

## Preview many components at once

One entry gives you one screen. A component library wants a browsable list instead, which is what
**gallery mode** is: name any file `*.loom.tsx`, export a `preview` object from it, and add
`--targets`.

```tsx title="src/scenes/Button.loom.tsx"
export const preview = {
  render: () => (
    <textbutton
      Text="click me"
      Size={new UDim2(0, 160, 0, 40)}
      BackgroundColor3={Color3.fromRGB(64, 118, 246)}
      TextColor3={Color3.fromRGB(255, 255, 255)}
    />
  ),
  title: "Button",
} as const;
```

```bash title="Browse every scene under the project"
pnpm exec loom preview . --targets
```

Every match gets a sidebar entry, mounts lazily, and is wrapped in its own error boundary — one
broken scene does not take the page down. No client entry is needed in gallery mode, and
`loom build --targets` emits the same thing as a static, deep-linkable site. The target file
contract, the glob rules and the URL parameters are in [Gallery
targets](https://docs.astra-void.xyz/loom/guides/gallery-targets.md).

## Flags

```text
loom preview [dir] [--port <n>] [--host] [--targets [glob]]
```

`dir` defaults to `.`. `--port` takes a number and errors out if it is not numeric. `--host` is a
boolean that binds all interfaces, unless it is followed by a non-flag value, which is then used as
the bind address. `--targets` switches to gallery mode, covered in [Gallery
targets](https://docs.astra-void.xyz/loom/guides/gallery-targets.md).

An optional `<dir>/loom.config.ts` supplies `targets` and `port` when the flags are absent; CLI flags
always win. See the [CLI reference](https://docs.astra-void.xyz/loom/reference/cli.md) for the exact precedence rules.

## What you get

Standard Vite dev-server behavior, because it *is* a Vite dev server: HMR on save, source maps, and
esbuild-speed TSX transpilation. Nothing runs `rbxtsc`. Input works — clicks reach `Activated`,
typing reaches `TextBox`, and `UserInputService` fires — so state-driven UI can be exercised in the
browser rather than in Studio.

## Next step

Before you trust what you see, read [Scope and status](https://docs.astra-void.xyz/loom/getting-started/scope-and-status.md) —
it lists what loom renders faithfully and what it accepts and ignores.

After that: [Vite integration](https://docs.astra-void.xyz/loom/guides/vite-integration.md) when you want the preview to live
inside your own project, or [How it works](https://docs.astra-void.xyz/loom/getting-started/how-it-works.md) for the architecture
behind it.
