Advanced FiveM NUI
Choose the surface before writing the UI: use ui_page for an interactive overlay, DUI for a browser rendered to a texture, and loadscreen for the connection screen. They have different input and lifecycle responsibilities. This guide extends NUI messages and callbacks with reproducible examples rather than assumed prop texture names.
DUI: browser output on a game texture
This self-contained client resource displays a small browser texture on screen using DrawSprite. It does not modify a game prop or require a particular asset. Create resources/[local]/doc_dui/ with these files.
Create the DUI browser
fxmanifest.lua:
fx_version 'cerulean'
game 'gta5'
client_script 'client.lua'
files { 'html/screen.html' }html/screen.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Docs DUI demonstration</title>
<style>
body { margin: 0; padding: 24px; background: #18202b; color: white; font: 24px sans-serif; }
</style>
</head>
<body><h1>DUI is ready</h1><p>This page is rendered into a runtime texture.</p></body>
</html>Turn the handle into a runtime texture
client.lua includes creation, bounded readiness waiting, drawing, and resource-stop cleanup:
local dui
local running = true
local function destroyBrowser()
if dui then
DestroyDui(dui)
dui = nil
end
end
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
running = false
destroyBrowser()
end)
CreateThread(function()
local url = ('https://cfx-nui-%s/html/screen.html'):format(GetCurrentResourceName())
dui = CreateDui(url, 512, 256)
if not dui or dui == 0 then
dui = nil
print('[doc_dui] Could not create the browser.')
return
end
local started = GetGameTimer()
while running and dui and not IsDuiAvailable(dui) do
if GetGameTimer() - started > 5000 then
print('[doc_dui] Browser readiness timed out; check the resource URL and files.')
destroyBrowser()
return
end
Wait(0)
end
if not running or not dui then return end
local dictionary = CreateRuntimeTxd('doc_dui_txd')
CreateRuntimeTextureFromDuiHandle(dictionary, 'panel', GetDuiHandle(dui))
while running do
DrawSprite('doc_dui_txd', 'panel', 0.5, 0.3, 0.4, 0.2, 0.0, 255, 255, 255, 255)
Wait(0) -- Drawing is frame-dependent; browser creation is not.
end
end)Run refresh and ensure doc_dui in the server console while a client is connected. The client should see the panel. stop doc_dui should remove it and destroy its browser. Test repeated restarts rather than leaving old browser instances alive during development.
DUI availability does not prove the HTML loaded successfully. For a blank panel, inspect the actual resource name, declared file, and NUI developer-tools network errors before changing rendering code.
Apply the texture to a prop
A prop integration requires the actual texture dictionary and texture name from that asset. Do not guess them from the prop model name. AddReplaceTexture replaces a named texture, potentially affecting multiple instances that use it. A render-target or model-specific integration needs its own asset inspection and cleanup, including removal of any replacement before destroying its DUI.
Keep the on-screen example working first; it separates a browser-loading failure from an asset-binding failure. See assets and streaming for the asset side.
DUI is not directly interactive
Creating the browser does not automatically map the player’s mouse to the surface. DUI has mouse-move, mouse-down, mouse-up, and wheel natives. A world-space screen must translate the interaction position into browser-pixel coordinates and forward matching press/release events. Use an ordinary NUI overlay when you need a form with keyboard input and accessible focus behavior.
The official DUI manual links the individual native contracts and rendering options.
Choosing the right focus mode
Passive HUDs should not take keyboard or cursor focus. Open an interactive overlay deliberately and release it in both its close path and its resource-stop cleanup. For an existing NUI resource, this Lua extension demonstrates that lifecycle:
local panelOpen = false
local function closePanel()
if panelOpen then SetNuiFocus(false, false) end
panelOpen = false
SendNUIMessage({ type = 'close' })
end
RegisterCommand('docpanel', function()
if panelOpen then closePanel(); return end
panelOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ type = 'open' })
end, false)
RegisterNUICallback('close', function(_, reply)
closePanel()
reply({ ok = true }) -- Complete every callback; do not leave fetch pending.
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName == GetCurrentResourceName() then closePanel() end
end)Pair the open and close messages with your page’s visibility handler from NUI basics. A resource-local flag does not arbitrate focus between multiple menus; coordinate with your framework’s UI owner when several resources can open overlays.
The ESC-to-close pattern
In the existing NUI page’s JavaScript, request the same close path from Escape:
window.addEventListener('keydown', async (event) => {
if (event.key !== 'Escape') return;
try {
const response = await fetch(`https://${GetParentResourceName()}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify({}),
});
if (!response.ok) throw new Error(`Close failed: ${response.status}`);
} catch (error) {
console.error('Could not close the NUI panel', error);
}
});Test opening, Escape, command-toggle closing, and restarting the resource while the overlay is focused. Do not rely only on a browser key event to restore gameplay input.
Customizing the loading screen
Create a separate doc_loadscreen resource. Its fxmanifest.lua is:
fx_version 'cerulean'
game 'gta5'
loadscreen 'html/loadscreen.html'
files { 'html/loadscreen.html', 'html/progress.js' }html/loadscreen.html:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Connecting to the server</title></head>
<body>
<h1>Connecting to the server</h1>
<label for="connection-progress">Loading game data</label>
<progress id="connection-progress" value="0" max="1"></progress>
<p id="connection-status">Waiting for progress...</p>
<script src="progress.js" defer></script>
</body>
</html>html/progress.js handles the documented payload and rejects malformed values:
const progress = document.getElementById('connection-progress');
const status = document.getElementById('connection-status');
window.addEventListener('message', ({ data }) => {
if (!data || data.eventName !== 'loadProgress') return;
if (typeof data.loadFraction !== 'number' || !Number.isFinite(data.loadFraction)) return;
const fraction = Math.min(1, Math.max(0, data.loadFraction));
progress.value = fraction;
status.textContent = `Loading game data: ${Math.round(fraction * 100)}%`;
});Add ensure doc_loadscreen on your development server and reconnect. Avoid enabling multiple resources that compete to supply a loading screen. A local browser preview can exercise the UI with window.postMessage({ eventName: 'loadProgress', loadFraction: 0.5 }, '*'); this is a UI test, not proof of an in-game connection.
Use real progress messages rather than a fake timer. A full bar describes this loading progress, not necessarily completion of your character-selection or spawning workflow. Use textContent, not innerHTML, for any player-provided handover text.
Manual shutdown is optional
The default example does not opt into manual lifetime management. Add loadscreen_manual_shutdown 'yes' only when your actual client spawn flow will call ShutdownLoadingScreenNui() when ready. A plain ShutdownLoadingScreen() call is not the same as dismissing a manually managed loading-screen NUI. Do not add an arbitrary timer to hide an unresolved connection or spawn failure.
The official loading-screen guide defines loadProgress, loadFraction, handover data, and the shutdown behavior.
Related guides
Continue with NUI basics, key bindings, resource lifecycle, and profiling UI-related script work.