The widget layer that sits on top of Taki.NUI.
Notifications, progress, context menus, dialogs, input forms, text overlays, status panels, keybind hints, and loading/error states - with theming, localisation and accessibility already handled.
- The one rule
- How it fits together
- The API
- Notifications
- Progress
- Context menus
- Dialogs
- Input forms
- Text, status and keybinds
- Theming
- Localisation
- Accessibility
- The wire protocol
- Writing your own renderer
- Development
- Troubleshooting
Taki_Kit Core (src/) contains no UI framework.
Not React. Not Vue. Not Svelte. Not jQuery. This is checked by a build gate that runs first, before a single byte is bundled:
[1/8] Framework-independence gate
ok Taki_Kit Core is framework-independent (64 source files scanned, 0 UI framework imports).
It fails the build if any file under src/ imports one, and it checks both
package.json dependencies and the actual import statements in the source.
Because a FiveM resource is not a website. It is a script that runs inside another program's process, on a machine you do not control, with a CEF browser version that lags by years. Every kilobyte and every abstraction you add to the client is a liability on every player's machine.
The split this buys:
| Client bundle | NUI page | |
|---|---|---|
| Contains React | no | yes |
| Size | 54.5 KiB | 470 KiB |
The client - the part that runs in the game's own Lua VM, where a GC pause is a visible stutter - carries no UI framework at all. React is loaded once, by the page, and only because the page is a real browser.
If you decide you do not want React, delete ui/src/react/, write 200 lines
against UiStore, and nothing in src/ changes. That is the whole point.
client script the wire the page
--------------- -------------- --------------------------
Taki.UI.notify() ──▶ UiCommand (JSON) ──▶ UiStore ──▶ React components
▲ │
└────── UiResult ───────────┘
Three rules make this work:
-
Commands go one way, results come back the other. The client never mutates state; it sends a
UiCommand. The page never invents state; it emits aUiResultwhen the user acts. "Who may change the state" is never ambiguous. -
The store is pure.
src/client/ui/ui-store.tshas no DOM, no React and no FiveM. Every behaviour - auto-dismiss, field validation, keyboard navigation, plural forms - is unit-tested underbun testwith an injected clock. -
The bridge is the only impure file.
ui/src/bridge.tsis the single place that toucheswindow.postMessageandGetParentResourceName().
src/shared/types/ui.ts UiCommand, UiState, UiResult, themes, protocol version
src/client/ui/ui-store.ts the store: all behaviour
src/client/ui/index.ts Taki.UI - the client-side API
ui/src/bridge.ts postMessage transport, validation
ui/src/theme.ts UiTheme -> CSS custom properties
ui/src/react/ the reference renderer
ui/public/ index.html, style.css (hand-written, committed)
web/ generated page (git-ignored)
Everything is on Taki.UI.
| Method | Returns | Notes |
|---|---|---|
notify(options) |
string (id) |
Auto-dismisses unless duration: 0 |
success(title, message?, duration?) |
string |
Shorthand for notify |
warn(title, message?, duration?) |
string |
Shorthand for notify |
error(title, message?, duration?) |
string |
Shorthand for notify |
info(title, message?, duration?) |
string |
Shorthand for notify |
loading(title, message?) |
string |
duration: 0, so it persists |
dismiss(id) |
void |
Removes one notification |
clearNotifications() |
void |
Removes all, and cancels their timers |
progress(options) |
string (id) |
Animates and closes itself if given duration |
updateProgress(id, value, total?, label?) |
void |
For work you drive yourself |
closeProgress(id) |
void |
|
context.open(options) |
Promise<UiResult | null> |
The full result, including action |
context.close(id?) |
void |
|
dialog.open(options) |
Promise<boolean | null> |
null on timeout |
input.open(options) |
Promise<Record<string,string> | null> |
Values are always strings |
text(options) |
string (id) |
A centred overlay |
closeText(id) |
void |
|
status(payload) |
string (id) |
Persistent HUD panel |
clearStatus(id?) |
void |
One panel, or all of them |
keybinds(entries, options?) |
Disposer |
Fires onPress |
setTheme(name | theme) |
void |
Live |
getTheme() |
UiTheme |
The resolved theme object |
setLocale(code) |
void |
Live |
getLocale() |
string |
|
getI18n() |
UiI18n |
The localizer, for your own strings |
setAccessibility(prefs) |
void |
|
open(options?) |
void |
Focus, show/hide the page |
close() |
void |
|
isOpen / isReady |
boolean |
Read-only state, for a HUD that mirrors it |
fatal(options) |
string (id) |
Full-screen error state |
receive(command) |
boolean |
Feed one command in; used by the bridge |
onCommand(handler) |
Disposer |
Observe every command that lands |
dispose() |
Disposer |
Releases pending requests. Registered automatically |
receive and onCommand are the seam for driving the UI without Taki.UI's
own helpers - a custom transport, a replay, or a test. Most code never calls
them.
Every promise resolves. None of them reject, because a UI the player dismissed
is not an exceptional condition - it is a normal outcome, and forcing every
call site into a try/catch would be noise. Check for null, which is
how a dismissal or a timeout is reported.
Taki.UI.notify({
type: "success", // info | success | warning | error | loading
title: "Success",
message: "Vehicle spawned", // optional
duration: 4000, // ms. 0 = stays until dismissed
});The store owns auto-dismiss. That decision is not the renderer's, because
duration is a statement about the notification, not about how it is drawn -
and because a renderer that forgot would leave cards on screen forever.
Sending the same id again updates the existing card in place. This is how a
loading state becomes a success without a flash:
Taki.UI.loading({ id: "save", title: "Saving…" });
// … later …
Taki.UI.notify({ id: "save", type: "success", title: "Saved", duration: 3000 });The old timer is cancelled when the replacement arrives, so the success message
lasts its own duration rather than inheriting the loading state's deadline.
At most 6 notifications are kept. Older ones are dropped, and their timers are
cancelled with them - otherwise a script that spams notify would leak a timer
per notification.
// Fire and forget: Taki_UI animates it and closes it when the duration elapses.
Taki.UI.progress({ label: "Repairing vehicle", duration: 2500 });
// Or drive it yourself.
const id = Taki.UI.progress({ label: "Downloading", variant: "modal" });
Taki.UI.updateProgress(id, 40, 100);
Taki.UI.closeProgress(id);progress() returns the bar's id, not a promise - the bar is a HUD element, not
a question. With a duration the client drives it to 100% and closes it, on a
timer that is cancelled if the resource stops.
variant: "inline" | "modal" | "bar". A determinate bar reports
aria-valuenow; an indeterminate one omits it rather than reporting a fake
zero. cancellable: true adds a cancel button.
const choice = await Taki.UI.context.open({
id: "vehicle_menu",
title: "Vehicle",
subtitle: "Blista, 2-door",
options: [
{ label: "Repair", description: "Free", action: "repair", icon: "🔧" },
{ label: "Lock", action: "lock" },
{ label: "Delete", action: "delete", tone: "danger", separatorBefore: true },
{ label: "Unavailable", disabled: true },
],
});
if (choice?.action === "repair") {
// …
}It resolves the whole UiResult, not just the action string, so optionId and
action are both available. null means the player dismissed it, the page
closed, or the request timed out - check for it.
Arrow keys, Home, End, type-ahead and Escape all work; focus is trapped
while open and restored to whatever had it before. Disabled options are skipped
by keyboard navigation, so Enter can never fire one.
searchThreshold: 5 adds a search box once there are at least that many
options.
if (
await Taki.UI.dialog.open({
id: "confirm",
title: "Delete vehicle?",
body: "This cannot be undone.",
confirmLabel: "Delete",
tone: "danger", // default | danger
})
) {
// confirmed
}Opens as role="alertdialog", which is what makes a screen reader announce it
immediately instead of waiting for a pause. Escape resolves false; a
timeout resolves null, so false unambiguously means "the player said no".
const values = await Taki.UI.input.open({
id: "deposit",
title: "Deposit",
fields: [
{ name: "amount", label: "Amount", type: "number", required: true, min: 1, max: 100_000 },
{ name: "note", label: "Note", maxLength: 16 },
{ name: "pin", label: "PIN", type: "password", pattern: "^[0-9]{4}$" },
],
});
if (values !== null) {
// { amount: "500", note: "", pin: "1234" }
}Validation is immediate and per-field. aria-invalid is set on the offending
input, the message is linked with aria-describedby, and fixing a field clears
its error on the next change rather than on submit.
Field types: text, number, password, textarea, select, checkbox.
This validation is UX, not security. It runs in the player's browser, which they fully control. The server must validate every value it receives, every time.
docs/SECURITY.mdcovers the pattern.
// A centred overlay, e.g. for a checkpoint. The id is optional.
Taki.UI.text({ id: "cp", text: "Los Santos", subtext: "Waypoint reached", duration: 2500 });
// A persistent panel. Entries can be meters, plain values, or both.
Taki.UI.status({
id: "vitals",
position: "top-right", // top-left | top-right | bottom-left | bottom-right
collapsible: true,
entries: [
{ id: "fuel", label: "Fuel", value: 68, total: 100, tone: "warn" },
{ id: "clock", label: "Time", value: "21:40", align: "right" },
],
});
// Keybind hints. Returns a Disposer.
const stop = Taki.UI.keybinds(
[
{ id: "trunk", keys: ["E"], label: "Trunk" },
{ id: "hood", keys: ["H"], label: "Hood" },
],
{ onPress: (id) => Taki.NUI.send("trunk:toggle", id) },
);Meters use role="meter" with real aria-valuenow/aria-valuemin/
aria-valuemax, so the value is announced rather than only seen.
Taki.UI.setTheme("crimson"); // a built-in
Taki.UI.setTheme({
name: "corp",
mode: "dark",
radius: "lg",
density: "comfortable",
fontScale: 1,
colors: {/* 16 HSL channel triples */},
});Built in: midnight, daylight, crimson.
Colours are bare HSL channel triples - "217 91% 60%", not #3b82f6 - so one
declaration yields every translucent variant via hsl(var(--taki-accent) / 0.2).
A theme declares ~16 colours instead of ~80.
radius is none | sm | md | lg | xl | full; density is
compact | comfortable | spacious and drives gap, padding and row height
together, so "make it tighter" is one word rather than a stylesheet edit.
Switching themes is instant and does not reload the page: it rewrites CSS custom properties on the root element.
/* ui/public/style.css - after the theme is applied */
.taki-root {
--taki-accent: 280 80% 60%;
--taki-radius: 0.75rem;
--taki-surface-alpha: 0.9;
}Useful variables: --taki-accent, --taki-radius, --taki-gap, --taki-pad,
--taki-row, --taki-font-scale, --taki-surface-alpha, --taki-blur,
--taki-dur, --taki-ease.
Taki.UI.setLocale("pt");Built in: en, pt, fr. Every built-in string switches immediately,
including plurals. Any string is accepted, so a locale with no messages falls
back to en per key rather than rendering blanks.
For your own strings, build a localizer and use it from the client script. The
Taki.UI widgets resolve their own strings through the same catalogue:
import { UiLocalizer } from "taki_kit/ui";
const t = new UiLocalizer({
catalogues: {
en: { "shop.buy": "Buy {name} for ${price}", "shop.empty": "Nothing here" },
es: { "shop.buy": "Comprar {name} por ${price}", "shop.empty": "No hay nada" },
},
locale: "en",
defaultLocale: "en",
});
Taki.UI.setLocale("es");
t.t("shop.buy", { name: "Lockpick", price: 250 }); // "Buy Lockpick for $250"| Member | Does |
|---|---|
t(key, context?, options?) |
Translate, interpolating {placeholders} |
has(key) |
Whether the current locale (or the fallback) defines it |
resolveAll(keys, context?) |
Many keys at once, for a render pass |
setLocale(code) / locale |
Switch, and read the current one |
available |
Locales with at least one message - for a picker |
Placeholders use {name}. Plural forms use ICU-style #, where the bare key is
the singular and #other is the plural:
{
"vehicles.found": "Found {count} vehicle.",
"vehicles.found#other": "Found {count} vehicles.",
}A missing key renders as the key itself rather than an empty string, so a typo is visible instead of silent.
Not an afterthought - a NUI is frequently operated without a mouse, and often by someone for whom a 180ms animation is genuinely unpleasant.
- Focus is visible. A 2px high-contrast ring on
:focus-visible. - Focus is trapped in modals and restored on close.
- Announcements. New notifications go through an
aria-live="polite"region; errors usealertdialog. Screen-reader announcements are on by default. - Reduced motion is honoured from the OS and settable in-game:
Taki.UI.setAccessibility({ reducedMotion: true }). Both collapse every transition. - High contrast strengthens borders, drops the blur, and makes surfaces opaque.
- Progress bars are real
progressbar/meterroles with real values. - Colour is never the only signal - every tone also carries an icon and a text label.
Versioned. UI_PROTOCOL_VERSION is 1, and both ends check it, so a stale
cached web/ folder fails loudly instead of misbehaving quietly.
type UiCommand =
| { action: "ui:notify"; id: string; payload: NotificationPayload }
| { action: "ui:context:open"; payload: ContextMenuPayload }
| { action: "ui:progress:update"; id: string; value: number; total?: number }
| { action: "ui:theme"; theme: UiTheme };
// … 20 moreThe union is exhaustive: adding an action without handling it is a compile error in the store, not a silently ignored message at runtime.
Everything crossing the boundary is validated. A NUI page is untrusted input
like any other - a browser extension, or a stray postMessage, must not be
able to drive the store, so an unrecognised message is dropped and logged rather
than thrown.
ui/src/bridge.ts also validates the resource name against
/^[A-Za-z0-9._-]{1,64}$/ before interpolating it into a request URL.
The store is the contract. Mount nothing from ui/src/react/ and you still get
correct behaviour:
import { UiStore } from "../../src/client/ui/ui-store";
const store = new UiStore({ protocol: 1, theme: myTheme, locale: "en" });
store.subscribe((state) => render(state));
store.dispatch({ action: "ui:notify", id: "x", payload: { type: "info", title: "Hello" } });The store gives you:
state- a frozen snapshot. Never mutate it.dispatch(command)- client to store. Returns the action, ornullif the command was rejected.selectContextOption,confirmDialog,submitInput,dismissNotification, and friends - user actions, which emit aUiResult.tick()- call periodically to decay the notification drain bars.dispose()- cancels every timer. Call it.
Inject now and schedule to make it fully deterministic under test.
bun install # once, at the repo root
bun run dev:ui # http://127.0.0.1:5173, rebuilds on save
TAKI_UI_PORT=8080 bun run dev:uiui/ is a Bun workspace member, so the single root bun install also installs
React for the NUI page. One lockfile, one install command - you never cd into
ui/ to install anything.
React is a peerDependency of @taki-kit/ui rather than a hard dependency: if
you write your own renderer, you should not inherit React.
| Command | Does |
| bun run dev:ui | Serves web/ with a source map, watches ui/src and ui/public |
| bun run build:ui | Minified page into web/ |
| bun run build:ui --dev | Unminified, with a linked source map |
| bun run build | Everything, including the 8 build gates |
| bun run check | format, lint, typecheck, test, build |
Environment: TAKI_UI_PORT (default 5173), TAKI_UI_HOST (default
127.0.0.1).
bun run dev:ui serves the page over HTTP, outside a game. GetParentResourceName()
does not exist in a normal browser, so the bridge logs that once and carries on
rendering. This is a feature: you get the real page, not a mock, so CSS and
layout iterate in seconds instead of the usual copy-into-resources/,
restart, join loop.
A dev control panel is injected into the served HTML to drive every widget. It
is never bundled into a real resource - main.tsx does not import it, and the
bundler cannot reach a file the entry point does not reference.
What standalone mode does not simulate: a real client script, net events, or anything behind a FiveM native. Use a real server for those.
TakiUI is exposed on globalThis:
TakiUI.store.state; // the full state
TakiUI.store.dispatch({ action: "ui:notify", id: "x", payload: { type: "info", title: "Hello", duration: 0 } });
TakiUI.protocol; // the protocol versionSet DEBUG_RESULTS = true in ui/src/main.tsx to log every user action. It is
off by default because a NUI page's console is the player's own, and an
unattended chatty log is noise rather than a diagnostic.
Nothing appears. Check ui_page 'web/index.html' is in fxmanifest.lua
and that web/ exists - run bun run build. Without ui_page the client
script still runs and every UI command is a no-op.
The console says "no FiveM parent detected". Expected in a browser. It means standalone mode, which is fine for CSS work. In game it should not appear.
Cards never disappear. The page is a stale build predating auto-dismiss.
bun run build and hard-reload.
A theme change does nothing. Confirm you passed a name ("crimson") or a
full theme object. A partial object is merged over the current theme; an empty
one is a no-op by design.
A menu opens behind another. Not possible - opening a context menu closes an
open dialog or input form. If you see two surfaces, one of them was opened with
raw Taki.NUI.send, which bypasses the store's bookkeeping.
React DevTools does not see it. Expected. The page is built in production mode with a minified bundle; the store, not the component tree, is the unit of behaviour.
Copyright (c) Taki. MIT licensed - see LICENSE.