Reference for migrating from ox_lib, RageUI / RageNativeUI, qb-menu, and esx_menu_default.
- Basic installation
- Migrating from ox_lib
- Migrating from RageUI / RageNativeUI
- Migrating from qb-menu / esx_menu_default
- Conceptual differences
- Async API (coroutine-style)
- Full equivalence table
-- In the fxmanifest.lua of each client resource that uses LastMenu:
client_scripts { '@LastMenu/client/exports.lua' }
-- In server.cfg, BEFORE resources that depend on it:
ensure LastMenuShorthand reference in code:
local UI = exports['LastMenu']| ox_lib | LastMenu |
|---|---|
lib.registerContext({ id, title, options }) |
builder UI:context(fn) |
lib.showContext('menu-id') |
open is part of the builder call |
{ title, description, icon, onSelect } |
menu:button(label, { icon, hint, cb }) |
metadata = { { label, value } } |
preview = { stats = { { label, value, max } } } |
disabled = true |
disabled = true (or a reactive function) |
arrow = true |
arrow = true |
ox_lib:
lib.registerContext({
id = 'garage',
title = 'Garage',
options = {
{
title = 'Repair engine',
description = 'Requires 500€',
icon = 'wrench',
onSelect = function() repairEngine() end,
},
{
title = 'Repaint',
icon = 'paintbrush',
disabled = GetVehiclePedIsIn(PlayerPedId(), false) == 0,
onSelect = function() end,
},
},
})
lib.showContext('garage')LastMenu equivalent:
UI:context(function(menu)
menu:title('Garage')
menu:button('Repair engine', {
icon = 'wrench',
hint = '500€',
cb = function() repairEngine() end,
})
menu:button('Repaint', {
icon = 'paintbrush',
disabled = GetVehiclePedIsIn(PlayerPedId(), false) == 0,
cb = function() end,
})
end)| ox_lib | LastMenu |
|---|---|
lib.inputDialog(title, rows) blocking |
UI:input_async(fn) blocking (coroutine) |
{ type = 'input', label, default } |
b:field(label, { type = 'text', default }) |
{ type = 'number', min, max } |
b:field(label, { type = 'number', min, max }) |
returns nil if cancelled |
returns nil if cancelled |
returns { [1] = value, ... } |
returns { [1] = value, ... } |
ox_lib:
Citizen.CreateThread(function()
local input = lib.inputDialog('Purchase', {
{ type = 'number', label = 'Quantity', min = 1, max = 10 },
{ type = 'input', label = 'Note' },
})
if not input then return end
print(input[1], input[2])
end)LastMenu equivalent:
Citizen.CreateThread(function()
local values = UI:input_async(function(b)
b:title('Purchase')
b:field('Quantity', { type = 'number', min = 1, max = 10 })
b:field('Note', { type = 'text' })
b:confirm_label('OK')
b:cancel_label('Cancel')
end)
if not values then return end
print(values[1], values[2])
end)| ox_lib | LastMenu |
|---|---|
lib.alertDialog({ header, content }) |
UI:alert_async(fn) |
returns 'confirm' or 'cancel' |
returns true or false |
ox_lib:
Citizen.CreateThread(function()
local result = lib.alertDialog({
header = 'Delete?',
content = 'This action is irreversible.',
})
if result == 'confirm' then
-- ...
end
end)LastMenu equivalent:
Citizen.CreateThread(function()
local confirmed = UI:alert_async(function(b)
b:title('Delete?')
b:message('This action is irreversible.')
b:confirm_label('Yes, delete')
b:cancel_label('Cancel')
end)
if confirmed then
-- ...
end
end)| ox_lib | LastMenu |
|---|---|
lib.notify({ title, description, type, duration }) |
UI:notify(fn) |
type = 'success' |
n:type('success') |
type = 'error' |
n:type('error') |
type = 'warning' |
n:type('warn') |
type = 'inform' |
n:type('info') |
ox_lib:
lib.notify({ title = 'Done', description = 'Engine repaired.', type = 'success', duration = 3000 })LastMenu equivalent:
UI:notify(function(n)
n:message('Engine repaired.')
n:type('success')
n:duration(3000)
end)| ox_target | LastMenu |
|---|---|
exports.ox_target:addLocalEntity(entity, opts) |
UI:target_add_entity(entity, opts) |
exports.ox_target:addModel(models, opts) |
UI:target_add_model(model, opts) |
exports.ox_target:addSphereZone(opts) |
UI:target_add_sphere(coords, radius, opts) |
exports.ox_target:addBoxZone(opts) |
UI:target_add_box(coords, opts) |
exports.ox_target:addPolyZone(opts) |
UI:target_add_poly(points, opts) |
exports.ox_target:removeLocalEntity(entity) |
UI:target_remove(id) |
{ label, icon, onSelect = fn } |
{ label, icon, cb = fn } |
canInteract = function(entity, ...) end |
condition = function(entity) return bool end |
ox_target:
exports.ox_target:addSphereZone({
coords = vector3(100.0, 200.0, 30.0),
radius = 2.0,
options = {
{
label = 'Interact',
icon = 'fa-hand',
canInteract = function(entity, distance, coords, name) return true end,
onSelect = function(data) print('Interacted') end,
},
},
})LastMenu equivalent:
UI:target_add_sphere(vector3(100.0, 200.0, 30.0), 2.0, function(t)
t:label('Zone')
t:button('Interact', {
icon = 'hand',
condition = function() return true end,
cb = function() print('Interacted') end,
})
end)| ox_lib | LastMenu |
|---|---|
lib.progressBar({ duration, label, … }) |
UI:progress(fn) |
anim = { dict, clip, flag } |
p:anim({ dict, clip, flag }) |
prop = { model, bone, … } |
p:prop({ model, bone, offset, rot }) |
| blocking (coroutine) | non-blocking (callbacks) |
ox_lib:
if lib.progressBar({ duration = 5000, label = 'Repairing…',
anim = { dict = 'mini@repair', clip = 'fixing_a_ped' } }) then
print('Done')
endLastMenu equivalent:
UI:progress(function(p)
p:label('Repairing…')
p:duration(5000)
p:icon('wrench')
p:anim({ dict = 'mini@repair', clip = 'fixing_a_ped' })
p:confirm(function() print('Done') end)
end)| RageUI | LastMenu |
|---|---|
RageUI.Menu('title', 'subtitle', …) |
UI:context(fn) |
RageUI.Item('label', 'description') |
menu:button(label, { hint = desc }) |
RageUI.CheckboxItem('label', state) |
menu:checkbox(label, { default = state }) |
RageUI.ListItem('label', list, index) |
menu:list(label, { items = list, default = index }) |
RageUI.SliderItem('label', min, max, val) |
menu:slider(label, { min, max, default = val }) |
menu pool + RageUI.IsAnyMenuOpen() |
handled automatically by the stack |
RageUI.Render(fn) — per-frame loop |
no loop required |
RageUI:
local menuVisible = false
local menu = RageUI.Menu('Garage', 'Repair your vehicle')
Citizen.CreateThread(function()
while true do
Citizen.Wait(0)
if menuVisible then
RageUI.Render(function()
RageUI.UseMenu(menu, function()
local repairItem = RageUI.Item('Repair', '500€')
if repairItem.Activated then
repairEngine()
end
end)
end)
end
end
end)
RegisterCommand('garage', function()
menuVisible = not menuVisible
end, false)LastMenu equivalent:
RegisterCommand('garage', function()
UI:context(function(menu)
menu:title('Garage')
menu:description('Repair your vehicle.')
menu:button('Repair', { hint = '500€', cb = function() repairEngine() end })
end)
end, false)LastMenu has no render loop — no
Citizen.Wait(0)needed.
RageUI:
local subMenu = RageUI.Menu('Sub-menu', '')
local item, activated, hovered = RageUI.SubMenu(subMenu, 'Go to sub-menu')
if activated then RageUI.OpenMenu(subMenu) endLastMenu equivalent:
menu:submenu('Go to sub-menu', function(sub)
sub:title('Sub-menu')
sub:button('Action', { cb = function() end })
sub:back()
end, { icon = 'chevron-right' })| qb-menu | LastMenu |
|---|---|
exports['qb-menu']:openMenu(items) |
UI:context(fn) |
{ header, txt, isMenuHeader } |
menu:header(label) |
{ header, txt, params = { event, args } } |
menu:button(label, { cb = function() TriggerEvent(…) end }) |
{ header, txt, disabled } |
menu:button(label, { disabled = true }) |
exports['qb-menu']:closeMenu() |
Stack.pop() or Escape |
qb-menu:
exports['qb-menu']:openMenu({
{ header = 'Garage', isMenuHeader = true },
{
header = 'Repair engine',
txt = 'Cost: 500€',
params = { event = 'garage:repairEngine', args = {} },
},
{
header = 'Disabled option',
disabled = true,
},
})LastMenu equivalent:
UI:context(function(menu)
menu:header('Garage')
menu:button('Repair engine', {
hint = '500€',
cb = function() TriggerEvent('garage:repairEngine') end,
})
menu:button('Disabled option', { disabled = true })
end)| esx_menu_default | LastMenu |
|---|---|
ESX.UI.Menu.Open('default', …, items, cb_select, cb_cancel) |
UI:context(fn) |
{ label, value } |
menu:button(label, { cb = function() end }) |
cb_cancel = function() end |
handled by Escape / menu:cancelable(false) |
ox_lib, RageUI, and NativeUI require an active render loop to update state. LastMenu uses a reactive polling engine — declare what is dynamic, the library handles the rest.
-- LastMenu: dynamic label with no loop
menu:button(function()
return 'Money: ' .. GetPlayerMoney()
end, { refresh = 500 })ox_lib uses string IDs to reference and reopen menus (lib.showContext('id')).
LastMenu uses handles:
-- ox_lib
lib.registerContext({ id = 'shop', … })
lib.showContext('shop')
-- LastMenu
local shopMenu = UI:context_build(function(menu) … end)
shopMenu.open()
shopMenu.close()In RageUI / ox_lib you manually manage which menu is open.
In LastMenu, every UI:context(…) call inside a callback pushes onto the stack — Escape pops automatically.
The navigation export is named lastmenu_back (to avoid collisions with other resources):
exports.LastMenu:lastmenu_back() -- equivalent to pressing EscapeThe builder method menu:back() is unchanged.
input_async and alert_async block the current coroutine, just like lib.inputDialog and lib.alertDialog in ox_lib.
Requirement: must be called from a
Citizen.CreateThread. If called from aRegisterCommandorAddEventHandler, wrap inCitizen.CreateThread(function() … end).
Citizen.CreateThread(function()
local values = exports.LastMenu:input_async(function(b)
b:title('Bank Transfer')
b:field('Recipient', { type = 'text', placeholder = 'Player name' })
b:field('Amount', { type = 'number', min = 1, max = 100000 })
b:field('Note', { type = 'text', maxlen = 50 })
b:confirm_label('Send')
b:cancel_label('Cancel')
end)
if not values then
print('Transfer cancelled.')
return
end
local recipient = values[1]
local amount = values[2] -- already cast to number
local note = values[3]
TriggerServerEvent('bank:transfer', recipient, amount, note)
end)Citizen.CreateThread(function()
local confirmed = exports.LastMenu:alert_async(function(b)
b:title('Reset account?')
b:message('All progress will be lost. This action is irreversible.')
b:confirm_label('Reset')
b:cancel_label('Cancel')
end)
if confirmed then
TriggerServerEvent('account:reset')
end
end)Citizen.CreateThread(function()
-- Step 1: collect data
local values = exports.LastMenu:input_async(function(b)
b:title('Create Character')
b:field('First Name', { type = 'text', maxlen = 20 })
b:field('Last Name', { type = 'text', maxlen = 20 })
b:confirm_label('Next')
end)
if not values then return end
-- Step 2: confirm
local ok = exports.LastMenu:alert_async(function(b)
b:title('Confirm creation')
b:message(string.format('Create character %s %s?', values[1], values[2]))
b:confirm_label('Create')
b:cancel_label('Go back')
end)
if not ok then return end
TriggerServerEvent('character:create', values[1], values[2])
end)| Feature | ox_lib | RageUI | qb-menu | LastMenu |
|---|---|---|---|---|
| Context menu | lib.registerContext + showContext |
RageUI.Menu + render loop |
openMenu(items) |
UI:context(fn) |
| Reusable menu | lib.hideContext + re-show |
menu pool | — | UI:context_build(fn) |
| Input dialog (callback) | — | — | — | UI:input(fn) |
| Input dialog (blocking) | lib.inputDialog |
— | — | UI:input_async(fn) |
| Alert dialog (callback) | — | — | — | UI:alert(fn) |
| Alert dialog (blocking) | lib.alertDialog |
— | — | UI:alert_async(fn) |
| Notification toast | lib.notify |
— | — | UI:notify(fn) |
| Radial menu | lib.radialMenu |
— | — | UI:radial(fn) |
| Progress bar | lib.progressBar |
— | — | UI:progress(fn) |
| Target entity | ox_target:addLocalEntity |
— | — | UI:target_add_entity |
| Target model | ox_target:addModel |
— | — | UI:target_add_model |
| Target sphere | ox_target:addSphereZone |
— | — | UI:target_add_sphere |
| Target box | ox_target:addBoxZone |
— | — | UI:target_add_box |
| Target polygon | ox_target:addPolyZone |
— | — | UI:target_add_poly |
| Real-time reactivity | ❌ | ❌ | ❌ | ✅ (polling engine) |
| Zero framework dependency | ❌ | ✅ | ✅ | ✅ |
| Stack navigation | ✅ manual | ❌ | ✅ automatic | |
| Gamepad support | ✅ | ❌ | ✅ | |
| CSS variable theming | ❌ | ❌ | ✅ |