01. NUI Basics
Plain English
NUI = “New UI”. It’s the HTML/JavaScript layer that gets rendered on top of GTA V via an embedded browser called CEF (Chromium Embedded Framework). Phones, menus, HUDs, shops, crafting screens - all NUI.
Think of NUI as a small browser tab pinned over the game window. Your client-side Lua talks to it by sending messages. The browser sends messages back via fetch (HTTP-style requests).
Architecture
GTA V Screen
└── Client Lua (your script)
├── SendNUIMessage(data) ──► sends data to the browser
└── SetNuiFocus(true, true) ──► gives the cursor to the UI
└── NUI Frame (HTML/JS in CEF)
├── window.addEventListener('message', fn) ──► receives from Lua
└── fetch('https://res-name/cb', ...) ──► calls back to Lua
Two-way:
- Lua → JS:
SendNUIMessage(table)on Lua side;window.addEventListener('message', ...)on JS side - JS → Lua:
fetch('https://my_resource/callbackName')on JS side;RegisterNUICallback('callbackName', ...)on Lua side
The client can also forward to the server via TriggerServerEvent - the server NEVER talks to NUI directly.
Minimum Resource
my_ui/
├── fxmanifest.lua
├── client.lua
└── html/
├── index.html
├── script.js
└── style.css
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
client_script 'client.lua'
ui_page 'html/index.html' -- entry point HTML
files { -- everything the browser needs to fetch
'html/index.html',
'html/script.js',
'html/style.css',
}
ui_page declares the main HTML file. files{} lists everything the browser is allowed to load. Forget a file in files{} → 404 in the browser.
html/index.html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app" class="hidden">
<h1>My UI</h1>
<button id="close">Close</button>
</div>
<script src="script.js"></script>
</body>
</html>
html/style.css
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
background: transparent; /* CRITICAL - without this, you get a black screen over the game */
}
#app {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px;
border-radius: 8px;
}
.hidden {
visibility: hidden; /* NOT display:none - see "Golden Rules" below */
}
background: transparent on body is mandatory. Skip it → black screen forever.
Use visibility: hidden when the UI should be hidden, not display: none and not conditional rendering. Reason explained below.
html/script.js
// grab the app element on load
const app = document.getElementById('app');
// listen for messages from Lua
window.addEventListener('message', (event) => {
const data = event.data;
if (data.action === 'open') {
app.classList.remove('hidden');
} else if (data.action === 'close') {
app.classList.add('hidden');
}
});
// when the close button is clicked, send a callback to Lua
document.getElementById('close').addEventListener('click', () => {
fetch(`https://${GetParentResourceName()}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
});
GetParentResourceName() is a global FiveM provides to the browser - returns your resource’s name (e.g., 'my_ui'). Use it in fetch URLs so the right resource handles the callback.
client.lua
local isOpen = false -- track UI state
RegisterCommand('myui', function() -- /myui opens the menu
if isOpen then return end -- already open, ignore
isOpen = true
SetNuiFocus(true, true) -- (focusGrabbed, cursorVisible) - both true for clickable menus
SendNUIMessage({ action = 'open' }) -- tell the browser to show
end)
-- ↓ register what happens when the browser fetches /close
RegisterNUICallback('close', function(data, cb)
isOpen = false
SetNuiFocus(false, false) -- give control back to the game
SendNUIMessage({ action = 'close' })
cb('ok') -- ALWAYS call cb() - the fetch hangs otherwise
end)
-- ↓ CRITICAL: cleanup on resource stop
AddEventHandler('onResourceStop', function(r)
if r ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false) -- release focus or player gets stuck
end)
cb('ok') is mandatory in every RegisterNUICallback. The browser’s fetch is waiting for a response - if you skip cb, the request hangs forever.
The Golden Rules
1. visibility: hidden, NOT conditional rendering
If you write your UI in React and do {visible && <Menu />}:
- When
visible = false, the<Menu />component is unmounted - itsuseEffects are gone, its message listeners are gone. - Lua sends
SendNUIMessage({ action: 'open' }). - The handler that would set
visible = truedoesn’t exist (it was inside the unmounted component). - Nothing happens. UI never shows.
Instead:
- Always render the component.
- Hide with CSS:
visibility: hidden. - React listeners stay alive even when invisible.
// BAD
{visible && <Menu />}
// GOOD
<Menu style={{ visibility: visible ? 'visible' : 'hidden' }} />
2. Always cleanup on onResourceStop
AddEventHandler('onResourceStop', function(r)
if r ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)
If you restart the resource while the UI is open, focus stays grabbed. Player can’t move until they manually restart the resource again from F8. Pure rage. Always cleanup.
3. Wrap fetchNui in try/catch
async function fetchNui(callback, data) {
try {
const res = await fetch(`https://${GetParentResourceName()}/${callback}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
});
return await res.json();
} catch (err) {
console.error('fetchNui error:', err);
return null;
}
}
CEF can hiccup. A thrown error in your fetch handler can kill the whole UI if uncaught.
4. Support ESC to close
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
fetchNui('close');
}
});
Players expect ESC to close menus. Don’t make them hunt for the X button.
5. Disable game controls when UI is focused
When NUI grabs focus, some GTA controls still fire (movement, weapon attacks). Disable them while open:
CreateThread(function()
while isOpen do
DisableControlAction(0, 30, true) -- 30 = move L/R (A/D)
DisableControlAction(0, 31, true) -- 31 = move F/B (W/S)
DisableControlAction(0, 24, true) -- 24 = attack
Wait(0)
end
end)
Sending Data Lua → UI
SendNUIMessage({
action = 'open',
payload = {
name = 'Shop',
items = {
{ id = 'bread', price = 10 },
{ id = 'water', price = 5 },
},
},
})
window.addEventListener('message', (e) => {
if (e.data.action === 'open') {
renderShop(e.data.payload);
}
});
The Lua table is serialized to JSON and arrives on the JS side as a regular object.
Sending Data UI → Lua (Callbacks)
JS:
await fetch(`https://${GetParentResourceName()}/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: 'bread' }),
});
Lua:
RegisterNUICallback('buy', function(data, cb)
-- data.itemId is 'bread'
TriggerServerEvent('shop:buy', data.itemId) -- forward to server
cb('ok') -- MANDATORY
end)
The Lua callback receives data as a table (parsed from the JSON body). cb is a function - call it with whatever response you want the JS side to receive.
SetNuiFocus Flags
SetNuiFocus(hasFocus, hasCursor)
| Combo | Effect |
|---|---|
(true, true) |
UI captures input, cursor visible. Menus, shops. |
(true, false) |
UI captures input, no cursor. Rare. |
(false, false) |
Game has focus. Always use this on close. |
Debugging NUI
- In-game: F8 console →
nui_devtools(requires dev mode convar) - Remote: open
chrome://inspectin any Chromium browser while the game is running, you can see and inspect every NUI frame console.logworks inside the dev tools
Enable dev tools in server.cfg:
set nui_devtools_enabled 1
Common Bugs
Black screen when UI loads
background: transparent is missing on body. Check CSS.
UI doesn’t show data
Conditional render pattern ({visible && <Menu />}) - listeners unmount. Switch to visibility: hidden.
Can’t close UI / player can’t move
SetNuiFocus(false, false) not called. Or you restarted the resource while UI was open without onResourceStop cleanup. F8 escape: type restart your_resource again.
Clicks pass through to game
SetNuiFocus(true, true) not called. UI is shown but not focused.
Cursor visible but clicks don’t register
The UI element has pointer-events: none somewhere up the tree, OR the page isn’t focused. Check CSS and call SetNuiFocus(true, true).
TL;DR
ui_page+files{}in fxmanifestSendNUIMessage→ JS,RegisterNUICallback→ LuaSetNuiFocus(true, true)to open,(false, false)to closevisibility: hidden, NOTdisplay: noneor conditional render- ALWAYS
cb('ok')in NUI callbacks, ALWAYSonResourceStopcleanup background: transparenton body or you get a black screen
Sources
- FiveM NUI Development Guide
- SetNuiFocus - native reference
- SendNUIMessage
- RegisterNUICallback
- JavaScript runtime reference - includes
GetParentResourceNameglobal
Next: 02-react-nui.md