NUI menu (advanced)
This is not a tutorial you copy. It is a half-finished resource called qu_menu with the wiring left out. The web page is built and the Lua command is registered, but the two of them cannot talk to each other yet. Lua never tells the page to open, the page never tells Lua a button was clicked, and the mouse is never freed. Your job is to wire the messages that pass between them, both directions, so the menu appears, the buttons reach Lua, and Escape closes it cleanly. A working solution is one click away when you want to check yourself.
Watch
The goal
You are finishing a small interface resource named qu_menu. It has the same shape every NUI resource has: a Lua side that runs inside the game, and a web page (HTML, CSS, JavaScript) rendered on top of it. They are two separate worlds that only talk through messages.
When it is done, this is the exact behaviour you want:
- Type
/menuin chat and the web menu appears in the middle of the screen, with the mouse cursor free to move and click. - The menu has two buttons. Click one and the action travels back to Lua, which prints which button was pressed (a real resource would trigger a feature here instead).
- Press Escape and the menu closes, the cursor disappears, and you can move and shoot again.
Vocabulary
- NUI
- New User Interface. A web page made of HTML, CSS, and JavaScript that FiveM renders on top of the game. Every menu, shop, and HUD you see in a server is a NUI page.
- SendNUIMessage
- The Lua function that sends a message into the page. This is the Lua to page direction: your script tells the page to open, close, or update what it shows.
- NUI callback
- A named message the page sends back to Lua. The page posts to a URL, and a matching RegisterNUICallback on the Lua side catches it. This is the page to Lua direction.
- SetNuiFocus
- The Lua function that decides whether the mouse and keyboard talk to the game or to the page. SetNuiFocus(true, true) hands input to the menu so the player can click. SetNuiFocus(false, false) hands it back to the game.
What you start with
The resource ships as a scaffold. The HTML and CSS are complete and given to you, so you can focus on the wiring. Two files have a gap in them, marked with a TODO, and those gaps are your job. Read every file before you touch anything.
The manifest. Note there is no lua54 line: Lua 5.4 is the only runtime now, so that old setting is gone. The ui_page names the entry HTML, and files lists everything the page is allowed to load. Forget a file in files and the page gets a 404 for it.
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
client_script 'client.lua'
ui_page 'html/index.html'
files {
'html/index.html',
'html/script.js',
'html/style.css',
}The page structure. This is complete. A root container that starts hidden, a title, and two buttons. Each button carries a data-action so the JavaScript knows which one was clicked.
<!-- html/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="menu" class="hidden">
<h1>Quasar Menu</h1>
<button class="action" data-action="greet">Say hello</button>
<button class="action" data-action="heal">Heal me</button>
</div>
<script src="script.js"></script>
</body>
</html>The styling. This is complete too. The one line that matters most is background: transparent on the body. Skip it and you get a solid black screen over the whole game instead of a floating menu. The menu hides with visibility: hidden, not display: none, so its click listeners stay alive while it is invisible.
/* html/style.css */
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
background: transparent;
font-family: 'Segoe UI', sans-serif;
}
#menu {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(0, 0, 0, 0.5);
color: white;
}
.hidden {
visibility: hidden;
}
.action {
min-width: 220px;
padding: 12px 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: white;
font-size: 15px;
cursor: pointer;
}
.action:hover {
background: rgba(255, 255, 255, 0.12);
}The page script. This is one of your two gaps. The page needs to do two things, and both are missing: listen for the open message from Lua, and send a button click back to Lua. The // TODO blocks mark exactly where.
// html/script.js
const menu = document.getElementById('menu');
// TODO 1: listen for messages from Lua.
// When Lua sends { action: 'open' }, remove the 'hidden' class so the menu shows.
// When Lua sends { action: 'close' }, add the 'hidden' class so it hides.
// Hint: window.addEventListener('message', (event) => { ... event.data ... })
// TODO 2: when a button is clicked, tell Lua which one.
// Each .action button has a data-action attribute ('greet' or 'heal').
// POST that action to the NUI callback URL with fetch.
// Hint: fetch('https://qu_menu/buttonClicked', { method, headers, body })
// Escape closes the menu. This part is done for you, it posts the 'close' action.
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
fetch('https://qu_menu/buttonClicked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'close' }),
});
}
});The client Lua. This is your other gap. The /menu command is registered but it does not open anything, and the NUI callback is stubbed but does not handle the action or release focus.
-- client.lua
local isOpen = false
RegisterCommand('menu', function()
if isOpen then return end
isOpen = true
-- TODO 3: open the menu.
-- Tell the page to show with SendNUIMessage, and hand input to it with SetNuiFocus.
end, false)
-- The page posts to https://qu_menu/buttonClicked, so the callback name is 'buttonClicked'.
RegisterNUICallback('buttonClicked', function(data, cb)
-- TODO 4: handle the action the page sent.
-- data.action is 'greet', 'heal', or 'close'.
-- For 'close', hide the menu and release focus with SetNuiFocus(false, false).
-- For the others, print which button was pressed (a real feature would go here).
cb('ok') -- always answer, or the page's fetch hangs forever
end)
-- Safety net: if the resource stops while the menu is open, release focus
-- so the player is not stuck. This part is done for you.
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)Your job
Wire the four TODO gaps so the two sides talk. Work through this checklist:
- TODO 3, open the menu on
/menu. Inside the command,SendNUIMessage({ action = 'open' })to tell the page to show, thenSetNuiFocus(true, true)so the mouse appears and the player can click. - TODO 1, let the page hear Lua. In
script.js, usewindow.addEventListener('message', ...)and readevent.data.action. On'open'remove thehiddenclass, on'close'add it back. - TODO 2, let the page reach Lua. Add a click listener to each
.actionbutton. When clicked, read itsdata-actionandfetchit tohttps://qu_menu/buttonClickedwithmethod: 'POST', a JSON content-type header, and the action in the JSON body. - TODO 4, handle the action in Lua. In the
buttonClickedcallback, readdata.action. If it is'close', send the close message and callSetNuiFocus(false, false)to release the mouse. Otherwise print which button was pressed. Always finish withcb('ok'). - Escape closes it. The keydown handler is already wired to post
'close', so once TODO 4 handles'close'correctly, Escape will work for free.
Hints
If you are stuck, read these in order
- The NUI callback URL always has the shape
https://RESOURCE_NAME/callbackName. Here the resource isqu_menuand the callback isbuttonClicked, so the page posts tohttps://qu_menu/buttonClicked. The name after the slash must match the string inRegisterNUICallbackexactly, or the message arrives nowhere. - Lua to page uses
SendNUIMessage(table). The table is serialized to JSON and arrives on the page asevent.data. SoSendNUIMessage({ action = 'open' })shows up asevent.data.action === 'open'in the page listener. - Page to Lua uses
fetch. The JSON body you send becomes thedatatable in the matchingRegisterNUICallback(name, function(data, cb) ... end). - Pair the focus calls.
SetNuiFocus(true, true)on open,SetNuiFocus(false, false)on close. The first argument grabs keyboard and mouse focus, the second shows the cursor. Both true for a clickable menu. cb({})orcb('ok')ends the callback and answers the page's fetch. Forget it and the fetch waits forever. Call it once at the end of every callback.
Reveal the solution
Stuck or done? Reveal a working solution
Here is the finished resource. The /menu command opens the page and grabs focus, the page listens for that open message and sends button clicks back, and the callback handles each action and releases focus on close.
The page script, completed. It listens for Lua's open and close messages, and posts each button click back to the callback.
// html/script.js
const menu = document.getElementById('menu');
// TODO 1 done: listen for messages from Lua.
window.addEventListener('message', (event) => {
const action = event.data.action;
if (action === 'open') {
menu.classList.remove('hidden'); // show
} else if (action === 'close') {
menu.classList.add('hidden'); // hide
}
});
// TODO 2 done: send each button click back to Lua.
document.querySelectorAll('.action').forEach((button) => {
button.addEventListener('click', () => {
fetch('https://qu_menu/buttonClicked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: button.dataset.action }),
});
});
});
// Escape closes the menu.
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
fetch('https://qu_menu/buttonClicked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'close' }),
});
}
});The client Lua, completed. It opens the menu, handles each action, and always releases focus when the menu closes.
-- client.lua
local isOpen = false
-- One place that closes the menu, so the focus release is never forgotten.
local function closeMenu()
isOpen = false
SendNUIMessage({ action = 'close' }) -- tell the page to hide
SetNuiFocus(false, false) -- hand input back to the game
end
RegisterCommand('menu', function()
if isOpen then return end
isOpen = true
-- TODO 3 done: open the page and grab focus.
SendNUIMessage({ action = 'open' }) -- Lua to page: show yourself
SetNuiFocus(true, true) -- grab keyboard + mouse, show the cursor
end, false)
-- The page posts to https://qu_menu/buttonClicked, so the callback name is 'buttonClicked'.
RegisterNUICallback('buttonClicked', function(data, cb)
-- TODO 4 done: handle the action the page sent.
if data.action == 'close' then
closeMenu()
elseif data.action == 'greet' then
print('[qu_menu] greet button pressed')
-- a real resource would trigger a hello feature here
elseif data.action == 'heal' then
print('[qu_menu] heal button pressed')
-- a real resource would heal the player here
end
cb('ok') -- always answer, or the page's fetch hangs forever
end)
-- Safety net: if the resource stops while the menu is open, release focus
-- so the player is not stuck.
AddEventHandler('onResourceStop', function(resource)
if resource ~= GetCurrentResourceName() then return end
SetNuiFocus(false, false)
end)How the two directions fit together:
- Lua to page. When you type
/menu, the command callsSendNUIMessage({ action = 'open' }). That table is turned into JSON and arrives in the page asevent.data, where themessagelistener removes thehiddenclass and the menu appears. The same channel sends{ action = 'close' }to hide it. - Page to Lua. When you click a button, the page reads its
data-actionandfetches it tohttps://qu_menu/buttonClicked. The name after the slash,buttonClicked, matches the string inRegisterNUICallback, so the message lands in that handler. The JSON body arrives as thedatatable, sodata.actionis'greet','heal', or'close'. - The focus pairing. Opening calls
SetNuiFocus(true, true)so the cursor shows and clicks reach the page. Every close path, the close button, the Escape key, and the resource-stop safety net, runsSetNuiFocus(false, false). That is why the player is never left frozen. Routing every close through onecloseMenufunction is the cleanest way to guarantee the release is never skipped.
What you practiced
- Declared a web page to the game with ui_page and listed its assets in files.
- Sent a message from Lua into the page with SendNUIMessage and read it as event.data on the page.
- Sent a button click from the page back to Lua by fetching a NUI callback URL.
- Matched the callback name in the fetch URL to the string in RegisterNUICallback so the message lands.
- Paired SetNuiFocus(true, true) on open with SetNuiFocus(false, false) on every close path so the player never gets stuck.
- Answered every callback with cb so the page's fetch never hangs.
Nicely done. You just wired the exact two-way conversation that sits under every menu, phone, shop, and HUD you have ever clicked in a server: Lua sends messages in, the page sends callbacks back, and focus is handed over and handed back cleanly. Keep this one as a starting skeleton, because almost every interface you build reuses this shape. When you are ready to turn a plain page like this into a full, stateful interface built with React, that is Track C, where you build complete NUI resources from scratch and then with React, step by step. For now, you are ready for Track B. Go open it and start building real scripts.