START HERE·PRACTICE TEMPLATES·Verified June 2026 · Lua 5.4 · ox_lib 3.x
Learning with an AI assistant?
Copies this whole lesson - every step, code block, and the exact console errors - plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.
Start Here · Practice Templates

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.

Goal
A /menu command opens an in-game web menu, and clicking a button runs Lua. You wire the two-way messages.
Difficulty
Advanced
Estimated time
~30 minutes
Skills practiced
NUI, SendNUIMessage, NUI callbacks, SetNuiFocus, commands, client/server split

Watch

RED: a real NUI skill-check build.
BEFORE YOU START

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 /menu in 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.

lua
-- 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
<!-- 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.

css
/* 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.

javascript
// 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.

lua
-- 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, then SetNuiFocus(true, true) so the mouse appears and the player can click.
  • TODO 1, let the page hear Lua. In script.js, use window.addEventListener('message', ...) and read event.data.action. On 'open' remove the hidden class, on 'close' add it back.
  • TODO 2, let the page reach Lua. Add a click listener to each .action button. When clicked, read its data-action and fetch it to https://qu_menu/buttonClicked with method: 'POST', a JSON content-type header, and the action in the JSON body.
  • TODO 4, handle the action in Lua. In the buttonClicked callback, read data.action. If it is 'close', send the close message and call SetNuiFocus(false, false) to release the mouse. Otherwise print which button was pressed. Always finish with cb('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 is qu_menu and the callback is buttonClicked, so the page posts to https://qu_menu/buttonClicked. The name after the slash must match the string in RegisterNUICallback exactly, or the message arrives nowhere.
  • Lua to page uses SendNUIMessage(table). The table is serialized to JSON and arrives on the page as event.data. So SendNUIMessage({ action = 'open' }) shows up as event.data.action === 'open' in the page listener.
  • Page to Lua uses fetch. The JSON body you send becomes the data table in the matching RegisterNUICallback(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({}) or cb('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.

javascript
// 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.

lua
-- 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 calls SendNUIMessage({ action = 'open' }). That table is turned into JSON and arrives in the page as event.data, where the message listener removes the hidden class 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-action and fetches it to https://qu_menu/buttonClicked. The name after the slash, buttonClicked, matches the string in RegisterNUICallback, so the message lands in that handler. The JSON body arrives as the data table, so data.action is '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, runs SetNuiFocus(false, false). That is why the player is never left frozen. Routing every close through one closeMenu function 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.