@pre_scripts/preui-nui
v0.3.1
Published
FiveM NUI helpers for preUI: fetchNui, NUI message hooks, browser mocks and a live theme bridge
Maintainers
Readme
@pre_scripts/preui-nui
FiveM NUI helpers for preUI: talking to Lua, developing in a normal browser, and one live theme for every resource on the server. Kept out of the core package so preUI stays lean outside of FiveM.
npm install @pre_scripts/preui @pre_scripts/preui-nuiNo dependencies besides react and @pre_scripts/preui (peer dependencies). Compiled for Chromium 103 (FiveM NUI).
API
| Export | Purpose |
|---|---|
| fetchNui<T>(event, data?, mock?) | POST https://<resource>/<event> with data as JSON (resource name from GetParentResourceName()), resolves with the callback's result. In a browser nothing is sent and mock is returned. |
| useNuiEvent<T>(action, handler) | Calls handler(data) for every window message { action, data } with that action (SendNUIMessage in Lua). |
| useNuiVisibility({ closeOnEscape, initialVisible, action, closeEvent, toggleKeys }) | { visible, setVisible, close }: follows setVisible messages (data: boolean); Escape (or close()) hides the UI and calls fetchNui("close") so Lua can release the focus. An Escape inside an open Select / Dialog / menu closes only that popup. toggleKeys (e.g. ["F1"]) show and hide the page in a browser during development and are ignored in the game. |
| isEnvBrowser() | true outside FiveM (browser, Vite dev server, tests, server rendering). |
| getResourceName() | GetParentResourceName(), or "nui-frame-app" in a browser. |
| debugData([{ action, data }], delay?) | Sends mock messages in a browser (no-op in game), one every delay ms (default 1000). |
| <NuiThemeBridge /> | Applies the server theme: listens for setTheme, asks getTheme once on mount, sets tokens / scheme / theme. |
| resolveThemeTokens(payload) | Turns a payload's palette + tokens into one applyTokens input. |
| useNuiLocale({ event, action, fallback, mock }) | { locale, language, ready } — the server language from the getLocale NUI callback, updated by setLocale messages. Use it for preUI's locale props and to pick your texts / labels. |
| normalizeLocale(value) | "de_DE" → "de-DE"; null for anything that isn't a locale. |
| <NuiTextsProvider event fallback locale> / useNuiTexts() | The server's texts: asks getTexts once, follows setTexts, merges over fallback (e.g. an exported dev-texts.json). t(key, values) fills {name} placeholders, t.plural(key, count) picks key_one / key_other / … by Intl.PluralRules. Nested Lua tables become dot keys. |
| createNuiTexts(texts, { locale }), flattenTexts, formatText | The same t without React (tests, notifications). |
| useNuiMoney({ event, fallback, locale }) | { format(amount), currency, ready }: asks getCurrency once ({ format = '${amount}', decimals = 0 }), follows setCurrency, then formats every amount locally — no round trip per amount. |
| formatMoney(amount, { format, decimals, locale }) | 1234.5 → "$1,235"; the sign goes in front of the template (-$500). |
| thirdPartyLicenses() from @pre_scripts/preui-nui/vite | Vite plugin (build time): writes dist/THIRD_PARTY_LICENSES.txt with name, version, license and license text of every npm package in the bundle, fonts included. |
// main.tsx
import { ThemeProvider } from "@pre_scripts/preui";
import { NuiThemeBridge, debugData, fetchNui, useNuiEvent, useNuiVisibility } from "@pre_scripts/preui-nui";
debugData([{ action: "setVisible", data: true }]); // browser only: open the UI right away
function App() {
const { visible } = useNuiVisibility({ toggleKeys: ["F1"] }); // F1 reopens in the browser only
const [cars, setCars] = useState<Car[]>([]);
useNuiEvent<Car[]>("setCars", setCars);
const buy = (id: number) => fetchNui("buyCar", { id }, { ok: true });
return (
// storage={false}: the server is the source of truth, not this resource's own localStorage
// colorScheme={false}: keeps the NUI iframe transparent (see "Things to know")
<ThemeProvider storage={false} colorScheme={false}>
<NuiThemeBridge mock={{ scheme: "dark" }} />
{visible && <Shop cars={cars} onBuy={buy} />}
</ThemeProvider>
);
}Texts and money, with the Lua side:
const { locale } = useNuiLocale();
<NuiTextsProvider fallback={devTexts} locale={locale}>
<Cart />
</NuiTextsProvider>
function Cart({ items, cash }: { items: Item[]; cash: number }) {
const t = useNuiTexts();
const money = useNuiMoney({ locale });
const total = items.reduce((sum, item) => sum + item.price * item.amount, 0);
return (
<p>
{t.plural("cart.items", items.length)} · {money.format(total)}
{total > cash && t("shop.not_enough", { amount: money.format(total - cash) })}
</p>
);
}RegisterNUICallback('getTexts', function(_, cb) cb(texts) end) -- flat or nested table
RegisterNUICallback('getCurrency', function(_, cb) cb({ format = '${amount}', decimals = 0 }) end)Plural keys follow the locale: cart.items_one / cart.items_other in English and German, plus _few / _many in
Polish or Russian; an optional _zero is used for 0. {count} is filled in with the formatted count.
Licenses of the bundled code
React, Base UI, preUI and the rest are MIT / ISC / Apache-2.0, the fonts OFL-1.1. All of them allow almost
anything, but ask that their license text travels with the code — and a NUI's web/dist is sent to every player. The
plugin writes that file at build time, only for what actually is in the bundle:
// web/vite.config.ts
import { thirdPartyLicenses } from "@pre_scripts/preui-nui/vite";
export default defineConfig({
plugins: [react(), thirdPartyLicenses()], // → dist/THIRD_PARTY_LICENSES.txt
});Options: fileName (default "THIRD_PARTY_LICENSES.txt"), header (first lines, e.g. your resource name),
exclude (package names to leave out). Works with Vite 7 (Rollup) and 8 (Rolldown).
NuiThemeBridge must sit inside a ThemeProvider (it uses useTheme). Props: action (default "setTheme"),
getThemeEvent (default "getTheme", false to skip), mock (what getTheme returns in a browser), tokensId
(<style> id, see applyTokens) and onThemeChange(payload). Its token overrides are removed on unmount.
Theme message (protocol v1)
The same object travels in both directions: Lua pushes it with SendNUIMessage and returns it from the getTheme
NUI callback. Other core libraries can implement it the same way.
SendNUIMessage({
action = 'setTheme',
data = {
v = 1, -- protocol version
scheme = 'dark', -- 'dark' | 'light' | 'system'
theme = '', -- data-theme name; '' removes it (Lua tables can't hold nil)
palette = { -- base colours per scheme, expanded with deriveTokens
dark = { primary = '#f97316', background = '#101418' },
light = { primary = '#c2410c' },
},
tokens = { -- explicit overrides (applyTokens format), win over palette
shared = { radius = '0.75rem' },
dark = { ['--pui-ring'] = '#ffffff' },
},
},
})| Field | Type | Meaning |
|---|---|---|
| v | 1 | Protocol version. Newer versions log a warning; known fields still apply. |
| scheme | "dark" \| "light" \| "system" | Left out → the current scheme stays. |
| theme | string \| null | Named theme (data-theme). "" or null removes it; left out → stays. |
| palette | { dark?, light? } of { primary, background?, foreground?, positive?, negative?, destructive?, warning?, info? } | Expanded per scheme with deriveTokens. |
| tokens | { shared?, dark?, light? } | Token overrides; colours as hex / rgb() / hsl() / channels, names with or without --pui-. |
The message is the complete theme state (like GlobalState.theme): when neither palette nor tokens is
present, the runtime overrides are removed and the preUI defaults (or your data-theme rules) apply again. Every value
is validated in the browser — invalid colours and unsafe values are dropped — but only let trusted players change the
theme (ACE permission on the server).
Lua side
The Lua counterpart lives in your core resource, not in preUI. Minimal version (see
examples/fivem-theme for a complete resource with an in-game editor):
-- server: one theme for everyone
GlobalState.theme = { v = 1, scheme = 'dark', theme = '' }
-- client, in every resource with a preUI NUI (e.g. client_script '@preui_theme/bridge.lua')
RegisterNUICallback('getTheme', function(_, cb) cb(GlobalState.theme or {}) end)
AddStateBagChangeHandler('theme', 'global', function(_, _, value)
SendNUIMessage({ action = 'setTheme', data = value or {} })
end)SendNUIMessage only reaches the NUI frame of the resource that calls it — that's why every resource needs the
client part (an @resource/file.lua include keeps it in one place).
Things to know in FiveM
- Keep the page transparent: FiveM shows NUI pages in an iframe, and Chromium paints an opaque background behind
an iframe whose
color-schemediffers from its parent's. UsecreatePreuiPreset({ colorScheme: false })and<ThemeProvider colorScheme={false}>, otherwise preUI'scolor-scheme: darkdarkens the whole screen. - Language:
useNuiLocale()+ agetLocalecallback in Lua (RegisterNUICallback('getLocale', function(_, cb) cb(GetConvar('ox:locale', 'en')) end), included in the example'sbridge.lua). - Build for the engine:
build: { target: "chrome103" }invite.config.ts,base: "./"for relative asset paths. - Each resource's NUI has its own origin (
https://cfx-nui-<resource>/) and its own localStorage — that's why the theme comes from the server and the provider usesstorage={false}. - Until
getThemeanswers, the page shows theThemeProviderdefaults. Keep the UI hidden untilsetVisible(asuseNuiVisibilitydoes) and the first paint already has the server theme. useNuiVisibilitycloses on Escape only when no popup inside the page owns the key (preUI'sisOverlayOpen): the first Escape closes an open Select, menu or dialog, the next one closes the UI. Toasts and tooltips don't count. For a window inside the page (not the whole page), useuseWindowTogglefrom@pre_scripts/preui.- Developing in a browser:
useNuiVisibility({ toggleKeys: ["F1"] })reopens the page after Escape. In the game, Lua opens it (a keybind or command sendingsetVisible), so the keys are ignored there. - No native popups in CEF: use preUI's
Select,DatePicker,ColorPicker,AlertDialoginstead of<select>,<input type="date|color">oralert().
Development
Built with tsdown; tests run from the repository root (npm test includes this package):
npm run build # in packages/preui-nui
npm test # in the repository root