Authoring Layouts
Writing the layout and stylesheet a custom_hud_layout loads — the narrow XML subset it accepts, Panorama CSS versus web CSS, compiling, and the state of shipping layouts to players.
The plugin side is a few calls; most of the work is the layout. A
custom_hud_layout loads a Panorama layout, but through a validator much
stricter than Panorama itself, and Panorama CSS is not web CSS. Both fail
silently — a layout that does not load simply is not there, a CSS rule it
does not understand is dropped. This page collects what is known to work.
Source2Mod repository's panorama/ folder holds the reference layouts the
core draws HUD menus, texts and prompts with
(s2t_menu, s2t_hud), with their stylesheets and a README on building the
addon — a working starting point for a layout of your own. What follows comes
from hands-on testing of custom_hud_layout on CS2 builds from
August–September 2026. Valve marks the feature experimental; re-check after
game updates.
File layout
content/csgo_addons/<addon>/panorama/layout/custom_game/my_menu.xml source layout
content/csgo_addons/<addon>/panorama/styles/custom_game/my_menu.css source stylesheet
game/csgo_addons/<addon>/panorama/layout/custom_game/my_menu.vxml_c compiled
game/csgo_addons/<addon>/panorama/styles/custom_game/my_menu.vcss_c compiledThe plugin refers to the layout by name — CCSCustomHudLayout::Create("my_menu")
loads panorama/layout/custom_game/my_menu.vxml_c. The layout pulls in its
stylesheet by the compiled path, with the s2r:// scheme.
A minimal layout
<root>
<styles>
<include src="s2r://panorama/styles/custom_game/my_menu.vcss_c" />
</styles>
<Panel class="screen">
<Panel id="MenuRoot" class="menu">
<Label id="Title" class="title" text="{s:title}" />
<Button id="buy_ak47" class="row"><Label text="AK-47" /></Button>
<Button id="close" class="row"><Label text="Close" /></Button>
</Panel>
</Panel>
</root>.screen {
width: 100%;
height: 100%;
z-index: 99999;
}
.menu {
visibility: collapse; /* authored hidden: the entity shows it to everyone */
flow-children: down;
horizontal-align: center;
vertical-align: center;
background-color: #000000c0;
padding: 16px;
}
.menu.show {
visibility: visible;
}
.row:hover {
background-color: #ffffff20; /* :hover works once input capture is on */
}Three rules this example follows on purpose:
- The outermost panel has no
id. The loader assigns the root; put an anonymous full-screen panel first and your real root with its id inside it. - The menu is authored hidden. One entity draws its layout for every
player; per-player state only overlays it. A layout that is visible as written
appears on everyone's screen the moment the entity spawns. Show it per player
with
SetHasClass("MenuRoot", "show", true, pPlayer). z-indexgoes on the outermost panel, with a large value.z-indexonly orders siblings, so on an inner panel it does nothing; on the root wrapper it puts the layout above the game HUD, crosshair included.
What the validator accepts
The custom_hud_layout validator allows a small subset of Panorama:
| Allowed | |
|---|---|
| Tags | Panel, Label, Image, Button |
| Attributes | id, class, hittest, text, src, texturewidth, textureheight |
| File sections | <styles> with <include> |
Everything else is rejected, and that covers most of what Valve's own layouts use:
- No scripts.
<scripts>,<script>and<snippets>are rejected, so there is no JavaScript in a custom HUD layout — all logic stays in your plugin, which drives the layout through classes and dialog variables. - No event attributes — no
onactivate,onload. A button's click reaches the server through itsid; see Click callbacks. - No
style,html,selected,disabled,acceptsfocus,hittestchildren,tooltip, and noTextEntry— a layout cannot take typed text; use chat. Imagesrcis static. The server cannot change it. For a picture that changes, use aPanelwhosebackground-imageis set by a class.
Copying a Valve layout is rarely a good start: most of them declare scripts or use attributes from the list above.
When a layout is rejected the client console says why — one violation per load, so expect to iterate:
Console line before did not pass CustomHud validation | Meaning |
|---|---|
Failed to load layout '<path>' | The file was never found. Nothing was parsed — check the path and that the .vxml_c exists on the client. |
Layout contains disallowed attribute X for panel type 'Y' | It loaded and was rejected. Fix the XML. |
Panorama CSS is not web CSS
The habits that fail silently:
| Web | Panorama |
|---|---|
display: flex / grid | No display. Use flow-children: down / right. Panels never wrap. |
display: none | visibility: collapse |
rgb(), rgba(), hsl() | #rrggbb / #rrggbbaa only |
calc(), var(), @media, ::before, ::after | Do not exist |
top / left / float | x, y, horizontal-align, vertical-align, position |
box-shadow: 0 4px 8px #000 | Colour first: box-shadow: #000000 0px 4px 8px 0px |
background-size: contain | contains |
width: 100% in an unsized parent | Resolves to nothing — the parent sizes to the child and the child to the parent |
What does exist: :hover and :active (with input capture on),
:first-child, :last-child, :not(), :nth-child(); @keyframes,
@import, @define; transitions. :selected, :disabled and :focus are
registered but unreachable, because the attributes that drive them are not
allowed — use a class from the server instead.
Useful ones:
- Bars and gauges with
clip, notwidth.clip: rect( 0%, 50%, 100%, 0% )(top, right, bottom, left) does not affect layout. Awidthtransition restarts whenever a dialog variable in the same subtree is written;clipdoes not. One class per step:.bar.w50 { clip: rect( 0%, 50%, 100%, 0% ); }. text-overflow: shrinklowers the font size until the text fits the box.- Stock stylesheets can be included (
csgostyles.vcss_c) for the game's fonts and colours. Watch for class-name clashes: stock sheets define bothhiddenandHidden. - Restarting an animation needs two class names used alternately; turning one class off and on in the same tick sends nothing (see Layouts).
Compiling
Layouts and stylesheets are compiled like any other Source 2 resource, in the
CS2 Workshop Tools for your addon, or with the resource compiler that ships with
the game (game\bin\win64\resourcecompiler.exe), which the Workshop Tools
use. Given the source file (-i <path>), it writes the _c file to the
matching path under game\csgo_addons\<addon>\.
.xml and .css changes hot-reload in a running client; z-index changes
did not. Get the plugin side working first, then iterate on the stylesheet.
Shipping to clients
Since the CS2 update of 24 August 2026 the client loads custom HUD layouts
from a Workshop addon (earlier builds refused them with
client disallowing panorama layout file creation). The way to players:
- Create an addon in the Counter-Strike 2 Workshop Tools and copy your
panorama/folder intocontent/csgo_addons/<addon>/. - Compile — the tools do it on publish, or run
resourcecompiler.exe -ion each file — and publish the addon from the Workshop Manager. Note the file id. - Make the server hand it out. The engine only downloads the map's addon
by itself; an extra addon needs
MultiAddonManager
(
mm_extra_addons <id>) or an equivalent.S2C_CONNECTION [addons:'<id>']in the server log shows it being sent.
Panorama caches layouts for the whole client session: after a republish, players see the new version once they restart the game.
The server cannot tell whether a player has the addon. A player without it draws nothing — and, with input capture on, is left with a cursor. Plan for that where you cannot assume it (a center HTML fallback for menus, say).
For a local client, a search path in gameinfo.gi pointing at the compiled
game/csgo_addons/<addon>/ works too; that is a development setup, not a way
to ship.