Skip to content

NUI contract (Lua ⇄ web) ​

The message contract between the Lua client and the web UI. The full source of truth is docs/ARCHITECTURE.md in the resource (§3 wire types, §4 protocol); this page summarises it and adds the decisions the web made where the contract leaves room (web/README.md). You only need it to change the web UI or write a different front end. Resources that just use menus never touch it.

The rule: navigation logic lives in the web (selection, wrap-around, hold-to-confirm, search, key repeat). Menu data, callbacks, permissions and the back stack live in Lua. The web never sees functions; Lua serializes menus into WireMenu / WireItem objects, evaluating permissions and format() on the way.

Lua → web: SendNUIMessage({ action, data }) ​

Messages sent before the web posted ready are queued (up to 256) and flushed in order.

actiondatameaning
menu:open{ menu: WireMenu, direction: 'forward' | 'back' | 'none' }Show or replace the list / grid menu, with the slide direction
menu:close{}Hide it
menu:patch{ menuId, patch: Partial<WireMenu> }Header, preview, cart or item list changed
menu:item{ menuId, itemId, patch: Partial<WireItem> }One row changed (value, display, loading, …)
input{ key, down, repeat, shift }Walk-mode navigation from Lua controls. key: up, down, left, right, enter, back, pin, search, pageup, pagedown, home, end. enter sends down and up (hold rows)
radial:open{ id, title, items, icon? }Show the wheel (Lua already set focus)
radial:close{}
radial:release{}Hold key released: the web runs the aimed slice, or closes
context:open{ menu: WireMenu, anchor: { x, y }, entityLabel? }World-anchored compact menu, anchor in 0..1 screen space
context:anchor{ x, y, visible }Anchor moved (≤ 20 Hz, only when it moved > 0.002)
context:close{}
dialog:openDialogInput or confirm dialog (Lua set focus)
dialog:close{ id }
notify{ type, title, message?, icon?, tint?, duration?, app? }Toast
settings{ brand?, strength, material, position, density, scale, sounds, locale, focusMode }Player / server settings
locale{ strings }UI strings (key hints, search, dialogs)
clipboard{ text }Copy text to the clipboard

Web → Lua: fetch('https://np_menu/<name>', { method: 'POST', body }) ​

Every body is validated in Lua, and menuId must be a menu that is currently visible.

namebodyLua does
ready{}Sends settings + locale, flushes the queue (or re-renders after a web reload)
select{ menuId, itemId }Runs onSelect / opens a submenu / confirm dialog / server action. itemId: '__cart' = cart button
change{ menuId, itemId, value }Validates and stores the value, runs onChange, answers with a menu:item patch
focus{ menuId, itemId }Remembers the selection for the back stack, runs onFocus
back{ menuId }Pops the stack (or closes the root / context menu)
close{ menuId?, kind?: 'menu' | 'radial' | 'context' }Closes
pin{ menuId, itemId }Toggles the pin (KVP), answers with a patch
jump{ menuId, itemId }Deep-search result: builds the stack down to that menu and selects the row
nuiFocus{ focus, cursor? }Walk mode: take / release focus while typing
radial:select{ id, itemId }Runs the slice; submenu slices answer { ok, items } for the next ring
radial:close{ id }Releases focus
dialog:submit{ id, values }Validates and resolves the waiting inputDialog / confirm ({ confirmed: true })
dialog:cancel{ id }Resolves with nil / false
sound{ name }PlaySoundFrontend when sounds are on (nav, select, back, error, toggle, open, close)
settingspartial settingsAccepted for validated player-setting keys (reserved)

Wire types ​

ts
type Tint = 'neutral'|'red'|'orange'|'rust'|'pink'|'olive'|'lilac'|'green'|'blue'|'aqua'|'brand'

interface WireMenu {
  id: string; title: string; subtitle?: string; icon?: string; tint?: Tint
  layout: 'list'|'grid'; position: 'right'|'left'|'center'; skin: 'default'|'staff'
  search: boolean; counter: boolean; canClose: boolean
  crumbs: string[]              // titles of the stack, root first
  depth: number                 // 1 = root
  selectedId?: string
  items: WireItem[]; dock?: WireItem[]
  preview?: PreviewCard; cart?: { label: string; total: string; action: string }
  searchIndex?: { menuId: string; itemId: string; label: string; crumbs: string[]; icon?: string }[]
  tag?: { icon?: string; label: string }   // extension
}

interface WireItem {
  id: string; type: ItemType; label?: string; sublabel?: string; description?: string
  icon?: string; tint?: Tint; keywords?: string[]
  disabled?: boolean; disabledReason?: string; loading?: boolean; badge?: number|string|true
  cooldown?: { remaining: number; total: number }   // ms
  pinned?: boolean
  // per type: rightLabel, key, variant, hold, confirm, value, display, min, max, step, bigStep, unit,
  // options: { label, value }[], index, wrap, palette, inputType, placeholder, maxLength, checked, group,
  // count, hasSubmenu
}

interface PreviewCard {
  title: string; subtitle?: string; icon?: string; image?: string; hint?: string
  stats?: { label: string; value: number /* 0-100 */; display?: string; delta?: number }[]
  palette?: { label?: string; colors: string[]; selected?: number /* 1-based */ }
}

interface Dialog {
  id: string; kind: 'input'|'confirm'; title: string; message?: string; icon?: string; tint?: Tint
  fields?: { id: string; type: 'text'|'number'|'money'|'password'|'select'|'checkbox'|'color'|'textarea'
             label: string; placeholder?: string; default?: unknown; min?: number; max?: number
             required?: boolean; options?: { label: string; value: unknown }[]; hint?: string }[]
  confirmLabel?: string; cancelLabel?: string; variant?: 'confirm'|'danger'
  timeout?: number; hold?: number
}

Web implementation notes ​

  • Disabled rows stay selectable so the reason is visible. Enter plays the error sound.
  • Optimistic values: the web updates a value immediately. A menu:item echo that arrives less than 250 ms after a local change with a different value is ignored as stale. Rows with a Lua format() keep their old display text until the patch arrives.
  • Radial rings: going back a ring is local (no callback), so radial:select can receive an item id from any ring opened since radial:open. After a non-submenu pick the web closes the wheel and posts radial:close.
  • Context menu: back on the root posts close { menuId, kind: 'context' }.
  • Focus requests: input rows, search and keybind capture post nuiFocus { focus: true } in walk mode only, and { focus: false } when done. Nothing is posted in 'nui' focus mode.
  • Lists over 60 rows are virtualised. prefers-reduced-motion is respected.
  • Optional extensions: WireMenu.tag (header meta tag), radial:open.icon (hub tile), notify.app (toast head label).

np_* FiveM resources