> ## Documentation Index
> Fetch the complete documentation index at: https://docs.9am.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# NUI bridge

> Talking to Lua from React, controlling frame visibility, theming, and runtime translations.

## The two directions

| Direction   | React side                     | Lua side                         |
| ----------- | ------------------------------ | -------------------------------- |
| Lua → React | `useNuiEvent(action, handler)` | `SendReactMessage(action, data)` |
| React → Lua | `fetchNui(name, data, mock)`   | `RegisterNUICallback(name, cb)`  |

Install the whole layer with `bunx shadcn@latest add @9am/nui @9am/visibility @9am/use-theme @9am/i18n`.

## fetchNui

```ts theme={null}
import { fetchNui } from "@/utils/fetchNui";

const result = await fetchNui<PurchaseResult>(
  "buyVehicle",
  { model: "adder", shopId: "pdm" },
  { success: true },   // returned instead in browser dev mode
);
```

Three behaviours worth knowing:

* **Ten-second timeout.** A Lua callback that never calls `cb()` would otherwise hang the promise forever.
* **Browser dev mode.** When running under `bun run start` and `mockData` is supplied, it returns the mock without touching the network.
* **It never rejects.** On failure it returns `mockData` if that was an array, otherwise `{ success: false, message: "Request failed" }`. Awaiting call sites cannot hang or throw an uncaught rejection.

The resource name comes from `window.GetParentResourceName()` in CEF, falling back to `nuiConfig.resourceName` in the browser.

<Warning>
  Every `RegisterNUICallback` must call `cb(...)` exactly once. Returning without calling it leaves the awaiting promise unresolved until the timeout fires.
</Warning>

## useNuiEvent

```ts theme={null}
import { useNuiEvent } from "@/hooks/useNuiEvent";

useNuiEvent<Vehicle[]>("setStock", (vehicles) => {
  setStock(vehicles);
});
```

The handler is kept in a ref, so you can pass an inline closure without re-subscribing on every render.

On the Lua side:

```lua theme={null}
SendReactMessage('setStock', vehicles)
```

## Visibility

`VisibilityProvider` shows and hides the app, and guarantees the player never loses input.

```jsx theme={null}
import { VisibilityProvider } from "@/providers/VisibilityProvider";

<VisibilityProvider>
  <App />
</VisibilityProvider>
```

It listens for the `setVisible` action, handles <kbd>Escape</kbd> by calling the `hideFrame` callback, and fades the tree with an opacity and scale transition. Read the state anywhere with `useVisibility()`.

From Lua:

```lua theme={null}
SetNuiVisible(true)   -- takes focus, sends setVisible
SetNuiVisible(false)  -- releases focus
```

<Note>
  The scaffold's `client/nui.lua` also releases focus on `onResourceStop`. Without that, restarting the resource while the UI is open leaves the player stuck with no input — which happens constantly during development.
</Note>

Pair it with the error boundary in `main.tsx`, which calls `hideFrame` if a render crashes. A UI error must never trap the player.

## Theme

```ts theme={null}
import { useTheme } from "@/hooks/useTheme";

const { theme, setTheme, toggleTheme } = useTheme();
```

A zustand store that toggles the `.dark` class on `<html>` and persists to `localStorage` under `nuiConfig.themeStorageKey`.

`index.html` reads the same key in an inline script before first paint, so the correct theme is applied before React mounts. Keep the two in sync when you change the key.

## Configuration

`src/nui.config.ts` is the one kit file each script is expected to edit. `9am-ui check` skips it deliberately.

```ts theme={null}
export const nuiConfig = {
  resourceName: "my-script",
  themeStorageKey: "my-script:theme",
  defaultTheme: "dark",
} as const;
```

| Field             | Purpose                                                                                |
| ----------------- | -------------------------------------------------------------------------------------- |
| `resourceName`    | Browser-dev fallback for the callback URL. In CEF the real name is read from the host. |
| `themeStorageKey` | Namespaced so two 9AM NUIs in one CEF context cannot overwrite each other              |
| `defaultTheme`    | `"light"` or `"dark"` — used before the player has toggled anything                    |

## Translations

Translations are **not bundled**. They live in the resource's `locales/*.json` and are pulled from Lua at boot, so a customer can retranslate a script without a web rebuild.

<Steps>
  <Step title="Boot the dictionary">
    ```ts theme={null}
    // main.tsx
    import { initI18n } from "./i18n";

    initI18n(__DEV_SEED__ ? () => import("../../locales/en.json") : undefined);
    ```

    The optional loader only runs under `bun run start`, where there is no Lua to ask. The kit does not hardcode the path — only your app knows where its locales live.
  </Step>

  <Step title="Use it in components">
    ```jsx theme={null}
    import { useT } from "@/i18n";

    const t = useT();
    <span>{t("ui.dashboard.stock.title")}</span>
    <span>{t("ui.catalog.priceFrom", { price: "50,000" })}</span>
    ```
  </Step>

  <Step title="Use it outside components">
    ```ts theme={null}
    import { t } from "@/i18n";
    ```
  </Step>
</Steps>

Placeholders use `{name}` syntax and are substituted from the vars object.

<Warning>
  Never call `t` at module scope. The dictionary loads asynchronously, so a translated string captured in a top-level constant will be the raw key forever. Store keys in your definition arrays and translate at render time.
</Warning>

### Resolution order

1. The dictionary from Lua
2. `KIT_DEFAULTS` — English fallbacks for the kit's own strings
3. The key itself

Step two means a freshly scaffolded script renders "No results." rather than the raw string `ui.common.noResults`. Your own keys still belong in `locales/`.

Keys the kit uses itself, all under `ui.common`: `close`, `searchPlaceholder`, `noResults`, `noResultsFound`, `selectPlaceholder`, `nSelected`, `rowCount`, `pageOf`.

### The Lua side

`shared/locale.lua` loads English first, deep-merges the active language over it, and exposes both a Lua helper and the NUI callback:

```lua theme={null}
locale('game.purchase.success', { vehicle = label, price = 50000 })
```

A key missing from the active language falls back to English; missing from both, it returns the key, so a typo is visible in-game rather than a crash.

Load it **after** `shared/config.lua` — it reads `Config.Locale`.

## Browser development

```ts theme={null}
import { isEnvBrowser } from "@/utils/misc";
import { debugData } from "@/utils/debugData";

// Simulate a message from Lua while developing in the browser
debugData([{ action: "setStock", data: mockVehicles }]);
```

`debugData` only fires in development mode and outside CEF, so it is safe to leave in place.

Gate dev-only code behind `__DEV_SEED__` — the compile-time flag is `false` for `bun run production-build`, which removes those branches and their chunks from the customer build entirely.

## Smooth scroll

```ts theme={null}
// main.tsx
import { installSmoothScroll } from "./lib/smooth-scroll";

installSmoothScroll();
```

Call this once. It is not optional: [CEF will not wheel-scroll a `clip-path`'d container](/ui-kit/cef), which includes every `Viewport`. It also gives every scroller the same eased feel in the browser and in-game.
