UI Style
Last updated
May be stale
This page was last updated ago, on 19 September 2026. Check what it says against the code before relying on it.
Which colour, font, size and pattern to use when you build client UI: a Derma panel, an entity menu or a HUD element. It is the style CityRP's recent panels already share, written down and ratified, so that a new panel matches the ones around it.
This page covers values. How sizes are scaled is in UI Scaling and is not
repeated here: every dimension and font on this page goes through cityrp.ui unless it says
otherwise.
Most of the patterns below are a call, not a recipe: cityrp.ui carries the panel kit, and the
pattern sections name the helper that builds each one. Read the values so you know what the kit
is doing and where a one-off has to match it by hand.
The rules apply to new UI and to UI you are already changing. They are not a reason to sweep old
panels. The evidence behind each choice is in the repository at
docs/design/ui-style/research-existing-ui.md.
Scope
- Panels: anything the player opens. Frames, entity menus, editors, dialogs. Staff and developer panels use exactly the same style as player panels; there is no staff chrome.
- HUD: anything drawn over the world while playing. Chips, badges, notifications, overlays.
- The F1 menu follows this page in everything except scaling, until its replacement lands. The replacement's surface palette is being decided with that work and is not defined here.
- Vendored addons are out of scope. Patch their integration points, do not re-skin them.
Exceptions
Two kinds of colour are outside the UI palette, and the frame chrome around them still follows this page:
- Data palettes. Colours that encode a category: job and team colours, rank colours, chat channel colours, waypoint types.
- Diegetic surfaces. The content area of something that is an object in the world: a card table, a keypad screen, a licence plate.
Further exceptions can be made for a specific theme. Write the reason down beside the palette.
Palette
A colour that has a role on this page is used by its constant, never retyped as Color(...).
Inline Color(...) stays fine for a one-off that has no role here.
Panel surfaces
Neutral charcoal, opaque.
| Role | Constant | Value |
|---|---|---|
| Frame fill | COL_PANEL_BG |
28, 28, 31, 252 |
| Title bar | COL_PANEL_TITLEBAR |
20, 20, 23 |
| Content surface | COL_PANEL_BODY |
38, 38, 42 |
| Card, list row | COL_PANEL_CARD |
32, 32, 36 |
| Alternate row | COL_PANEL_ROW_ALT |
40, 40, 45 |
| List header, inset border | COL_PANEL_HEADER |
24, 24, 27 |
| Text input | COL_PANEL_INPUT |
48, 48, 52 |
| Neutral button | COL_PANEL_BUTTON |
58, 58, 64 |
| Selected row | COL_PANEL_SELECTED |
24, 96, 168 |
Text
| Role | Constant | Value |
|---|---|---|
| Body text | COL_TEXT |
206, 212, 218 |
| Secondary text | COL_TEXT_MUTED |
132, 137, 145 |
| Headings, labels on light-text fills | COL_STEEL_WHITE |
236, 239, 241 |
| Labels on dark-text fills | COL_STORM |
33, 33, 33 |
Roles
Five semantic roles. Each is one colour, used for fills, rules, icons and text alike.
| Role | Constant | Value | Use |
|---|---|---|---|
| Brand | COL_BRAND |
66, 165, 245 |
The single accent: title-bar rule, active tab, primary button, focused input, text selection |
| Information | COL_INFO (COL_UI_PALEBLUE) |
112, 145, 187 |
Neutral emphasis, hints. Never a second accent |
| Success | COL_SUCCESS (COL_UI_GREEN, COL_ACCEPT) |
89, 127, 88 |
Confirm, save, met |
| Warning | COL_WARNING (COL_UI_AMBER) |
212, 172, 58 |
Needs attention, validation |
| Danger | COL_DANGER (COL_UI_RED) |
179, 74, 78 |
Destructive action, error, unmet |
COL_REJECT (139, 35, 35) is danger as a large fill only, such as a banner.
Text on a role fill
Look the label colour up from the role; do not choose it by eye.
button:SetTextColor(COL_TEXT_ON[COL_BRAND])
| Fill | Label |
|---|---|
COL_BRAND, COL_INFO, COL_WARNING |
COL_STORM (dark) |
COL_SUCCESS, COL_DANGER, COL_PANEL_BUTTON, COL_STEEL |
COL_STEEL_WHITE (light) |
COL_TEXT_ON is keyed by the colour itself, so look up the base role colour, once, when you
style the control. A hover or pressed shade is a new Color and is not in the table. Labels on
COL_SUCCESS are weight 500 or heavier.
States are derived
Hover, pressed and disabled colours are never authored. Build them once, at style time, with
cityrp.ui.Shade, and never inside Paint:
| State | Shade |
|---|---|
| Hover | cityrp.ui.Shade(base, 25) |
| Pressed | cityrp.ui.Shade(base, -25) |
| Disabled | cityrp.ui.Shade(base, -30), with the label switched to COL_TEXT_MUTED |
Every interactive control has a disabled state. Paint in the order pressed, disabled, hovered,
normal. Never write to a shared colour; Shade and ColorAlpha return a new one.
HUD
| Role | Constant | Value |
|---|---|---|
| Chip with text | COL_HUD_CHIP |
41, 41, 41, 204 |
| Icon tile | COL_UI_GREY |
41, 41, 41, 230 |
| Chip outline, 1px | COL_HUD_OUTLINE |
68, 68, 68 |
| World hint text ("Press E to...") | COL_GUIDE |
255, 137, 0 |
COL_GUIDE is for prompts drawn in the world through GM:DrawInformation. It is not a panel
accent.
Type
All fonts are created with cityrp.ui.CreateScaledFont inside a cityrp.ui.OnScaleChanged
callback; see UI Scaling.
| Surface | Face |
|---|---|
| Panels | Roboto |
| HUD | Saira |
| Chat log | Tahoma, the one exception. It was chosen to avoid synthesised-bold blur at small sizes |
Roboto ships with Garry's Mod and Saira is deployed with the server content. No other face is used: a face that is not deployed falls back silently on clients that do not have it installed.
Panel scale (Roboto)
| Role | Constant | Size / weight | Use |
|---|---|---|---|
| Title | cityrp.ui.FONT_TITLE |
19 / 500 | Frame heading |
| Body | cityrp.ui.FONT_BODY |
14 / 400 | Everything by default: labels, rows, buttons, inputs |
| Caption | cityrp.ui.FONT_CAPTION |
12 / 500 | Secondary lines, list headers, hints |
| Section | — | 13 / 700 | Optional. A group heading inside a dense table |
The three constants are the kit's own fonts, built and rebuilt with the scale. A panel that needs none of its own names them rather than creating a duplicate set.
HUD scale (Saira)
| Role | Size | Use |
|---|---|---|
| Label | 19 | Chip label |
| Secondary | 15 | Secondary chips, notifications |
| Caption | 12 | Captions, percentages |
Weights 400 to 500. A label that must fit a fixed width may step down through smaller sizes; that is a fitting technique, not extra steps on the scale.
Naming
CityRP.<Feature>.<Role>, with the role taken from the tables above: CityRP.Phonebook.Title,
CityRP.Phonebook.Body. Never name a font after its face.
Spacing and sizing
Units passed to cityrp.ui.Scale:
| Unit | Use |
|---|---|
| 2 | Rules, accent bars |
| 4 | Gaps inside a control, between a label and its control |
| 8 | The default gap between controls and between rows |
| 12 | The default panel padding; title inset |
| 20 | Content inset of a modal |
6 is tolerated between tightly packed controls. Scale a padding once into a local and reuse it
across DockMargin's arguments.
| Element | Size |
|---|---|
| Title bar | Scale(26) for a single-purpose popup, Scale(32) for a multi-section frame |
| Button | Scale(28) tall |
| List row | Scale(22) |
| List header | Scale(24) |
| Tab bar | Scale(32) |
| Small entity popup | Scale(400) by Scale(180) |
Corner radius is not scaled. 6 for the frame and its title bar, 4 for everything inside: buttons, cards, rows, inputs. HUD chips are square.
A line that must never disappear is guarded: math.max(cityrp.ui.Scale(2), 1).
HUD elements keep a 30px screen margin, 8px between stacked elements and 4px inside a stack, and
ask their neighbour for its bounds (Get*Bounds()) instead of hardcoding a y position.
Patterns
Frame
cityrp.ui.StyleFrame(frame, heading), which returns the title bar height to inset the content
by. It does the title bar, the close button and the chrome below in one call.
A DFrame with SetTitle("") and ShowCloseButton(false), because the stock title and close
button do not scale. Paint draws draw.RoundedBox(6, ...) in COL_PANEL_BG. Frames are
centred and not draggable. SetBackgroundBlur(true) on a modal that blocks play; off on a tool
used while playing.
Give a transparent child an explicit no-op Paint, never nil. Lay out with Dock, not
SetPos.
Title bar
Part of cityrp.ui.StyleFrame; its third argument is the height, Scale(32) by default and
Scale(26) for a single-purpose popup.
draw.RoundedBoxEx(6, ...) rounding the top corners only, in COL_PANEL_TITLEBAR, with a
math.max(Scale(2), 1) rule in COL_BRAND along its bottom edge. The heading is the Title font
in COL_STEEL_WHITE, inset Scale(12), vertically centred.
Fill the bar with a role colour only when the fill carries state, as the item grant menu does for give and take. Decoration is not a reason.
Close button
cityrp.ui.AddTitleBarCloseButton(frame, titleBarHeight, onClose), always.
Button
cityrp.ui.StyleButton(button, variant). A DButton with a Paint override:
draw.RoundedBox(4, ...) in the state colour, Body font, label colour from COL_TEXT_ON.
| Variant | Argument | Base |
|---|---|---|
| Primary | "accent" |
COL_BRAND |
| Neutral | "neutral" |
COL_PANEL_BUTTON |
| Positive | "positive" |
COL_SUCCESS |
| Destructive | "danger" |
COL_DANGER |
One primary button per view.
A control that has to match a button but cannot be one -- a tab, a chip, a hand-built row --
takes its fills from cityrp.ui.ButtonColours(variant) rather than shading them itself.
Input
A DTextEntry whose Paint draws draw.RoundedBox(4, ...) in COL_PANEL_INPUT, then
self:DrawTextEntryText(COL_STEEL_WHITE, COL_BRAND, COL_STEEL_WHITE). Overriding Paint drops
the skin's placeholder, so draw one yourself in COL_TEXT_MUTED, inset Scale(4). DComboBox
takes the same treatment.
List
- Pure data:
cityrp.ui.StyleList(list), before the list's firstAddColumnorAddLine, because it wraps both. ADListViewwith header and row heights set from the table above, because the stock 17px row clips a scaled font. - Rows that hold controls: a
DScrollPanelof hand-built rows,Dock(TOP),Scale(4)apart,draw.RoundedBox(4, ...).
Rows alternate COL_PANEL_CARD and COL_PANEL_ROW_ALT. The selected row is
COL_PANEL_SELECTED.
Card
cityrp.ui.StyleCard(panel, fill), or cityrp.ui.PaintCard(panel, w, h, fill) from a Paint
that has its own drawing to do on top.
A 1px inset border: draw.RoundedBox(4, ...) in COL_PANEL_HEADER, then the same box inset by 1
in the fill colour.
Tabs
Hand-built Dock(LEFT) buttons in a Scale(32) bar, top corners rounded. The active tab takes
the content panel's fill so it merges into it, with a Scale(2) bar in COL_BRAND along its top
edge. A tab bar that does not apply is collapsed, not shown disabled. Do not use
DPropertySheet.
Confirmation
Anything the player cannot undo is confirmed first: destroy, sell, delete, remove from a clan. Reversible actions such as drop or unequip are not.
A confirmation is a small modal frame in the style above: a title, one or two lines of Body text
saying what will happen, a Positive or Destructive button naming the action ("Destroy", not
"Yes"), and a Neutral "Cancel" to its right. Escape cancels. Background blur is on.
gamemode/core/derma/cl_makeleader.lua is the reference. Stock Derma_Query and
Derma_StringRequest are for developer tools only.
Notification
A notification is a HUD chip: COL_UI_GREY, square, COL_HUD_OUTLINE, Saira Secondary, with a
2px bar along its top edge in the role colour for its type: generic is COL_BRAND, hint is
COL_INFO, warning is COL_WARNING, error is COL_DANGER. It takes its position from the HUD's
bounds.
This is the target for the notification renderer, which does not meet it yet. Panels keep calling
notification.AddLegacy and need no change when the renderer is rebuilt.
Motion
Panels do not animate. HUD elements slide in over 0.5s and out over 0.25s, and fade over 0.4s.
Scale changes
A panel opened after the scale changes must be correct, and fonts must rebuild live through
OnScaleChanged. Relayout of a panel that is already open is recommended: derive geometry in
PerformLayout, or rebuild the panel with its state carried over.
Accessibility
- Body text has a contrast ratio of at least 4.5:1 against the surface it sits on. Large or bold
labels have at least 3:1. The palette above meets this when
COL_TEXT_ONis used. COL_TEXT_MUTEDis not used onCOL_PANEL_BUTTON.- State is never carried by colour alone. Pair a role colour with a label or an icon.
- Everything follows the player's
cl_llhud_scalesetting throughcityrp.ui.