🤠 Wild Country Libs
Scripts

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.

Included. wc_menu is bundled inside 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

lua
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 / Close

Open a menu, navigate back, or close entirely.

FunctionDescription
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.
Updates

Modify a menu or a single item while it is open.

FunctionDescription
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.
Builders

Shorthand helpers that return a pre-configured item table.

FunctionDescription
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

all 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

Button default

A selectable action item. Fires onSelect when the player confirms.

lua
{
    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
}
Submenu type = 'submenu'

Opens a nested menu level on select. submenu can be a table or a function that returns a table (evaluated fresh each open).

static submenu
{
    id      = 'settings',
    label   = 'Settings',
    icon    = 'gear',
    type    = 'submenu',
    submenu = {
        id    = 'settings_sub',
        title = 'Settings',
        items = { ... }
    }
}
dynamic submenu (built fresh each open)
{
    id      = 'live',
    label   = 'Live Data',
    type    = 'submenu',
    submenu = function()
        return {
            id    = 'dynamic_sub',
            title = 'Live Data',
            items = buildItemsNow()
        }
    end
}
Checkbox type = 'checkbox'

A toggleable on/off item. Calls onChange with the new boolean value.

lua
{
    id       = 'notif',
    label    = 'Notifications',
    icon     = 'bell',
    type     = 'checkbox',
    value    = true,                -- current state
    onChange = function(v) end,     -- v = true/false
}
Slider type = 'slider'

A horizontal range control. Left/right keys adjust by step.

lua
{
    id       = 'volume',
    label    = 'Volume',
    icon     = 'volume-high',
    type     = 'slider',
    min      = 0,
    max      = 100,
    step     = 5,
    value    = 60,
    onChange = function(v) end,     -- v = number
}
Quantity type = 'quantity'

A +/− counter. Designed for purchase amounts. Displays price in the item row and preview panel.

lua
{
    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
}
Input type = 'input'

A text field. Calls onChange as the player types.

lua
{
    id          = 'search',
    label       = 'Search',
    icon        = 'keyboard',
    type        = 'input',
    value       = '',
    placeholder = 'Type here...',
    onChange    = function(v) end,  -- v = string
}
Select type = 'select'

A cyclic selector. Left/right keys cycle through options.

lua
{
    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
}
Divider & Label

Visual separators and section headers within the item list.

lua
{ type = 'divider' }

{ type = 'label', label = 'SECTION HEADER' }

Common item fields

These work on any item type.

all common fields
{
    -- 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

all supported 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

serverEvent / clientEvent

Any item can fire an event on selection instead of (or alongside) onSelect.

item definition
{
    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:

server handler — payload shape
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

wc.menu.action(opts)client

Shorthand for a button with event routing and optional job/grade requirements. Returns an item table.

lua
wc.menu.action({
    id           = 'start_patrol',
    label        = 'Start Patrol',
    icon         = 'shield',
    serverEvent  = 'patrol:start',
    args         = { zone = 'armadillo' },
    requiredJob  = 'sheriff',
    requiredGrade = 2,
    confirm      = 'Begin patrol?',
})
wc.menu.purchase(opts)client

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'.

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

update a badge count after 3 seconds
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)
update() vs updateItem(). 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/.

examples
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.

static lock
{
    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' },
}
dynamic lock via server callback (VORP)
-- 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)
VORP callback tip. Always pass a single table from 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.

lua
{
    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

two-level menu with back button
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

themePresetDescription
'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
override individual values
themePreset = 'shop',
theme = { accent = '#d4af37', scale = 0.95 }

Layouts

layoutDescription
'portrait'Tall, left-aligned (default)
'compact'Shorter, no title cartouche
'wide'Wider panel, centred
'dialog'Centred, confirm-style

Keyboard controls

KeyAction
W / Move up
S / Move down
A / Decrease value (slider / quantity)
D / Increase value (slider / quantity)
E / EnterSelect item
Q / BackspaceBack / close
ESCClose menu entirely
TABTab / 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).

all exports
-- 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

listen for menu actions in your script
-- 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)
push job data to keep locked items in sync
-- 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

client + server
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)
Security. Every field in the 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

Troubleshooting


Tips & gotchas

VORP callbacks. Always cb({ key = val }) — never cb(a, b). Only the first argument survives Citizen.Await.
Submenu as function. Use submenu = function() return {...} end when the items need fresh data each time the sub-menu opens.
Cache busting. If you edit NUI files (js/, css/) and changes don't appear in-game, bump the ?v=N query string in nui/index.html.
Disabled items are still navigable. Players can highlight a disabled item and read its disabledReason. Use this for requirement transparency, not to hide content.
confirm + serverEvent. The server event only fires after the player confirms. Safe to use on destructive or irreversible actions.