.json` file. |
| `Config.InteractionMode` | `string` | `'textui'` | How players open shops: `'textui'`, `'target'` (needs ox\_target), or `'marker'`. |
| `Config.DefaultGarage` | `string` | framework-based | Garage purchased cars are stored in. Auto-set per framework; override as needed. |
| `Config.SpawnOnPurchase` | `boolean` | `true` | `true` spawns the car at the shop and warps the buyer in; `false` saves to garage only. |
| `Config.ShowPreviewDriverPed` | `boolean` | `true` | Show a clone of the player seated in the catalog preview vehicle. |
| `Config.GlobalVAT` | `number` | `0.1` | Tax rate on all purchases (`0.1` = 10%). |
| `Config.SalesCommission` | `number` | `0.10` | Commission paid to sales staff on in-person sales. |
| `Config.VehicleImageUrl` | `string` | fivem CDN | Catalog image URL template (`%s` = model). Per-vehicle override via `image` in `vehicles.lua`. |
| `Config.PlateFormat` | `string` | `'AAAA0000'` | Plate pattern: `A` = random letter, `0` = random digit, other characters literal (max 8). |
| `Config.ReportErrors` | `boolean` | `false` | Send runtime errors to 9am.dev for diagnostics. |
| `Config.Debug` | `boolean` | `false` | Gate debug commands (`/openshop`, `/opendash`, …). |
### NPC suppression
Inside dealership zones, ambient pedestrians and traffic can be suppressed so the showroom stays clean. Player vehicles, dealer peds, and showroom vehicles are unaffected.
```lua theme={null}
Config.NpcSuppression = {
enabled = true, -- master toggle
radius = 60.0, -- meters around each shop's dealer ped
peds = true, -- block ambient pedestrians & scenario peds
vehicles = true, -- block ambient traffic & parked vehicle generators
}
```
### Finance
```lua theme={null}
Config.Finance = {
minDownPaymentPercent = 0.25, -- 25% minimum down payment
maxDownPaymentPercent = 0.90, -- highest down payment allowed
interestRate = 0.10, -- 10% interest on the financed amount
totalPayments = 12, -- default number of installments
minPayments = 2, -- fewest installments allowed
maxPayments = 24, -- most installments allowed
paymentIntervalHours = 12, -- real-time hours between auto-deductions
autoDeductFrom = 'bank', -- account type auto-deductions pull from
}
```
### Test drive
```lua theme={null}
Config.TestDrive = {
duration = 60, -- seconds the player can drive
useRoutingBucket = false, -- true = isolated bucket; false = normal world
endKey = 73, -- control index to end early (73 = X)
cooldownSeconds = 60, -- per-player cooldown between test drives
}
```
### Phone app
```lua theme={null}
Config.PhoneApp = {
enabled = true,
identifier = '9am-dealership',
icon = nil, -- nil = bundled icon; set any https:// URL to customize
}
```
`lb-phone` and `cylex_phone` are both supported and detected automatically. Employees open the dashboard from the app; permissions apply as usual.
### Orders & delivery
`Config.Orders` controls stock ordering and the delivery mission. Highlights:
* `preparationTime` — minutes until an ordered stock delivery is ready (lower it for testing; default `30`).
* `delivery.enabled` — when `false`, ready orders add to stock directly via a **Receive** button instead of running a mission.
* `delivery.truckModel`, `carTrailer`, `containerTrailer` — the truck and two trailer types (car trailer holds `S`/`M`/`L`, container holds everything including `XL`).
* `vehicleSizes` / `slotCosts` — map each vehicle category to a size (`S`/`M`/`L`/`XL`) and a trailer-slot cost.
* `containerOnlyModels` — models too large for the car trailer, forced to container-only.
* `delivery.shops[shopId]` — per-shop truck spawn and delivery destination.
`Config.CatalogColors` (colorIndex → GTA paint index) must stay in sync with `web/src/constants/catalogColors.ts`. Only change both together.
## Shops and the in-game creator
`Config.Shops` is intentionally **empty**. Shops live in the database (`9am_vehicleshop_dynamic_shops`) and are created, edited, and deleted in-game — never by editing config.
* Run `/dealerships` (alias `/dealershipcreator`) as an admin to open the creator.
* Place the shop on the map (a live blip preview uses GTA minimap tiles), set the dealer ped, showroom slots, blip, and the **Vehicle Source** (which catalog the shop sells).
* The **Garage Override** dropdown lists garages detected from your garage script; unsupported scripts fall back to garages already in use on stored vehicles, and a **Custom** option always lets you type any name.
* On first boot, the former static shops (`pdm`, `air`, `boats`) are seeded once into the database, then loaded server-side into `Config.Shops` and published to clients via GlobalState. So `Config.Shops[shopId]` works at runtime on both sides, but its contents come from the DB.
## The vehicle catalog
`vehicles.lua` is the catalog (\~150 KB). Each entry defines the spawn code, brand, price, category, and which shop(s) sell it — a vehicle may belong to multiple shops via its `shop` tag.
* **Images** load from `Config.VehicleImageUrl` (default `https://docs.fivem.net/vehicles/.webp`). Add-on / custom vehicles FiveM doesn't host should set a per-vehicle `image = 'https://...'`, or they show a broken image.
* **Blocked models** — oversized models in `Config.BlockedModels` are never spawnable for safety.
* **The `truck` catalog** — 32 heavy/commercial vehicles ship tagged `shop = 'truck'` that no default dealership sells. To sell them, create a dealership and pick **truck** as its Vehicle Source.
## Management dashboard & permissions
Staff open the dashboard with `/dealertablet`, from the phone app, or (with `Config.Debug`) the debug command. The UI is one React app with dashboard, catalog, sale-offer, finance, and shop-creator pages.
Access is gated by **granular per-employee permissions** that appear in both the server (gating) and the UI. Boss grades and shop owners bypass all checks.
| Permission | Grants |
| ----------- | ------------------------------------ |
| `sales` | Open the tablet; view sales records. |
| `case` | Shop wallet balance and ledger. |
| `stock` | Stock levels and ordering. |
| `coupons` | Create and manage discount codes. |
| `orders` | Stock orders and delivery. |
| `employees` | Hire, fire, and set permissions. |
| `settings` | Shop settings. |
## Sales, financing & coupons
* **In-person sales** — a staffer with `sales` sends an offer to a nearby player; on acceptance the buyer pays and the staffer earns `Config.SalesCommission`. Price, coupon, stock, and job checks are all re-derived server-side.
* **Financing** — the buyer chooses a down payment and installment count within the `Config.Finance` bounds; the balance auto-deducts from their bank on a real-time interval, with manual and early payoff available.
* **Coupons** — percentage or fixed discount codes, created and managed from the dashboard.
All money, price, permission, and stock validation happens **server-side** — client input is never trusted.
## Stock, orders & delivery
1. Staff order stock from the dashboard; after `preparationTime` the order is ready.
2. With delivery enabled, a truck-and-trailer mission spawns at the shop's `truckSpawn`; the driver hauls the trailer to the `deliveryPoint`.
3. Each ordered vehicle occupies trailer slots sized by category (`S`/`M`/`L`/`XL`); the car trailer holds `S`/`M`/`L`, the container holds everything.
4. On delivery the stock is added and becomes purchasable.
With delivery disabled, ready orders are added to stock directly via a **Receive** button — no mission.
## Showroom displays & info stands
Each shop can show physical display vehicles in its showroom slots, edited in-game with the showroom editor. Optional **info stands** render a standalone page (`web/showroom_stand.html`) as a **DUI texture** on in-world stand props, showing the model's name, price, and specs. Disable info stands per shop if you don't have the prop models.
## Framework & integration bridges
Two bridge files auto-detect your stack and ship unencrypted so you can edit them:
* **`bridge/server.lua`** — player lookup, money, vehicle registration, and keys/garage integration. Auto-detects the framework and errors if none is found. Plates are trimmed before being handed to key/garage scripts (GTA pads plates to 8 chars).
* **`bridge/client.lua`** — synthesizes `QBX.PlayerData` in QBox shape for all three frameworks.
Vehicle keys, garages, and phones are matched to whatever you have installed. To audit exactly what's detected vs. active, run **`dealership_audit`** in the server console — it lists every supported integration per feature with installed vs. active state.
## Custom currencies
Dealerships can take payment in something other than cash and bank — VIP coins, car tokens, crypto, an inventory item. Currencies are defined in `currencies.lua` and enabled **per dealership**, so one showroom can be VIP-only while the rest stay on cash.
The file ships fully commented out. Leave it alone and nothing changes.
The registration API is drop-in compatible with **JG Dealerships**. An existing `Currencies.Server.Register` block pastes in unchanged, including calls to `Framework.Server.GetPlayerIdentifier(src)` inside its callbacks.
```lua currencies.lua theme={null}
Currencies.Server.Register({
id = 'vip_coins',
label = 'VIP Coins',
format = '%s coins', -- how it displays: "1,250 coins"
conversionRate = 10000, -- 1 coin = $10,000, so a $50,000 car costs 5 coins
allowFinance = false,
backend = { type = 'db' },
})
```
| Field | Required | Description |
| ---------------- | -------- | ----------------------------------------------------------------------------------- |
| `id` | Yes | Unique key — lowercase letters, digits, underscores |
| `label` | Yes | What players see |
| `format` | Yes | Display template with exactly one `%s` |
| `conversionRate` | Yes\* | How many dollars one unit is worth |
| `flatCost` | No | Every vehicle costs this many units, whatever its price. Overrides `conversionRate` |
| `allowFinance` | No | Reserved — see the limitation below |
| `decimals` | No | `0`, `1`, or `2`. Defaults to `0` |
| `icon` | No | `coins`, `wallet`, `card`, or `gem` |
\* Not required when using `flatCost`.
Prices convert **after** tax and coupons and always round up, so a vehicle can never come out free at a high conversion rate. Coupons do not apply to flat-cost purchases.
### Where the balance lives
Write `fetchBalance` / `addBalance` / `removeBalance` yourself, or add a `backend` block and let the script generate them:
| `backend.type` | Balance source | Offline players |
| -------------- | ----------------------------------------------------------------------- | ------------------------------------------- |
| `db` | A table owned by the script — nothing else to install | Yes |
| `account` | A named framework account, i.e. `xPlayer.getAccount('viptoken')` on ESX | Yes |
| `export` | Another resource's exports | Only if it exposes identifier-based exports |
| `metadata` | Framework player metadata | QB and QBox yes; ESX needs 1.10+ |
| `item` | The player's count of an inventory item | No — rewards queue until they log in |
Pick `db` if you have no existing currency resource, or `account` if your tokens already live in an ESX account. Both support offline players, which is what makes webstore rewards land immediately instead of queuing.
Register the account in your framework config first, then point the currency at it — no balance callbacks needed.
```lua theme={null}
Currencies.Server.Register({
id = 'viptoken',
label = 'VIP Token',
format = '%s VIP',
conversionRate = 25000,
backend = { type = 'account', account = 'viptoken' },
})
```
One token buys any vehicle, whatever it costs.
```lua theme={null}
Currencies.Server.Register({
id = 'car_token',
label = 'Car Token',
format = '%s Token(s)',
flatCost = 1,
backend = { type = 'db' },
})
```
```lua theme={null}
Currencies.Server.Register({
id = 'premium_points',
label = 'Premium Points',
format = '%s pts',
conversionRate = 1000,
backend = {
type = 'export',
resource = 'premium-shop',
fetch = 'GetPoints',
add = 'AddPoints',
remove = 'RemovePoints',
},
})
```
```lua theme={null}
Currencies.Server.Register({
id = 'vip_coins',
label = 'VIP Coins',
format = '%s coins',
conversionRate = 10000,
allowFinance = false,
fetchBalance = function(src)
return exports['vip-system']:GetCoins(src) or 0
end,
addBalance = function(src, amount)
exports['vip-system']:AddCoins(src, amount)
return true
end,
removeBalance = function(src, amount)
exports['vip-system']:RemoveCoins(src, amount)
return true
end,
})
```
### Enabling a currency
Open `/dealerships` as an admin, edit a dealership, and use the **Payment Methods** section. The same section controls whether that dealership accepts cash, bank, and finance at all.
Payments taken in a custom currency are held in that dealership's case **in the same currency**, alongside its dollar balance, and staff withdraw them from the **Case** tab. Stock orders and case deposits stay dollar-only.
Financing in a custom currency is not supported. `allowFinance` and the offline callbacks are accepted and stored so JG definitions load cleanly, but financed purchases always charge the bank account.
## Webstore rewards
Sell VIP coins on Tebex and let the script deliver them. Run this from the server console, or as a Tebex command package:
```bash theme={null}
dealership_grant [txId] [note...]
dealership_grant license:110000112345678 vip_coins 5 tebex-8842119 Gold Package
```
`identifier` accepts `license:...`, `cid:`, a bare citizenid, or `#` for an online player. Substitute your own store's placeholder tokens — the exact tokens differ per store plugin.
Always pass a transaction id. It is the primary key of the grant table, so re-running the same line grants nothing and prints `REPLAY`. That makes a webstore retry safe.
Offline players are credited directly when the currency supports it, and queued for their next login when it cannot — an inventory item can't be handed to someone who isn't connected. Every movement is recorded for auditing.
Sibling commands: `dealership_balance ` and `dealership_grants `.
## Exports
Server-side. Use these to wire the script into a society account, a payslip system, a Discord bot, or your own store handler.
### Player balances
Work for any registered currency.
```lua theme={null}
local balance = exports['9am-vehicleshop']:getCurrencyBalance(source, 'viptoken')
local ok, err = exports['9am-vehicleshop']:addCurrencyBalance(source, 'viptoken', 100)
local ok, err = exports['9am-vehicleshop']:removeCurrencyBalance(source, 'viptoken', 100)
-- Reward delivery. Replay-safe, works for offline players.
local ok, err = exports['9am-vehicleshop']:grantCurrency(identifier, 'viptoken', 100, txId, note)
exports['9am-vehicleshop']:getCurrencies()
exports['9am-vehicleshop']:getPendingGrantCount()
```
### Dealership balances
Each dealership keeps its own case rather than an `esx_addonaccount` society, because a dealership is not always a job — a player-owned shop belongs to one character with no society to point at, and two shops can share a job. These exports expose the balance so you can mirror it into a society account or read it elsewhere.
```lua theme={null}
local balance = exports['9am-vehicleshop']:getCompanyBalance('pdm') -- dollars
local coins = exports['9am-vehicleshop']:getCompanyBalance('pdm', 'viptoken') -- one currency
local all = exports['9am-vehicleshop']:getCompanyBalances('pdm') -- every balance
local ok, err = exports['9am-vehicleshop']:depositCompanyMoney('pdm', 1000)
local ok, err = exports['9am-vehicleshop']:withdrawCompanyMoney('pdm', 1000)
exports['9am-vehicleshop']:getCompanies() -- every dealership id
```
The currency id is optional everywhere — omit it for dollars. Withdrawals fail rather than overdrawing, and both directions write to the dealership's ledger, so the movement appears in the in-game **Case** tab.
Mutating exports return `boolean, string?`. The error string is one of `unknown_shop`, `unknown_currency`, `invalid_amount`, `insufficient_funds`, `duplicate`, `missing_tx_id`, or `delivery_failed`.
You can also register a currency from another resource instead of `currencies.lua`:
```lua theme={null}
exports['9am-vehicleshop']:RegisterCurrency({ id = 'viptoken', label = 'VIP Token', conversionRate = 25000 })
```
## Commands
| Command | Access | Description |
| ------------------------------------------- | ------------------- | -------------------------------------------------------------------------- |
| `/dealertablet` | Dealership staff | Open the management dashboard remotely. |
| `/dealerships` (alias `/dealershipcreator`) | Admin | Place and configure a dealership in-game. |
| `debug_dealership_info` | Server console only | Dump framework, dependency states, server info, and DB status for support. |
| `dealership_audit` | Server console only | List every supported integration per feature (installed vs. active). |
| `dealership_seed` | Server console only | Seed mock data into the default shops for testing/demos. |
| `dealership_grant` | Server console only | Grant a custom currency to a player by identifier. Replay-safe. |
| `dealership_balance` | Server console only | Read a player's currency balance, online or offline. |
| `dealership_grants` | Server console only | Inspect grants, or `flush` to retry queued deliveries. |
Debug commands (`/openshop`, `/opendash`, `/finance`, `/trailerspawn`, …) are gated behind `Config.Debug` and off by default.
## Database
These 19 tables are auto-created on startup — **do not** import them manually:
```
9am_vehicleshop_sales 9am_vehicleshop_visits
9am_vehicleshop_case 9am_vehicleshop_case_balance
9am_vehicleshop_stock 9am_vehicleshop_coupons
9am_vehicleshop_mugshots 9am_vehicleshop_permissions
9am_vehicleshop_settings 9am_vehicleshop_orders
9am_vehicleshop_showroom 9am_vehicleshop_finance
9am_vehicleshop_finance_payments 9am_vehicleshop_dynamic_shops
9am_vehicleshop_jobs 9am_vehicleshop_migrations
9am_vehicleshop_currency_balances
9am_vehicleshop_currency_log 9am_vehicleshop_currency_grants
```
The resource also reads/writes your framework's own vehicle tables (`player_vehicles` on QB/QBox, `owned_vehicles` / `users` on ESX) when registering a purchased vehicle. Schema upgrades run as guarded `ALTER TABLE` migrations in the `MySQL.ready` block — never add a SQL import file.
## Localization
All user-facing text is keyed, English is the fallback, and **30 languages** ship: `en`, `ar`, `cs`, `da`, `de`, `es`, `fi`, `fr`, `he`, `hi`, `hu`, `id`, `it`, `ja`, `ko`, `lt`, `ms`, `nl`, `pl`, `pt`, `pt-br`, `sl`, `sv`, `th`, `tl`, `tr`, `uk`, `vi`, `zh-cn`, `zh-tw`. `locales/en.json` is the source of truth. Each file has two namespaces — `game.*` (Lua) and `ui.*` (React). To add a language, copy `en.json`, translate the values (keys identical; partial files fall back per-key), and set `Config.Locale`. No web rebuild is needed.
## Version history
**Custom currencies.** Dealerships can be paid in VIP coins, tokens, crypto, or an inventory item, enabled per dealership. The registration API is drop-in compatible with JG Dealerships, and five backends (`db`, `account`, `export`, `metadata`, `item`) cover the common setups with no callbacks to write.
**Webstore rewards.** `dealership_grant` delivers currency by player identifier from Tebex or RCON — replay-safe on the store's transaction id, so a retried delivery grants nothing. Offline buyers are credited directly where the currency allows it, and queued for their next login where it physically cannot be handed over, such as an inventory item.
**Per-currency case.** Each dealership holds its currency balances alongside its dollar balance, with a switcher and per-currency withdrawal in the **Case** tab. Deposits and stock orders stay dollar-only.
**Per-dealership payment methods.** A new **Payment Methods** section in `/dealerships` controls cash, bank, finance, and each currency.
**Exports.** New company-balance and currency-balance exports for integrating with society accounts and external systems. Withdrawals fail with `insufficient_funds` rather than overdrawing, and both directions write a ledger row.
**Audit log.** Every currency movement is recorded in `9am_vehicleshop_currency_log`, including webstore grants and case withdrawals.
Financing in a custom currency is not supported in this release. `allowFinance` and the offline callbacks are accepted, validated, and stored so JG definitions load cleanly, but financed purchases always use the bank account.
**Fixes.** Three pre-existing bugs were found and fixed while building this release:
* A credit to a dealership that had no balance row yet was silently discarded. The shop case is now written as an upsert.
* Renaming a shop while its catalog was open reset the test-drive toggle, and would also have cleared that shop's payment methods.
* The sale-offer page failed to render in browser development because its state was never seeded.
Schema changes are additive and apply automatically on first start; the per-currency case arrives as the one-time `case_currency_v1` migration. Do not downgrade to 1.3.x after a dealership has taken a custom-currency payment — the older version's case queries cannot tell currencies apart.
With an untouched `currencies.lua`, every existing dealership keeps accepting cash, bank, and finance and behaves exactly as it did in 1.3.x.
Redesigned the dealership list in `/dealerships` with a clearer hierarchy and filters, and tidied the editor header.
Customer-editable files moved. Copy your customizations to the new paths **before** starting 1.3.0 — the old filenames are no longer loaded, so edits left behind are silently ignored.
| Old path | New path |
| ---------------------- | ------------------- |
| `shared/config.lua` | `config.lua` |
| `client/framework.lua` | `bridge/client.lua` |
| `server/bridge.lua` | `bridge/server.lua` |
Also relocated, though usually not customized: `shared/locale.lua` → `modules/locale.lua`, `server/helpers.lua` → `modules/helpers.lua`, `server/data.lua` → `modules/data.lua`, `client/utils.lua` → `modules/utils.lua`, `client/state.lua` → `modules/state.lua`.
Server-only files are no longer included in the client download, so permission checks and SQL builders stay on the host.
Internal build tooling only — no gameplay or configuration changes.
Fixed the phone dashboard bottom sheet sitting in the wrong position after the build toolchain upgrade.
Moved the interface onto the shared [9AM UI Kit](/ui-kit/introduction) so components stay consistent across scripts.
Completed the localization pack — 30 languages now ship, with English fallback for any missing key. Set `Config.Locale` and restart; no rebuild required.
Customer builds are now produced with the production pipeline, which strips development seed data, mock data, and console noise from the shipped interface.
## Best for
Choose this script if your server wants:
* A believable vehicle-sales loop instead of a flat buy menu
* Staff-driven dealership gameplay with real permissions
* A business players can own and operate
* Financing, coupons, stock, and delivery logistics
* Live, in-game dealership creation with no config edits
* A donation currency that buys cars, delivered automatically from your store
## Buy link
[Open Dealership in the store](https://9am.dev/scripts/dealership)
# FiveM Lockpick Script
Source: https://docs.9am.dev/products/lockpick
9AM Lockpick is a standalone FiveM lockpick script with a Payday 3 inspired minigame, XP progression, and detailed configuration.
## 9AM Lockpick overview
9AM Lockpick is a standalone FiveM lockpick minigame for servers that want crime gameplay to feel tactile, skill-based, and worth mastering over time.
Both the escrow and open source editions expose the same `config.lua` and client exports. You can wire the minigame into doors, vehicles, or custom jobs without editing protected files.
## Public features
* Payday 3 inspired design
* Level and XP system
* Detailed config file
* 600+ lines of code
* Standalone support
## Compatibility and editions
| Type | Details |
| ----------------- | ----------- |
| Compatibility | Standalone |
| Escrow price | `EUR 15.35` |
| Open source price | `EUR 35.58` |
Prices reflect the public store on March 22, 2026.
The minigame itself does not require a framework. Set `Config.Framework` so XP is stored against the correct player identifier.
## Config
Edit `config.lua` after install. Escrow customers get the same editable config file as open source.
```lua theme={null}
-- config.lua
Config = {}
Config.EearnXpRatio = 250 -- XP awarded per successful lockpick
Config.Level2Xp = 4000 -- XP required for level 2 (16 successes at default ratio)
Config.Level3Xp = 8000 -- XP required for level 3 (32 successes at default ratio)
Config.Framework = 'qb' -- 'qb', 'esx', or 'other'
Config.GetPlayerIdentifier = function(source)
if Config.Framework == 'qb' then
QBCore = exports['qb-core']:GetCoreObject()
local Player = QBCore.Functions.GetPlayer(source)
return Player.PlayerData.citizenid
else
return GetPlayerIdentifiers(source)[1]
end
end
```
### Config options
| Option | Type | Default | Description |
| --------------------- | ---------- | ------------------------------- | ----------------------------------------------------------- |
| `EearnXpRatio` | `number` | `250` | XP added on each successful lockpick |
| `Level2Xp` | `number` | `4000` | XP threshold for level 2 |
| `Level3Xp` | `number` | `8000` | XP threshold for level 3 |
| `Framework` | `string` | `'qb'` | Identifier mode: `'qb'`, `'esx'`, or `'other'` |
| `GetPlayerIdentifier` | `function` | QB citizenid / license fallback | Returns the player key used to store XP in `levelData.json` |
For ESX or custom stacks, set `Config.Framework` and adjust `GetPlayerIdentifier` so it returns a stable ID for each player.
Player XP is saved in `levelData.json` inside the resource. Higher levels feed into the minigame difficulty settings sent to the UI.
## Exports
Ensure `9am-lockpick` starts before any resource that calls these exports.
### Parameters
| Parameter | Type | Description |
| ------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
| `speed` | `number` | Pointer tick interval in ms. Lower is faster and harder (`1` is harder than `10`). `7` is a good default. |
| `isDecrease` | `boolean` | When `true`, the progress bar drains over time. If it hits zero, the attempt fails. |
| `randomSpeed` | `boolean` | When `true`, speed can change after successful hits for more challenge. |
| `callback` | `function` | Used only by `createLockpickGameCb`. Receives `true` on success or `false` on fail. |
Both exports award XP automatically on success through the server.
### `createLockpickGame`
Awaitable export that returns a boolean.
```lua theme={null}
local success = exports['9am-lockpick']:createLockpickGame(7, false, true)
if success then
-- Unlock the door, give loot, etc.
else
-- Break the lockpick item, alert cops, etc.
end
```
### `createLockpickGameCb`
Same minigame, with the result delivered to a callback.
```lua theme={null}
exports['9am-lockpick']:createLockpickGameCb(7, false, true, function(success)
if success then
-- Unlock the door, give loot, etc.
else
-- Break the lockpick item, alert cops, etc.
end
end)
```
## Usage examples
### Test commands
Use these while developing. Remove or protect them before production.
```lua theme={null}
RegisterCommand('lockpick', function()
local result = exports['9am-lockpick']:createLockpickGame(7, false, true)
if result == true then
print('success')
elseif result == false then
print('fail')
end
end)
RegisterCommand('lockpick2', function()
exports['9am-lockpick']:createLockpickGameCb(7, false, true, function(result)
if result == true then
print('success')
elseif result == false then
print('fail')
end
end)
end)
```
### Vehicle lockpick
```lua theme={null}
RegisterCommand('pickvehicle', function()
local ped = PlayerPedId()
local vehicle = GetVehiclePedIsIn(ped, false)
if vehicle == 0 then
vehicle = GetClosestVehicle(GetEntityCoords(ped), 5.0, 0, 71)
end
if vehicle == 0 then
return
end
local success = exports['9am-lockpick']:createLockpickGame(7, false, true)
if success then
SetVehicleDoorsLocked(vehicle, 1)
SetVehicleDoorsLockedForAllPlayers(vehicle, false)
end
end)
```
### Item-based trigger (QBCore example)
Call the export from your inventory item use handler. Keep item remove / reward logic in your own resource.
```lua theme={null}
-- Inside your items resource or qb-inventory use hook
exports['9am-lockpick']:createLockpickGameCb(8, false, true, function(success)
if success then
TriggerServerEvent('my-crime:server:lockpickSuccess')
else
TriggerServerEvent('my-crime:server:lockpickFailed')
end
end)
```
### Difficulty presets
Tune `speed` and `randomSpeed` per interaction type.
```lua theme={null}
-- Easy house door
exports['9am-lockpick']:createLockpickGame(10, false, false)
-- Standard vehicle
exports['9am-lockpick']:createLockpickGame(7, false, true)
-- Hard safe or high-tier door
exports['9am-lockpick']:createLockpickGame(4, true, true)
```
## Best for
Choose this script if you want:
* A deeper lockpick minigame than one-click interactions
* Skill progression tied to repeated gameplay
* A standalone script that is easier to fit into mixed stacks
## Buy link
[Open Lockpick in the store](https://9am.dev/scripts/open-source/lockpick)
# FiveM Multicharacter Script
Source: https://docs.9am.dev/products/multicharacter
9AM Multicharacter is a FiveM multicharacter script for ESX and QB servers with extra character slots, redeem keys, and a modern UI.
## 9AM Multicharacter overview
9AM Multicharacter is built for servers that want a clean, modern character selection experience and a practical way to monetize or manage extra slots.
## Public features
* ESX and QB compatibility
* Client data cache system
* Modern and smooth UI
* Unlock slots with redeem key
* Extra character slot monetization
## Compatibility and editions
| Type | Details |
| ----------------- | ----------- |
| Compatibility | ESX, QB |
| Escrow price | `EUR 23.68` |
| Open source price | `EUR 59.38` |
Prices reflect the public store on March 22, 2026.
## Best for
Choose this script if your server needs:
* Multiple characters per player
* A cleaner first join and character switch flow
* A slot system you can expand over time
* A UI that feels more premium than default setups
## Buy link
[Open Multicharacter in the store](https://9am.dev/scripts/open-source/multicharacter)
# FiveM Report Script
Source: https://docs.9am.dev/products/report
9AM Report is a FiveM support report script for QBCore and ESX servers with pre-recording, screen recording, and high-performance staff workflows.
## 9AM Report overview
9AM Report is built for servers that want a more modern support workflow than text-only reports. The public store highlights screen recording and pre-record functionality for faster staff review.
## Public features
* Pre-record system
* Screen recording
* Easy drag and drop installation
* High performance
* QBCore and ESX compatibility
## Compatibility and editions
| Type | Details |
| ----------------- | ----------- |
| Compatibility | QBCore, ESX |
| Escrow price | `EUR 23.68` |
| Open source price | `EUR 59.38` |
Prices reflect the public store on March 22, 2026.
## Best for
Choose this script if your staff team needs:
* Better evidence in player reports
* Faster troll detection
* A stronger support workflow inside roleplay communities
## Buy link
[Open Report in the store](https://9am.dev/scripts/open-source/report)
# FiveM Spawn Selector Script
Source: https://docs.9am.dev/products/spawn-selector
9AM Spawn Selector is a FiveM spawn selector script for ESX and QB servers with multiple spawn points, client data cache, and a modern UI.
## 9AM Spawn Selector overview
9AM Spawn Selector is built for roleplay servers that want the spawn flow to feel cleaner and more intentional. It lets you present multiple spawn locations inside a modern interface instead of a rough default flow.
## Public features
* ESX and QB compatibility
* Client data cache system
* Modern and smooth UI
* Multiple spawn locations
## Compatibility and editions
| Type | Details |
| ----------------- | ----------- |
| Compatibility | ESX, QB |
| Escrow price | `EUR 15.35` |
| Open source price | `EUR 35.58` |
Prices reflect the public store on March 22, 2026.
## Best for
Choose this script if you want:
* A stronger first impression after character selection
* Flexible RP spawn points
* A cleaner spawn flow for ESX or QB servers
## Buy link
[Open Spawn Selector in the store](https://9am.dev/scripts/open-source/spawn-selector)
# FiveM Text UI Script
Source: https://docs.9am.dev/products/textui
9AM TextUI is a free animated Text UI replacement for FiveM with 3D world-space and screen-space rendering, smooth morphing animations, and ox_lib override support.
## 9AM TextUI overview
9AM TextUI is a free animated Text UI replacement for FiveM that supports both 3D world-space and screen-space rendering with smooth morphing animations. It automatically overrides `ox_lib`'s Text UI so all existing scripts use it without code changes.
## Public features
* 3D world prompts rendered at world coordinates with camera projection tracking
* Classic screen-anchored overlay mode
* Drop-in `ox_lib` override for `lib.showTextUI` / `lib.hideTextUI`
* Sphere, box, and poly zone types via `ox_lib`
* Distance-based animation that expands when near and collapses into a dot when far
* Configurable accent color
* Free and open source
## Dependencies
* `ox_lib`
## Compatibility and editions
| Type | Details |
| ------------- | ---------- |
| Compatibility | Standalone |
| Price | **Free** |
## Config
```lua theme={null}
-- config.lua
Config.PrimaryColor = '#f7e472'
Config.Zones = {
{
id = 'management-menu',
text = 'Open Management',
key = 'E',
coords = vec3(-34.9118, -1102.1649, 26.4224),
displayDist = 10.0,
nearDist = 2.0,
offset = vec3(0.0, 0.0, -0.6),
},
}
```
### Zone options
| Option | Type | Default | Description |
| ------------- | --------- | ------------- | --------------------------------------- |
| `id` | `string` | — | Unique identifier (required) |
| `text` | `string` | — | Prompt label |
| `key` | `string` | `""` | Key hint in the badge (e.g. `'E'`) |
| `coords` | `vec3` | — | World position |
| `displayDist` | `number` | `10.0` | Distance at which the prompt is visible |
| `nearDist` | `number` | `2.0` | Distance at which the prompt expands |
| `offset` | `vec3` | `(0, 0, 1)` | Offset from coords for display anchor |
| `type` | `string` | `'sphere'` | Zone type: `sphere`, `box`, or `poly` |
| `radius` | `number` | `displayDist` | Zone radius (sphere only) |
| `size` | `vec3` | — | Zone dimensions (box only) |
| `rotation` | `number` | — | Zone rotation (box only) |
| `points` | `table` | — | Zone vertices (poly only) |
| `thickness` | `number` | — | Zone height (poly only) |
| `debug` | `boolean` | `false` | Draw debug zone outline |
## Exports
### 3D World TextUI
```lua theme={null}
-- Create
exports['9am-textui']:createTextUI('shop-door', {
text = 'Open Shop',
key = 'E',
coords = vec3(250.0, -850.0, 29.5),
displayDist = 10.0,
nearDist = 2.0,
})
-- Update (partial, only changed fields)
exports['9am-textui']:updateTextUI('shop-door', { text = 'Closed' })
-- Remove
exports['9am-textui']:removeTextUI('shop-door')
```
### Screen Overlay TextUI
```lua theme={null}
-- Show (key is parsed from [X] prefix)
exports['9am-textui']:showOverlayTextUI('[E] Open Menu')
-- Show at world coords
exports['9am-textui']:showOverlayTextUI('[E] Open Menu', {
coords = vec3(100.0, 200.0, 30.0),
})
-- Hide
exports['9am-textui']:hideOverlayTextUI()
-- Check state
local isOpen = exports['9am-textui']:isOverlayTextUIOpen()
```
### ox\_lib override
The included `override.lua` replaces `lib.showTextUI` / `lib.hideTextUI` globally. Two ways to set it up:
**Option A — Add directly to ox\_lib** (recommended, applies to all resources automatically):
Copy `override.lua` into `ox_lib/imports/` (or any loaded path), then add it to ox\_lib's `fxmanifest.lua`:
```lua theme={null}
files { '@9am-textui/override.lua' }
client_scripts { '@9am-textui/override.lua' }
```
**Option B — Per resource:**
Add to the target resource's `fxmanifest.lua`:
```lua theme={null}
files { '@9am-textui/override.lua' }
client_scripts { '@9am-textui/override.lua' }
```
After either setup, these calls route through 9am-textui automatically:
```lua theme={null}
lib.showTextUI('[E] Interact')
lib.hideTextUI()
lib.isTextUIOpen()
```
## Advanced zone types
### Box
```lua theme={null}
exports['9am-textui']:createTextUI('garage', {
text = 'Enter Garage',
key = 'E',
type = 'box',
coords = vec3(200.0, -800.0, 30.0),
size = vec3(10.0, 6.0, 4.0),
rotation = 45.0,
displayDist = 15.0,
})
```
### Poly
```lua theme={null}
exports['9am-textui']:createTextUI('park', {
text = 'Rest Area',
key = 'G',
type = 'poly',
points = {
vec3(100.0, 200.0, 30.0),
vec3(110.0, 200.0, 30.0),
vec3(110.0, 210.0, 30.0),
vec3(100.0, 210.0, 30.0),
},
thickness = 4.0,
displayDist = 20.0,
})
```
Poly zones don't require `coords` — defaults to the centroid of the points.
## Example
```lua theme={null}
CreateThread(function()
exports['9am-textui']:createTextUI('atm_legion', {
text = 'Use ATM',
key = 'E',
coords = vec3(149.0, -1040.0, 29.4),
displayDist = 5.0,
nearDist = 1.5,
offset = vec3(0.0, 0.0, 0.5),
})
end)
-- Dynamic update
RegisterNetEvent('bank:client:toggleATM', function(enabled)
if enabled then
exports['9am-textui']:updateTextUI('atm_legion', { text = 'Use ATM', key = 'E' })
else
exports['9am-textui']:updateTextUI('atm_legion', { text = 'Out of Service', key = '' })
end
end)
-- Cleanup
AddEventHandler('onResourceStop', function(res)
if res == GetCurrentResourceName() then
exports['9am-textui']:removeTextUI('atm_legion')
end
end)
```
## Best for
Choose this script if you want:
* A visually polished Text UI with smooth animations at no cost
* 3D world-space prompts that track camera projection
* A seamless `ox_lib` Text UI replacement without changing existing code
## Download link
[Open TextUI in the store](https://9am.dev/scripts/escrow/3d-text-ui)
# CEF constraints
Source: https://docs.9am.dev/ui-kit/cef
FiveM runs Chromium 103. These are the things that break in-game while working perfectly in your browser.
## Why this page exists
FiveM's embedded browser is **Chromium 103**. Your development browser is several years newer.
That gap is expensive because the failures are silent and one-sided: `bun run start` looks perfect, you ship, and the UI is broken in-game. Every item below was paid for once already. The kit carries the fixes so you do not pay again.
Before using any modern CSS feature in a NUI, check it against Chromium 103. The browser preview will happily lie to you.
## oklch() is not parsed
The 9AM tokens are authored in OKLCH. Chromium 103 cannot read them, so every token resolves to nothing and the interface renders **completely unstyled** in-game.
`postcss.config.js` converts them at build time:
```js theme={null}
export default {
plugins: {
'postcss-color-converter': {
outputColorFormat: 'rgb',
ignore: ['hex']
},
'@tailwindcss/postcss': {},
autoprefixer: {}
}
}
```
Do not remove `postcss-color-converter`. This is the single most destructive thing you can do to a 9AM NUI, and it is invisible until you load the game.
Verify after any build change:
```bash theme={null}
bun run build && grep -c oklch dist/assets/*.css # must be 0
```
`bun src/scripts/9am-ui.mjs doctor` runs this check for you, including scanning your built CSS.
Tailwind v4 separately emits a hex fallback before each `color-mix()` behind an `@supports` guard, which is why `color-mix` is safe to use.
## Wheel scroll does nothing on clipped containers
CEF's compositor will not scroll a `clip-path`'d container with the mouse wheel. The event reaches the page; the scroll never happens. Since `Viewport` uses `clip-path`, that means nothing scrolls.
`installSmoothScroll()` catches the wheel event and drives the scroll from JavaScript. Call it once in `main.tsx`.
## mask does not apply to backdrop-filter output
A gradient mask over a `backdrop-filter` is ignored ([crbug.com/41465359](https://bugs.chromium.org/p/chromium/issues/detail?id=41465359), fixed in later Chrome).
`Viewport` fakes the gradient by stacking bands of fixed blur with progressively shorter spans. The blur values are chosen so the combined result increases linearly toward the edge — each step lands under half a pixel, so the eye cannot pick out the bands.
## No :has(), no @container
Both need Chromium 105+.
Where the kit needs them it computes the result in JavaScript instead. `ViewportHeader` detects a `ViewportAction` child at the React level and applies its grid columns directly, rather than using `:has()`.
If you find yourself reaching for either, restructure the component or resolve it in JS.
## overflow + border-radius does not clip composited layers
Rounded corners with `overflow: hidden` do not clip composited layers — scrolled content and `backdrop-filter` output escape the radius.
Use `clip-path` instead, which the compositor applies to every layer:
```jsx theme={null}
```
This is why `Viewport` uses `clip-path`, which is in turn why the wheel-scroll fix is required. The two are linked.
## Tailwind v4: @theme inline emits no custom properties
Not a CEF issue, but it bites in the same way — silently, and only in some contexts.
Variables declared in `@theme inline` are inlined into the generated utilities. They do **not** exist as CSS custom properties at runtime:
```jsx theme={null}
{/* Renders nothing — the variable does not exist */}
{/* Correct */}
```
And because Tailwind scans source files as text, the class name must appear in full. `` `bg-primary-${n}` `` generates no CSS — spell out each class you intend to use.
## Checklist before shipping a NUI
```bash theme={null}
bun run build
grep -c oklch dist/assets/*.css # 0
```
`installSmoothScroll()` runs in `main.tsx`.
Scrolling, blurred edges, and anything using a modern selector need a real CEF check. `ensure ` and look at it.
```bash theme={null}
bun src/scripts/9am-ui.mjs doctor
```
# Components
Source: https://docs.9am.dev/ui-kit/components
The 19 primitives in the 9AM UI kit — props, variants, and usage examples.
Install any of these with `bunx shadcn@latest add @9am/`. They land in `src/components/ui/`.
## Viewport
The house scroll container, and the piece worth understanding properly. It is not a card — it is a scroller with three behaviours layered on:
* A **custom scrollbar thumb** you can drag, which fades in on hover and disappears entirely when the content does not overflow.
* **Layered glass edges** — a gradient blur at the top and bottom that content dissolves into.
* A **pinning header**. Scroll down and the real header blurs out while a sticky copy animates in above the content.
```jsx theme={null}
import {
Viewport, ViewportHeader, ViewportTitle, ViewportDescription,
ViewportAction, ViewportContent, ViewportFooter,
} from "@/components/ui/viewport";
Vehicle stock
42 vehicles across 3 dealerships
{rows}
```
| Prop | Type | Default | Description |
| ---------------- | --------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `fadeColor` | `string` | `var(--background)` | Colour the edge fades blend into. Must match the viewport's own background — pass `var(--card)` when the viewport uses the card surface. |
| `hideBottomFade` | `boolean` | `false` | Suppress the bottom fade |
Plus every standard `div` prop.
Getting `fadeColor` wrong is the most common mistake. The fade is a real gradient painted over your content — if it does not match the background behind it, you get a visible band at the top and bottom edges.
`ViewportAction` is detected at the React level rather than with CSS `:has()`, because [Chromium 103 does not support it](/ui-kit/cef). Put it inside `ViewportHeader` and the header switches to a two-column grid automatically.
## Button
```jsx theme={null}
import { Button } from "@/components/ui/button";
```
| Variant | Appearance |
| ------------- | --------------------------------------------------------------------------------------------------------- |
| `default` | Solid gold with dark text. The primary action. |
| `default2` | Translucent fill, gold text on hover. The workhorse secondary — most header and toolbar buttons use this. |
| `destructive` | Solid red |
| `outline` | Bordered, transparent fill |
| `secondary` | Muted solid |
| `ghost` | No chrome until hovered |
| `link` | Gold text with a tinted hover |
| `navigation` | Phudu display font, larger. For primary page navigation. |
| Size | Height |
| ------------ | ------------------------------- |
| `sm` | 32 px |
| `default` | 36 px |
| `lg` | 40 px |
| `icon` | 36 × 36 px square |
| `catalogNav` | 36 px, catalog-specific padding |
Supports `asChild` for rendering as a different element.
## DataTable
A TanStack-backed table with search, sorting, and pagination already wired up.
```jsx theme={null}
import { DataTable } from "@/components/ui/data-table";
t("ui.common.couponCount", { count })}
toolbar={}
/>
```
| Prop | Type | Description |
| ------------------- | ---------------------------- | --------------------------------------------------------------- |
| `columns` | `ColumnDef[]` | Standard TanStack column definitions |
| `data` | `TData[]` | Rows |
| `searchPlaceholder` | `string` | Defaults to `ui.common.searchPlaceholder` |
| `countLabel` | `(count: number) => string` | Footer row count. Defaults to `ui.common.rowCount` (" row(s)"). |
| `toolbar` | `ReactNode` | Rendered to the right of the search field |
Pass `countLabel` whenever a domain word reads better than "rows" — that is what stops a coupons table from saying "12 rows".
Pagination is ten rows per page.
## MultiSelect
```jsx theme={null}
import { MultiSelect } from "@/components/ui/multi-select";
```
| Prop | Type | Description |
| ------------------- | ------------------------------------ | ----------------------------------------- |
| `options` | `{ value: string; label: string }[]` | Available choices |
| `selected` | `string[]` | Currently selected values |
| `onSelectionChange` | `(selected: string[]) => void` | Change handler |
| `placeholder` | `string` | Defaults to `ui.common.selectPlaceholder` |
| `searchPlaceholder` | `string` | Search field placeholder |
| `emptyMessage` | `string` | Shown when the filter matches nothing |
## Tabs
Two variants. `default` is a segmented control; `line` is the underlined style the dashboard header uses, where the active tab turns gold and grows an underline.
```jsx theme={null}
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
Stock
Sales
…
```
`Tabs` also accepts `orientation="vertical"`.
## Field
Composable form-row primitives. Use these instead of hand-rolling label and helper-text layout.
```jsx theme={null}
import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field";
Dealership name
Shown on the showroom stand.
{errors.name}
```
`Field` takes `orientation="vertical"` (default) or `"horizontal"`. `FieldError` renders nothing when it has no children, so you can leave it in unconditionally.
Also exported: `FieldSet`, `FieldLegend`, `FieldGroup`, `FieldContent`, `FieldTitle`, `FieldSeparator`.
## Dialog
```jsx theme={null}
import {
Dialog, DialogTrigger, DialogContent, DialogHeader,
DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from "@/components/ui/dialog";
```
## InfoTip
A small `?` glyph that reveals an explanation on hover. Use it instead of cramming caveats into a label.
```jsx theme={null}
import { InfoTip } from "@/components/ui/info-tip";
Players may drive a vehicle before purchase.
```
| Prop | Type | Default |
| ----------- | ---------------------------------------- | ------- |
| `side` | `"top"`, `"right"`, `"bottom"`, `"left"` | `"top"` |
| `className` | `string` | — |
## Chart
A Recharts wrapper that maps series to theme tokens.
```jsx theme={null}
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
const config = {
revenue: { label: "Revenue", color: "var(--chart-1)" },
};
} />
```
Each key in `config` produces a `--color-` custom property. Also exported: `ChartLegend`, `ChartLegendContent`, `ChartStyle`.
Five chart tokens are defined per palette: `--chart-1` through `--chart-5`.
## Everything else
These follow their shadcn equivalents, restyled to the 9AM surface language.
| Component | Exports |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `card` | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` |
| `input` | `Input` |
| `label` | `Label` |
| `select` | `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`, `SelectGroup`, `SelectLabel`, `SelectSeparator` |
| `switch` | `Switch` |
| `separator` | `Separator` |
| `table` | `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableHead`, `TableRow`, `TableCell`, `TableCaption` |
| `popover` | `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`, `PopoverHeader`, `PopoverTitle`, `PopoverDescription` |
| `tooltip` | `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider` |
| `calendar` | `Calendar`, `CalendarDayButton` |
`Tooltip` and `InfoTip` need a `TooltipProvider` above them in the tree. Put one at the root of your app.
# Icons
Source: https://docs.9am.dev/ui-kit/icons
47 hand-built animated icons with an imperative ref handle, so a parent element can drive the animation.
## The contract
Every icon is a `forwardRef` component with the same shape:
```tsx theme={null}
interface IconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface IconProps extends HTMLAttributes {
size?: number; // default 28
}
```
Colour comes from `currentColor`, so the parent's text colour drives it — no `color` prop to keep in sync.
Install one at a time:
```bash theme={null}
bunx shadcn@latest add @9am/icon-box @9am/icon-truck
```
They land in `src/components/ui/` alongside the primitives, so the import path is the same as any other component.
## Basic use
Hover the icon itself and it animates on its own:
```jsx theme={null}
import { BoxIcon } from "@/components/ui/box";
```
## Driving it from a parent
This is the pattern that makes the kit's navigation feel alive: hovering a **tab** animates the icon inside it, not just the icon's own hitbox.
The moment you attach a ref, the icon stops handling its own hover and hands control to you.
```jsx theme={null}
import { useRef } from "react";
import { BoxIcon } from "@/components/ui/box";
type IconHandle = { startAnimation: () => void; stopAnimation: () => void };
function StockTab() {
const iconRef = useRef(null);
return (
iconRef.current?.startAnimation()}
onMouseLeave={() => iconRef.current?.stopAnimation()}
>
Stock
);
}
```
Attaching a ref means the icon no longer animates on its own hover — it forwards `onMouseEnter` and `onMouseLeave` to you instead. If you take the ref, you own both events.
## Theme toggle
`ThemeToggleIcon` is the one icon with an extra prop. It crossfades between a sun and a moon and needs to know which to show:
```jsx theme={null}
import { ThemeToggleIcon } from "@/components/ui/theme-toggle";
import { useTheme } from "@/hooks/useTheme";
const { theme, toggleTheme } = useTheme();
```
## Colouring by state
Because icons inherit `currentColor`, the usual approach is to compute a colour from the parent's state:
```jsx theme={null}
const iconColor = (tab) => {
if (currentTab === tab) return theme === "dark" ? "#F6E371" : "var(--color-primary)";
if (hoveredTab === tab) return theme === "dark" ? "#ffffff" : "#18181b";
return "#6b7280";
};
```
## The full set
47 icons. The registry item is `@9am/icon-`; the import path is `@/components/ui/`.
| File | Export | | File | Export |
| ------------------------- | --------------------------- | - | ----------------- | ------------------- |
| `arrow-left` | `ArrowLeftIcon` | | `layout-grid` | `LayoutGridIcon` |
| `arrow-right` | `ArrowRightIcon` | | `loader-circle` | `LoaderCircleIcon` |
| `badge-percent` | `BadgePercentIcon` | | `map-pin` | `MapPinIcon` |
| `box` | `BoxIcon` | | `menu` | `MenuIcon` |
| `boxes` | `BoxesIcon` | | `plus` | `PlusIcon` |
| `calendar-days` | `CalendarDaysIcon` | | `receipt` | `ReceiptIcon` |
| `cart` | `CartIcon` | | `refresh-cw` | `RefreshCWIcon` |
| `chart-column-increasing` | `ChartColumnIncreasingIcon` | | `route` | `RouteIcon` |
| `check` | `CheckIcon` | | `search` | `SearchIcon` |
| `chevron-down` | `ChevronDownIcon` | | `send` | `SendIcon` |
| `chevron-left` | `ChevronLeftIcon` | | `settings` | `SettingsIcon` |
| `chevron-right` | `ChevronRightIcon` | | `square-pen` | `SquarePenIcon` |
| `chevron-up` | `ChevronUpIcon` | | `theme-toggle` | `ThemeToggleIcon` |
| `circle-check` | `CircleCheckIcon` | | `trending-down` | `TrendingDownIcon` |
| `circle-dollar-sign` | `CircleDollarSignIcon` | | `trending-up` | `TrendingUpIcon` |
| `clock` | `ClockIcon` | | `truck` | `TruckIcon` |
| `credit-card` | `CreditCardIcon` | | `user` | `UserIcon` |
| `delete` | `DeleteIcon` | | `user-round-plus` | `UserRoundPlusIcon` |
| `dollar-sign` | `DollarSignIcon` | | `user-round-x` | `UserRoundXIcon` |
| `droplet` | `DropletIcon` | | `users` | `UsersIcon` |
| `folder-open` | `FolderOpenIcon` | | `wallet` | `WalletIcon` |
| `grip` | `GripIcon` | | `x` | `XIcon` |
| `hand-coins` | `HandCoinsIcon` | | | |
| `hand-helping` | `HandHelpingIcon` | | | |
| `hard-drive-download` | `HardDriveDownloadIcon` | | | |
`refresh-cw` exports `RefreshCWIcon`, not `RefreshCwIcon`. It is the one name that does not follow plain PascalCase.
## Adding a new icon
Copy an existing icon in `registry/icons/` and swap the SVG paths — the ref handle, hover forwarding, and `size` prop are all boilerplate you want to keep identical. Anything in that directory exposing `startAnimation` is picked up automatically by the registry generator and the preview gallery.
The kit has **no icon-library dependency at all**. Do not add one — reach for a hand-built icon so the animation contract stays consistent.
# 9AM UI Kit
Source: https://docs.9am.dev/ui-kit/introduction
The shared design language, NUI plumbing, and FiveM CEF workarounds behind every 9AM script — distributed as a private shadcn registry.
## Overview
9AM UI is the internal component kit every 9AM script builds its interface from. It is distributed as a **private [shadcn registry](https://ui.shadcn.com/docs/registry)**: you install components with `shadcn add`, and the files are copied into your script rather than imported from a package.
It was extracted from the vehicle shop, which is now its first consumer.
This kit is internal to the studio. The registry lives in a private repository and needs a GitHub token to install from — these pages are for 9AM developers, not for customers.
## What is in it
| Layer | Items | Contents |
| ---------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| Theme | `@9am/theme`, `@9am/fonts` | Design tokens for both palettes, the primary ramp, radius scale, Poppins + Phudu |
| Primitives | 19 | Buttons, inputs, tables, tabs, dialogs, the viewport scroller, and more |
| Icons | 47 | Hand-built animated SVGs with no icon-library dependency |
| NUI | `@9am/nui`, `@9am/visibility`, `@9am/use-theme`, `@9am/i18n` | `fetchNui`, `useNuiEvent`, frame visibility, theme store, runtime translations |
| Lib | `@9am/utils`, `@9am/smooth-scroll` | `cn()` and the CEF wheel-scroll fix |
| Scaffold | `@9am/scaffold`, `@9am/tools` | A complete new-script skeleton, plus the drift checker |
That is 76 items in total.
## Why copy-in and not a package
A package would give you one version and no way out of it. A registry copies source files into your script, which means:
* A script can diverge when it genuinely needs to, without forking anything.
* There is no runtime dependency between scripts — each ships its own built `dist`.
* Escrow builds stay clean, because the kit is a build-time concern and only `web/dist` reaches customers.
The tradeoff is that copies can drift apart. That is what [`9am-ui check`](/ui-kit/maintaining) is for: it fails the build when the design tokens have been edited locally, and warns when a component has.
## The two pieces that matter most
The house scroll container. Drag-able custom scrollbar, layered glass edges, and a header that blur-swaps to a sticky copy as you scroll.
FiveM runs Chromium 103. The kit carries the fixes for everything that breaks there but works in your browser.
Those two are the real value. Anyone can rebuild a button; the reason a 9AM NUI scrolls smoothly and renders correctly in-game is several days of paid-for debugging that now travels with the kit.
## Where to go next
Get a token, wire up `components.json`, scaffold a new script.
Tokens, palettes, fonts, and the styling conventions.
The full catalog with props and examples.
Talking to Lua, visibility, and translations.
# Maintaining the kit
Source: https://docs.9am.dev/ui-kit/maintaining
Drift checks, adding components, and releasing changes to the 9AM UI registry.
## Drift policy
Copy-in distribution means every script owns its files and can edit them. That flexibility is the point — but drift you did not choose is how a set of scripts stops looking related.
```bash theme={null}
bun src/scripts/9am-ui.mjs check
```
| What changed | Result |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `9am-theme.css` or `9am-fonts.css` | **Fails, exit 1.** Restore with `bunx shadcn@latest add @9am/theme @9am/fonts --overwrite`, or make the change in the kit and release it. |
| Any component or icon | **Warns, exit 0.** Allowed, but a local fix helps nobody else — consider upstreaming. |
| `src/nui.config.ts` | Ignored. It is supposed to differ per script. |
| Anything from `@9am/scaffold` | Ignored. A starting point, not a managed file. |
The asymmetry is deliberate. Identical tokens are what make scripts read as siblings; a one-off button variant in one script is a legitimate escape hatch.
Line endings are normalised before comparison, so a Windows checkout will not report false drift.
Wire `check` into CI. It only fetches the items you actually have installed, not all 76.
## Doctor
```bash theme={null}
bun src/scripts/9am-ui.mjs doctor
```
Verifies the things that are easy to get wrong and hard to notice:
* The `@9am` registry is configured and the token is set and working
* `index.css` imports both a theme and a font stylesheet
* `postcss.config.js` still has the [oklch conversion](/ui-kit/cef#oklch-is-not-parsed)
* Your built CSS contains no surviving `oklch()`
The checker is not an installable package. Bun resolves `github:` and `git+https:` dependencies through the GitHub tarball API, which returns 404 for a private repository even with a token — so it ships through the registry as `@9am/tools` and reuses the credentials already in `components.json`.
## Repository layout
```
registry/
theme/ 9am-theme.css (locked), 9am-fonts.css (generated), fonts/*.woff2
lib/ cn(), smooth-scroll
nui/ fetchNui, useNuiEvent, misc, debugData, useTheme,
VisibilityProvider, nui.config.ts, vite-env.d.ts
i18n/ runtime dictionary from Lua
ui/ 19 primitives
icons/ 47 animated icons
tools/ the check/doctor script
scaffold/ web/ + lua/
r/ BUILT registry JSON — committed, served by raw.githubusercontent
apps/preview/ gallery, imports straight out of registry/
cli/ setup commands (lua, fonts)
scripts/ build-fonts.ts, build-registry.ts
```
`registry.json` is **generated**. Never hand-edit it. `scripts/build-registry.ts` derives every item's npm dependencies and registry dependencies from the real import graph, so the manifest cannot disagree with the code.
## The preview gallery
```bash theme={null}
bun run preview
```
Renders every primitive, icon, and the viewport in both themes, importing straight out of `registry/` rather than from an installed copy. If a component would break on `shadcn add`, it breaks here first.
Use it to check both palettes before releasing a theme change.
## Adding a component
Drop it in `registry/ui/`, or `registry/icons/` if it exposes the `startAnimation()` handle.
`@/lib/utils`, `@/components/ui/button`, `@/hooks/useNuiEvent`, `@/utils/fetchNui`, `@/i18n`.
Never use a relative path across directories. That is what the file resolves to once installed — a relative path that works in the kit will break in a script.
Add any new npm dependency to the root `package.json` first. The generator errors out on an import it cannot find a version for, rather than shipping an item whose package never installs.
Add it to `apps/preview/src/Gallery.tsx`. Icons appear automatically — they are globbed.
```bash theme={null}
bun run build
bun run typecheck
bun run preview
```
## Adding a user-facing string
Kit components must not hardcode English. Use `t("ui.common.…")` and add the English fallback to `KIT_DEFAULTS` in `registry/i18n/index.ts` **and** to `registry/scaffold/lua/locales/en.json`.
If the string is domain-specific rather than generic, take it as a prop instead. `DataTable`'s `countLabel` exists for exactly this reason — so a coupons table can say "12 coupons" while the shared default stays "12 rows".
## Changing the theme
Token changes land in every 9AM script, so this is the highest-blast-radius edit in the repo.
* Check **both** palettes in the preview. Light mode is not a tint of dark mode.
* Never author a colour outside the token set. A raw hex in a component is drift `check` cannot see.
* Fonts: replace the woff2 in `registry/theme/fonts/`, then run `bun run fonts`. Never edit `9am-fonts.css` — it is generated.
## Releasing
```bash theme={null}
bun run build # regenerates fonts, registry.json and r/
bun run typecheck
bun run preview # eyeball both themes
git commit -am "feat(button): …" && git push
```
`r/` is committed on purpose. `raw.githubusercontent.com` serves committed files, which is what lets a private repository act as a registry with no hosting at all.
CI re-runs the build and fails if the committed `r/` differs from what the sources generate, so a stale registry cannot reach consumers.
Nothing auto-updates. Consumers pick a change up on their next `shadcn add … --overwrite`, which is deliberate — a script should adopt a token change when someone is watching it, not silently on a Tuesday.
# NUI bridge
Source: https://docs.9am.dev/ui-kit/nui
Talking to Lua from React, controlling frame visibility, theming, and runtime translations.
## The two directions
| Direction | React side | Lua side |
| ----------- | ------------------------------ | -------------------------------- |
| Lua → React | `useNuiEvent(action, handler)` | `SendReactMessage(action, data)` |
| React → Lua | `fetchNui(name, data, mock)` | `RegisterNUICallback(name, cb)` |
Install the whole layer with `bunx shadcn@latest add @9am/nui @9am/visibility @9am/use-theme @9am/i18n`.
## fetchNui
```ts theme={null}
import { fetchNui } from "@/utils/fetchNui";
const result = await fetchNui(
"buyVehicle",
{ model: "adder", shopId: "pdm" },
{ success: true }, // returned instead in browser dev mode
);
```
Three behaviours worth knowing:
* **Ten-second timeout.** A Lua callback that never calls `cb()` would otherwise hang the promise forever.
* **Browser dev mode.** When running under `bun run start` and `mockData` is supplied, it returns the mock without touching the network.
* **It never rejects.** On failure it returns `mockData` if that was an array, otherwise `{ success: false, message: "Request failed" }`. Awaiting call sites cannot hang or throw an uncaught rejection.
The resource name comes from `window.GetParentResourceName()` in CEF, falling back to `nuiConfig.resourceName` in the browser.
Every `RegisterNUICallback` must call `cb(...)` exactly once. Returning without calling it leaves the awaiting promise unresolved until the timeout fires.
## useNuiEvent
```ts theme={null}
import { useNuiEvent } from "@/hooks/useNuiEvent";
useNuiEvent("setStock", (vehicles) => {
setStock(vehicles);
});
```
The handler is kept in a ref, so you can pass an inline closure without re-subscribing on every render.
On the Lua side:
```lua theme={null}
SendReactMessage('setStock', vehicles)
```
## Visibility
`VisibilityProvider` shows and hides the app, and guarantees the player never loses input.
```jsx theme={null}
import { VisibilityProvider } from "@/providers/VisibilityProvider";
```
It listens for the `setVisible` action, handles Escape by calling the `hideFrame` callback, and fades the tree with an opacity and scale transition. Read the state anywhere with `useVisibility()`.
From Lua:
```lua theme={null}
SetNuiVisible(true) -- takes focus, sends setVisible
SetNuiVisible(false) -- releases focus
```
The scaffold's `client/nui.lua` also releases focus on `onResourceStop`. Without that, restarting the resource while the UI is open leaves the player stuck with no input — which happens constantly during development.
Pair it with the error boundary in `main.tsx`, which calls `hideFrame` if a render crashes. A UI error must never trap the player.
## Theme
```ts theme={null}
import { useTheme } from "@/hooks/useTheme";
const { theme, setTheme, toggleTheme } = useTheme();
```
A zustand store that toggles the `.dark` class on `` and persists to `localStorage` under `nuiConfig.themeStorageKey`.
`index.html` reads the same key in an inline script before first paint, so the correct theme is applied before React mounts. Keep the two in sync when you change the key.
## Configuration
`src/nui.config.ts` is the one kit file each script is expected to edit. `9am-ui check` skips it deliberately.
```ts theme={null}
export const nuiConfig = {
resourceName: "my-script",
themeStorageKey: "my-script:theme",
defaultTheme: "dark",
} as const;
```
| Field | Purpose |
| ----------------- | -------------------------------------------------------------------------------------- |
| `resourceName` | Browser-dev fallback for the callback URL. In CEF the real name is read from the host. |
| `themeStorageKey` | Namespaced so two 9AM NUIs in one CEF context cannot overwrite each other |
| `defaultTheme` | `"light"` or `"dark"` — used before the player has toggled anything |
## Translations
Translations are **not bundled**. They live in the resource's `locales/*.json` and are pulled from Lua at boot, so a customer can retranslate a script without a web rebuild.
```ts theme={null}
// main.tsx
import { initI18n } from "./i18n";
initI18n(__DEV_SEED__ ? () => import("../../locales/en.json") : undefined);
```
The optional loader only runs under `bun run start`, where there is no Lua to ask. The kit does not hardcode the path — only your app knows where its locales live.
```jsx theme={null}
import { useT } from "@/i18n";
const t = useT();
{t("ui.dashboard.stock.title")}
{t("ui.catalog.priceFrom", { price: "50,000" })}
```
```ts theme={null}
import { t } from "@/i18n";
```
Placeholders use `{name}` syntax and are substituted from the vars object.
Never call `t` at module scope. The dictionary loads asynchronously, so a translated string captured in a top-level constant will be the raw key forever. Store keys in your definition arrays and translate at render time.
### Resolution order
1. The dictionary from Lua
2. `KIT_DEFAULTS` — English fallbacks for the kit's own strings
3. The key itself
Step two means a freshly scaffolded script renders "No results." rather than the raw string `ui.common.noResults`. Your own keys still belong in `locales/`.
Keys the kit uses itself, all under `ui.common`: `close`, `searchPlaceholder`, `noResults`, `noResultsFound`, `selectPlaceholder`, `nSelected`, `rowCount`, `pageOf`.
### The Lua side
`shared/locale.lua` loads English first, deep-merges the active language over it, and exposes both a Lua helper and the NUI callback:
```lua theme={null}
locale('game.purchase.success', { vehicle = label, price = 50000 })
```
A key missing from the active language falls back to English; missing from both, it returns the key, so a typo is visible in-game rather than a crash.
Load it **after** `shared/config.lua` — it reads `Config.Locale`.
## Browser development
```ts theme={null}
import { isEnvBrowser } from "@/utils/misc";
import { debugData } from "@/utils/debugData";
// Simulate a message from Lua while developing in the browser
debugData([{ action: "setStock", data: mockVehicles }]);
```
`debugData` only fires in development mode and outside CEF, so it is safe to leave in place.
Gate dev-only code behind `__DEV_SEED__` — the compile-time flag is `false` for `bun run production-build`, which removes those branches and their chunks from the customer build entirely.
## Smooth scroll
```ts theme={null}
// main.tsx
import { installSmoothScroll } from "./lib/smooth-scroll";
installSmoothScroll();
```
Call this once. It is not optional: [CEF will not wheel-scroll a `clip-path`'d container](/ui-kit/cef), which includes every `Viewport`. It also gives every scroller the same eased feel in the browser and in-game.
# Setup
Source: https://docs.9am.dev/ui-kit/setup
Authenticate against the private 9AM UI registry, scaffold a new script, or add the kit to an existing one.
## Get a token
The registry is private, so `shadcn` needs credentials.
Create a personal access token with **read access to `ilovehugetits/9am-ui`**.
Add it to your shell profile so it survives a reboot.
```bash theme={null}
export NINEAM_UI_TOKEN=github_pat_xxxxxxxx
```
Never commit the token. `components.json` references it as `${NINEAM_UI_TOKEN}` and `shadcn` expands it at install time, so the file itself stays safe to check in.
## Point a script at the registry
Add a `registries` block to your script's `web/components.json`:
```json theme={null}
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@9am": {
"url": "https://raw.githubusercontent.com/ilovehugetits/9am-ui/main/r/{name}.json",
"headers": {
"Authorization": "Bearer ${NINEAM_UI_TOKEN}"
}
}
},
"iconLibrary": "lucide"
}
```
Items are served straight off `raw.githubusercontent.com`, so there is nothing to host and no npm registry to authenticate against.
## Start a new script
```bash theme={null}
mkdir -p my-script/web && cd my-script/web
bun init -y
```
Then add the `components.json` shown above.
```bash theme={null}
bunx shadcn@latest add @9am/scaffold
```
This writes `vite.config.ts`, `tsconfig.json`, `eslint.config.js`, `postcss.config.js`, `index.html`, `src/main.tsx`, and a starter `src/components/App.tsx` — and pulls in the theme, fonts, NUI bridge, i18n, viewport, and the icons the starter uses.
```bash theme={null}
cd .. && bun /cli/index.ts lua
```
Writes `fxmanifest.lua`, `client/nui.lua`, `shared/locale.lua`, and `locales/en.json` into the resource root. Existing files are skipped unless you pass `--force`.
This step is not a registry item because `shadcn` rejects any install target containing `..`, and the Lua files belong one level above the web project.
Edit `web/src/nui.config.ts` — this is the one kit file you are meant to change.
```ts theme={null}
export const nuiConfig = {
resourceName: "my-script",
themeStorageKey: "my-script:theme",
defaultTheme: "dark",
} as const;
```
Keep the `localStorage` key in `index.html` in sync with `themeStorageKey`. It runs before first paint to stop the wrong theme flashing.
```bash theme={null}
bun install
bun run build
bun src/scripts/9am-ui.mjs doctor
```
## Add the kit to an existing script
Add the `registries` block, then take only what you want:
```bash theme={null}
bunx shadcn@latest add @9am/theme @9am/fonts
bunx shadcn@latest add @9am/viewport @9am/data-table
```
Dependencies resolve transitively. Asking for `@9am/data-table` also installs `button`, `input`, `table`, `i18n`, and its three icons — and nothing else, so a script that only wants a button does not pull in charts.
After adding the theme, import it from your entry stylesheet. Order matters:
```css theme={null}
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/9am-fonts.css";
@import "./styles/9am-theme.css";
/* your script's own styles go below */
```
## Where files land
| Item type | Destination |
| ---------------------------------- | ------------------------ |
| Primitives and icons | `src/components/ui/` |
| `@9am/utils`, `@9am/smooth-scroll` | `src/lib/` |
| `useNuiEvent`, `useTheme` | `src/hooks/` |
| `fetchNui`, `misc`, `debugData` | `src/utils/` |
| `VisibilityProvider` | `src/providers/` |
| `@9am/i18n` | `src/i18n/` |
| `@9am/theme`, `@9am/fonts` | `src/styles/` |
| `@9am/nui` config | `src/nui.config.ts` |
| `@9am/tools` | `src/scripts/9am-ui.mjs` |
## Recommended package scripts
```json theme={null}
{
"scripts": {
"start": "vite",
"start:game": "vite build --watch",
"build": "tsc && vite build",
"production-build": "tsc && vite build --mode prod",
"lint": "eslint src",
"ui:check": "bun src/scripts/9am-ui.mjs check",
"ui:doctor": "bun src/scripts/9am-ui.mjs doctor"
}
}
```
| Command | Use it for |
| -------------------------- | ------------------------------------------------------------------ |
| `bun run start` | Browser-only development with mock data. Fastest loop for UI work. |
| `bun run start:game` | Rebuilds `dist` on save. Restart the resource to reload the NUI. |
| `bun run build` | Required before testing in-game — `web/dist` is gitignored. |
| `bun run production-build` | Customer-ready build with dev seeds and console noise stripped. |
# Theme
Source: https://docs.9am.dev/ui-kit/theme
Design tokens, the light and dark palettes, fonts, and the styling conventions that make a script look like a 9AM script.
## How the theme is structured
The theme installs as two stylesheets in `src/styles/`:
| File | Contents |
| --------------- | ----------------------------------------------------------------------------------------------------- |
| `9am-theme.css` | Tokens for both palettes, the primary ramp, radius scale, the `dark:` variant, and the NUI base rules |
| `9am-fonts.css` | Poppins and Phudu as `@font-face` declarations |
Both are **locked**. `9am-ui check` fails the build if either is edited locally — identical tokens across scripts is the whole point. Put your own styles in `index.css`, below the imports.
## Dark mode is class-driven
```css theme={null}
@custom-variant dark (&:where(.dark, .dark *));
```
Without this, every `dark:` utility keys off the player's operating system theme, which is neither what you want nor controllable from the UI. Instead the `.dark` class on `` drives everything, and [`useTheme`](/ui-kit/nui#theme) toggles it.
Dark is the shipped default. `index.html` sets the class before first paint so the wrong theme never flashes.
## Palettes
Tokens are authored in OKLCH. Dark ships by default; light is a real second palette, not a tint.
```css theme={null}
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--primary: oklch(0.9089 0.1361 99.99); /* bright gold */
--primary-foreground: oklch(0.28 0.07 46);
--muted-foreground: oklch(0.705 0.015 286.067);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
```
```css theme={null}
--background: oklch(0.985 0.002 285.9);
--foreground: oklch(0.21 0.006 285.885);
--card: oklch(1 0 0);
--primary: oklch(0.58 0.135 80); /* deeper amber */
--primary-foreground: oklch(0.985 0.015 95);
--muted-foreground: oklch(0.505 0.014 285.9);
--destructive: oklch(0.583 0.222 27.2);
--border: oklch(0 0 0 / 10%);
```
Light mode's `--primary` is deliberately a **deeper** amber than dark mode's bright gold. Bright gold is unreadable as text on white and washes out as a fill. Choosing one deeper gold that works both as `bg-primary` with dark text *and* as `text-primary` on white is why roughly forty `text-primary` usages needed no per-file changes when light mode was added.
## The primary ramp
Eleven fixed gold steps, independent of the active palette:
```
primary-50 primary-100 primary-200 primary-300 primary-400 primary-500
primary-600 primary-700 primary-800 primary-900 primary-950
```
Use them as normal Tailwind utilities — `bg-primary-500`, `text-primary-200`, `border-primary-700`.
The ramp lives in `@theme inline`, which means Tailwind inlines the values into utilities instead of emitting CSS custom properties. `var(--color-primary-500)` resolves to **nothing**. Always use the generated utility, and spell the class out in full so Tailwind's scanner can see it — a template literal like `` `bg-primary-${n}` `` produces no CSS.
## Radius
```css theme={null}
--radius: 0.625rem;
```
`radius-sm` is `--radius - 4px`, `radius-md` is `- 2px`, `radius-lg` equals it, `radius-xl` is `+ 4px`.
In practice: `rounded-2xl` for containers and cards, `rounded-lg` for controls, `rounded-xl` for tiles and avatars.
## Type
| Family | Weights | Used for |
| ------- | ------------------ | ---------------------------------------------------------------- |
| Poppins | 400, 500, 600, 700 | All body copy. Applied globally to `html, body`. |
| Phudu | 600, 700 | Display — headings, page titles, the `navigation` button variant |
Apply Phudu with `!font-[phudu]`:
```jsx theme={null}
Vehicle stock
```
### Choosing a font stylesheet
`9am-theme.css` does not import fonts, because which stylesheet you want depends on your app.
| Option | Use when | How |
| --------------------- | ------------------------------------------ | --------------------------------------------------------------------- |
| `@9am/fonts` (base64) | Single HTML entry — the common case | `bunx shadcn@latest add @9am/fonts` |
| Linked woff2 | Several HTML entries, especially DUI pages | `bun <9am-ui>/cli/index.ts fonts`, then import `9am-fonts-linked.css` |
Embedding is the better default: one command, nothing else to fetch, and in a NUI everything loads off local disk so there is no network round-trip to save.
It becomes the wrong choice the moment you have multiple entry points. **Every DUI is its own CEF instance**, so each one would decode the entire 322 KB payload again with nothing shared between them. The vehicle shop is the worked example — it has three entries and creates one DUI per showroom slot, so it uses the linked variant.
## Styling conventions
These are what make a screen read as 9AM rather than generic shadcn.
### Translucent fills, not solid greys
```jsx theme={null}
```
This is *the* raised-control surface — buttons, inputs, avatar tiles, list rows. Hairline borders are `border-black/10 dark:border-white/10`.
### Gold tint for accent states
```jsx theme={null}
```
`#F6E3711a` is gold at 10% opacity. It is the hover and active wash across links, tabs, and badges.
### Press feedback
```jsx theme={null}