wc_menu
Modern ornamental NUI menu library for RedM. Drop-in for any script — no dependencies beyond @wc_libs/init.lua. Supports buttons, submenus, checkboxes, sliders, quantity pickers, inputs, selects, live updates, toast notifications, and multiple theme presets.
wc_libs — no separate download or resource needed. Add dependency 'wc_libs' to your fxmanifest and the full menu API is available immediately via wc.menu.* or exports.wc_libs:*.
Quick start
wc.menu.open({
id = 'my_menu',
title = 'My Menu',
items = {
{
id = 'hello',
label = 'Say Hello',
icon = 'comment',
onSelect = function()
wc.menu.toast('Hello!', 'check', 2000)
end
},
{
id = 'close',
label = 'Close',
icon = 'door-open',
onSelect = function() wc.menu.close() end
}
}
})
Core API
Open a menu, navigate back, or close entirely.
| Function | Description |
|---|---|
wc.menu.open(menu) | Opens a menu. If one is already open, pushes the new one onto the navigation stack. |
wc.menu.close() | Closes the menu and clears the entire navigation stack. |
wc.menu.back() | Goes back one level. Closes entirely if at root. |
wc.menu.isOpen() | Returns true if any menu is currently open. |
wc.menu.currentId() | Returns the id of the currently open menu, or nil. |
Modify a menu or a single item while it is open.
| Function | Description |
|---|---|
wc.menu.update(patch) | Merges patch into the current menu and re-renders. Resets cursor position. |
wc.menu.updateItem(id, patch) | Updates a single item by id without closing or re-rendering the whole menu. Does not move the cursor. |
wc.menu.toast(text, icon, ms) | Shows a floating notification. Works whether a menu is open or not. |
Shorthand helpers that return a pre-configured item table.
| Function | Description |
|---|---|
wc.menu.action(opts) | Builds a button item with event/callback routing and optional job requirements. |
wc.menu.purchase(opts) | Builds a quantity item with purchase defaults (min, max, confirm prompt). |
wc.menu.open() — menu options
wc.menu.open({
id = 'shop_main', -- string; unique ID (auto-generated if omitted)
title = 'General Store', -- string; REQUIRED
subtitle = 'Armadillo', -- string; secondary header
description = 'Buy supplies.', -- string; tertiary text
layout = 'portrait', -- 'portrait' | 'compact' | 'wide' | 'dialog'
themePreset = 'shop', -- see Theme Presets section
theme = { accent = '#fff' }, -- overrides preset values
itemsPerPage = 6, -- items before pagination (default: Config.ItemsPerPage)
hints = { ... }, -- custom footer hints (default: Config.Hints.default)
onTab = function(data) end, -- called when TAB is pressed
onOpen = function() end, -- called after menu opens
onClose = function() end, -- called after menu closes
items = { ... }, -- array of item definitions
})
Item types
A selectable action item. Fires onSelect when the player confirms.
{
id = 'start',
label = 'Start Patrol',
icon = 'shield', -- Font Awesome icon name
image = 'badges.png', -- OR image from nui/assets/images/
description = 'Begin route.',
onSelect = function() end,
confirm = 'Are you sure?', -- shows confirm dialog before onSelect fires
}
Opens a nested menu level on select. submenu can be a table or a function that returns a table (evaluated fresh each open).
{
id = 'settings',
label = 'Settings',
icon = 'gear',
type = 'submenu',
submenu = {
id = 'settings_sub',
title = 'Settings',
items = { ... }
}
}
{
id = 'live',
label = 'Live Data',
type = 'submenu',
submenu = function()
return {
id = 'dynamic_sub',
title = 'Live Data',
items = buildItemsNow()
}
end
}
A toggleable on/off item. Calls onChange with the new boolean value.
{
id = 'notif',
label = 'Notifications',
icon = 'bell',
type = 'checkbox',
value = true, -- current state
onChange = function(v) end, -- v = true/false
}
A horizontal range control. Left/right keys adjust by step.
{
id = 'volume',
label = 'Volume',
icon = 'volume-high',
type = 'slider',
min = 0,
max = 100,
step = 5,
value = 60,
onChange = function(v) end, -- v = number
}
A +/− counter. Designed for purchase amounts. Displays price in the item row and preview panel.
{
id = 'ammo',
label = 'Ammunition',
icon = 'boxes-stacked',
type = 'quantity',
min = 1,
max = 99,
step = 1,
value = 5,
price = { money = 2 }, -- shown as $2 in item row and preview
onChange = function(v) end, -- v = number; called on every +/- press
}
A text field. Calls onChange as the player types.
{
id = 'search',
label = 'Search',
icon = 'keyboard',
type = 'input',
value = '',
placeholder = 'Type here...',
onChange = function(v) end, -- v = string
}
A cyclic selector. Left/right keys cycle through options.
{
id = 'difficulty',
label = 'Mode',
icon = 'list',
type = 'select',
value = 'normal',
options = {
{ value = 'easy', label = 'Quiet' },
{ value = 'normal', label = 'Standard' },
{ value = 'hard', label = 'High Risk' },
},
onChange = function(v) end, -- v = selected option value
}
Visual separators and section headers within the item list.
{ type = 'divider' }
{ type = 'label', label = 'SECTION HEADER' }
Common item fields
These work on any item type.
{
-- Visual
badge = 12, -- circular number badge (e.g. unread count)
rarity = 'legendary', -- 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
tags = { 'New', 'Sale' }, -- small label pills in preview
meta = { Weight = '1kg', Type = 'Ammo' }, -- key/value pills in preview
-- Stock info (display only)
stock = 50, -- how many available
owned = 3, -- how many the player has
limit = 10, -- max purchasable
-- Locking
disabled = true,
disabledReason = 'Requires Sheriff rank 3', -- shown as toast + lock banner in preview
status = 'Locked', -- shown as meta pill
-- Price
price = { money = 5 },
}
Price formats
-- Money (shorthand)
price = 5
price = { money = 5 }
-- Gold
price = { gold = 1 }
-- ROL
price = { rol = 50 }
-- Items
price = { item = 'pelt', quantity = 2, label = 'Pelts' }
-- Multiple currencies
price = { money = 10, gold = 1 }
-- Array form
price = { { money = 10 }, { gold = 1 } }
Server & client events
Any item can fire an event on selection instead of (or alongside) onSelect.
{
id = 'buy_horse',
label = 'Buy Horse',
type = 'button',
serverEvent = 'myshop:purchase', -- triggers TriggerServerEvent
clientEvent = 'myshop:localAction', -- triggers TriggerEvent
args = { item = 'horse', color = 'black' },
}
The event handler receives a single payload table:
RegisterNetEvent('myshop:purchase', function(payload)
-- payload.menuId = 'my_menu'
-- payload.itemId = 'buy_horse'
-- payload.label = 'Buy Horse'
-- payload.type = 'button'
-- payload.value = nil (or quantity for type='quantity')
-- payload.price = { { money = 500 } }
-- payload.args = { item = 'horse', color = 'black' }
-- payload.index = 3
end)
Helper builders
Shorthand for a button with event routing and optional job/grade requirements. Returns an item table.
wc.menu.action({
id = 'start_patrol',
label = 'Start Patrol',
icon = 'shield',
serverEvent = 'patrol:start',
args = { zone = 'armadillo' },
requiredJob = 'sheriff',
requiredGrade = 2,
confirm = 'Begin patrol?',
})
Shorthand for a quantity item with purchase defaults. Returns an item table. Defaults: min=1, max=99, step=1, confirm='Confirm purchase?', icon='dollar-sign'.
wc.menu.purchase({
id = 'buy_ammo',
label = 'Ammo Box',
icon = 'box',
price = { money = 5 },
max = 20,
serverEvent = 'shop:buy',
args = { sku = 'ammo_box' },
})
Live updates
Update items while the menu is open — stock counters, badge counts, disabled states — without closing the menu.
wc.menu.open({
id = 'reports',
title = 'Reports',
items = {
{ id = 'inbox', label = 'Inbox', icon = 'envelope', badge = 5 }
}
})
SetTimeout(3000, function()
wc.menu.updateItem('inbox', { badge = 6 })
end)
wc.menu.update(patch) replaces the whole items list and resets the cursor. wc.menu.updateItem(id, patch) patches one item in place without moving the cursor. Prefer updateItem for live data.
Toast notifications
Works whether a menu is open or not. icon accepts a Font Awesome icon name or an image filename from nui/assets/images/.
wc.menu.toast('Patrol started!', 'shield-check', 3000)
wc.menu.toast('Not enough money.', 'circle-xmark', 2500)
wc.menu.toast('Item purchased.', 'bag-shopping', 2000)
Locked / disabled items
Disabled items remain navigable so players can read the requirement. Use this for transparent access gates.
{
id = 'special_mission',
label = 'Special Mission',
icon = 'lock',
disabled = true,
disabledReason = 'Requires Sheriff grade 3',
rarity = 'legendary',
description = 'Unlocks at Sheriff grade 3.',
tags = { 'Requirement' },
}
-- server/main.lua
VORPcore.Callback.Register('myscript:getJob', function(source, cb)
local char = VORPcore.getUser(source).getUsedCharacter
cb({ job = tostring(char.job or ''), grade = tonumber(char.jobGrade) or 0 })
end)
-- client/main.lua
RegisterCommand('mymenu', function()
local r = VORPcore.Callback.TriggerAwait('myscript:getJob')
local job = r and r.job or ''
local grade = r and r.grade or 0
wc.menu.open({
id = 'my_menu',
title = 'My Menu',
items = {
{
id = 'locked_item',
label = 'Special Access',
icon = grade >= 3 and 'unlock' or 'lock',
disabled = grade < 3,
disabledReason = grade < 3 and ('Requires Sheriff grade 3\n(yours: ' .. job .. ' ' .. grade .. ')') or nil,
onSelect = grade >= 3 and function()
wc.menu.toast('Access granted!', 'unlock', 1800)
end or nil,
}
}
})
end, false)
cb({...}). Calling cb(a, b) silently drops b — only the first value survives Citizen.Await.
Confirm dialog
Any selectable item can require player confirmation before onSelect or serverEvent fires.
{
id = 'delete',
label = 'Delete Character',
icon = 'triangle-exclamation',
confirm = 'This cannot be undone. Proceed?',
onSelect = function()
-- only fires after player clicks Yes
end
}
Submenus — navigation pattern
wc.menu.open({
id = 'root',
title = 'Main Menu',
items = {
{
id = 'sub',
label = 'Sub Menu',
type = 'submenu',
submenu = {
id = 'sub_menu',
title = 'Sub Menu',
items = {
{
id = 'back',
label = 'Back',
icon = 'arrow-left',
onSelect = function() wc.menu.back() end
}
}
}
},
{
id = 'close',
label = 'Close',
icon = 'door-open',
onSelect = function() wc.menu.close() end
}
}
})
Theme presets
| themePreset | Description |
|---|---|
'default' | Neutral |
'sheriff' | Deep red accent, heavy ornament |
'doctor' | Pale bone accent |
'shop' | Gold accent |
'stable' | Brown accent |
'crafting' | Amber accent, light ornament |
'warning' | Red accent, heavy ornament |
themePreset = 'shop',
theme = { accent = '#d4af37', scale = 0.95 }
Layouts
| layout | Description |
|---|---|
'portrait' | Tall, left-aligned (default) |
'compact' | Shorter, no title cartouche |
'wide' | Wider panel, centred |
'dialog' | Centred, confirm-style |
Keyboard controls
| Key | Action |
|---|---|
W / ↑ | Move up |
S / ↓ | Move down |
A / ← | Decrease value (slider / quantity) |
D / → | Increase value (slider / quantity) |
E / Enter | Select item |
Q / Backspace | Back / close |
ESC | Close menu entirely |
TAB | Tab / mode switch (if onTab set) |
/ | Open search / filter |
Mouse is fully supported — click to select, scroll to navigate.
Export API
Use these exports to control wc_menu from any resource (no wc_libs required).
-- Open / close
exports.wc_libs:OpenMenu(menu)
exports.wc_libs:CloseMenu()
exports.wc_libs:BackMenu()
-- Updates
exports.wc_libs:UpdateMenu(patch)
exports.wc_libs:UpdateItem(id, patch)
-- Builders
local item = exports.wc_libs:ActionItem(opts)
local item = exports.wc_libs:PurchaseItem(opts)
-- State
local open = exports.wc_libs:IsMenuOpen()
local id = exports.wc_libs:CurrentMenuId()
-- Toast
exports.wc_libs:Toast('Message', 'icon', 2500)
Events
-- Menu opened
AddEventHandler('wc_libs:menu:opened', function(menuId) end)
-- Menu closed
AddEventHandler('wc_libs:menu:closed', function(menuId) end)
-- Item selected
AddEventHandler('wc_libs:menu:itemSelected', function(menuId, itemId) end)
-- From your server script:
TriggerClientEvent('wc_libs:menu:setPlayerJob', playerId, 'sheriff', 3)
-- wc_libs will auto-recalculate disabled states for any open menu
Full example — shop menu
RegisterCommand('shop', function()
wc.menu.open({
id = 'general_store',
title = 'General Store',
subtitle = 'Armadillo',
themePreset = 'shop',
layout = 'portrait',
onClose = function()
print('Store closed')
end,
items = {
{ type = 'label', label = 'Consumables' },
wc.menu.purchase({
id = 'bandage',
label = 'Bandage',
image = 'bandage.png',
price = { money = 3 },
stock = 100,
max = 10,
description = 'Stops bleeding.',
serverEvent = 'shop:purchase',
args = { sku = 'bandage' },
}),
wc.menu.purchase({
id = 'antibiotic',
label = 'Antibiotic',
image = 'antibiotic.png',
price = { money = 8 },
stock = 40,
max = 5,
rarity = 'uncommon',
description = 'Cures infections.',
serverEvent = 'shop:purchase',
args = { sku = 'antibiotic' },
}),
{ type = 'divider' },
{ type = 'label', label = 'Equipment' },
{
id = 'horse_gear',
label = 'Horse Gear',
icon = 'horse',
type = 'submenu',
submenu = {
id = 'horse_gear_sub',
title = 'Horse Gear',
items = {
wc.menu.purchase({
id = 'saddle',
label = 'Saddle',
icon = 'horse-saddle',
price = { money = 150 },
max = 1,
serverEvent = 'shop:purchase',
args = { sku = 'saddle' },
}),
{
id = 'back',
label = 'Back',
icon = 'arrow-left',
onSelect = function() wc.menu.back() end
}
}
}
},
{ type = 'divider' },
{
id = 'close',
label = 'Leave Store',
icon = 'door-open',
onSelect = function() wc.menu.close() end
}
}
})
end, false)
-- Server-side handler
RegisterNetEvent('shop:purchase', function(payload)
local src = source
local sku = payload.args and payload.args.sku
local qty = tonumber(payload.value) or 1
-- NEVER trust payload.price — it was built client-side from the menu
-- definition and can be edited by a modified client. Look the real
-- price up server-side from your own SKU table instead:
local listing = ShopCatalog[sku]
if not listing then return end
local total = listing.price * qty
if wc:GetMoney(src) < total then
wc:Notify(src, { variant = 'fail', title = "Not enough cash." })
return
end
wc:RemoveMoney(src, total)
wc:AddItem(src, listing.item, qty)
end)
payload table — price, value, args, even itemId — was assembled on the client from the menu definition you gave it, and a modified client can send anything it wants for a serverEvent. Never pay out money or items based on payload.price; treat payload.args.sku as "the player is asking to buy this," look the real price/item up in your own server-side table, and validate the player can actually afford/carry it before granting anything.
Common mistakes
- Trusting
payload.price/payload.valueserver-side instead of re-deriving them from your own SKU/price table — see the security note above. - Forgetting
dependency 'wc_libs'in your resource'sfxmanifest.lua— the menu opens with no styling or breaks entirely if wc_libs hasn't started first. - Calling
wc.menu.open(...)from server code — the entire menu module is client-only. Trigger a client event to open it for a specific player. - Using
cb(a, b)in a VORP callback feeding a menu's dynamic lock state — onlyasurvives; wrap multiple values in one table. - Rebuilding a static submenu as a function when it never changes — only use the function form when the items genuinely need to be fresh (e.g. live stock counts).
Troubleshooting
- If the menu doesn't open at all: check the browser console (F8 in RedM) for NUI errors, and confirm
ui_page 'nui/index.html'resolved correctly — usually a sign wc_libs itself failed to start. - If styling/edits to NUI files don't show up in-game: bump the
?v=Ncache-busting query string innui/index.html, per the tip below. - If
serverEventnever fires: confirm you didn't setconfirmto a truthy value and then never see the confirm dialog appear — check for NUI focus issues from another open menu/dialogue. - If job-gated items don't lock/unlock as expected: confirm you're calling
wc.menu.setPlayerJob(client) or firingwc_libs:menu:setPlayerJob(server→client) whenever the player's job actually changes, not just once at login.
Tips & gotchas
cb({ key = val }) — never cb(a, b). Only the first argument survives Citizen.Await.
submenu = function() return {...} end when the items need fresh data each time the sub-menu opens.
js/, css/) and changes don't appear in-game, bump the ?v=N query string in nui/index.html.
disabledReason. Use this for requirement transparency, not to hide content.