name: sbox-ui-razor description: Create UI in S&box using Razor (HTML/CSS/C# hybrid system). Use when building HUDs, menus, healthbars, inventory systems, or any in-game UI. Covers ScreenPanel vs WorldPanel, data binding, state management, BuildHash optimization, and interactive elements. metadata: author: basebound version: "1.0" source: "S&box UI Tutorial 2024" compatibility: S&box (Facepunch), .NET 10, C# 14, Razor
S&box UI with Razor
Build interactive UI in S&box using Razor, a hybrid system that blends HTML/CSS structure with C# logic.
When to Use
- Creating HUDs (health bars, ammo counters, minimaps)
- Building menus (pause, settings, inventory)
- Implementing interactive UI elements
- Designing in-world screens (terminals, billboards)
- Adding dynamic text/icons that update with game state
Core Concepts
Razor System
Razor files (.razor) combine:
- HTML for structure
- SCSS/CSS for styling (
.scssfile) - C# for logic (using
@symbol)
Panel Types
- Screen Panel - Overlay HUD (2D, always faces camera)
- World Panel - In-world interactive screen (3D space)
Project Setup
Creating a Razor Component
- Create a Razor Panel Component (not a regular component)
- This generates two files:
MyUI.razor- HTML structure + C# logicMyUI.razor.scss- Styling
Scene Integration
Add a panel to your scene:
- For HUD: Create Screen Panel in Scene hierarchy
- For 3D UI: Create World Panel and position in world
Scene
└── Screen Panel
└── My UI Component (attach your .razor component here)
Data Binding Basics
Use @ to execute C# code in HTML:
<label>@player.Health</label>
<div>Coins: @coins</div>
<label>@(Health / MaxHealth * 100)%</label>
State Management & Optimization
BuildHash - Critical for Performance
Override BuildHash to tell S&box when to redraw UI. If hash unchanged, UI doesn't update (saves performance).
protected override int BuildHash()
{
return System.HashCode.Combine(player.Health, player.Coins, timer);
}
Rule: Include ALL dynamic variables displayed in UI.
Update Methods
| Context | Method | Use Case |
|---|---|---|
PanelComponent |
OnUpdate() |
UI components attached to GameObjects |
Standalone Panel |
Tick() |
Custom panel classes |
// In a PanelComponent
protected override void OnUpdate()
{
// Update UI state every frame
}
// In a standalone Panel class
public override void Tick()
{
// Update UI state every frame
}
UI Elements
Vitals (Coins, Timer, Score)
<div class="vitals">
<label class="coins">@player.Coins</label>
<label class="timer">@FormatTime(timer)</label>
</div>
.vitals {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 24px;
text-stroke-size: 2px;
text-stroke-color: black;
}
}
Dynamic Health Bar
Method 1: Inline Style (Razor)
<div class="healthbar">
<div class="fill" style="width: @(HealthPercent)%;"></div>
</div>
float HealthPercent => (player.Health / player.MaxHealth) * 100f;
protected override int BuildHash()
{
return System.HashCode.Combine(player.Health);
}
Method 2: C# Reference (More Control)
<div class="healthbar">
<div @ref="HealthFill" class="fill"></div>
</div>
Panel HealthFill { get; set; }
protected override void OnUpdate()
{
var healthPercent = (player.Health / player.MaxHealth) * 100f;
HealthFill.Style.Width = Length.Percent(healthPercent);
}
Smooth Transitions:
.fill {
transition: all 0.2s ease; // Smooth drain animation
background-color: #ff0000;
}
Inventory / Hotbar
Use loops to generate slots dynamically:
<div class="hotbar">
@foreach (var item in inventory.Items)
{
<div class="slot @(item.IsActive ? "active" : "")">
<img src="@item.IconPath" />
<label>@item.Count</label>
</div>
}
</div>
.hotbar {
display: flex;
gap: 4px;
.slot {
width: 64px;
height: 64px;
background-color: rgba(0, 0, 0, 0.5);
&.active {
border: 2px solid yellow;
}
}
}
Pause Menu
Toggle Visibility:
<div class="pause-menu @(IsPaused ? "" : "hide")">
<h1>Paused</h1>
<button onclick="@Resume">Resume</button>
<button onclick="@Quit">Quit</button>
</div>
bool IsPaused { get; set; } = false;
protected override void OnUpdate()
{
if (Input.Pressed("Menu"))
{
IsPaused = !IsPaused;
Scene.TimeScale = IsPaused ? 0 : 1; // Freeze game time
}
}
void Resume()
{
IsPaused = false;
Scene.TimeScale = 1;
}
void Quit()
{
Game.Close();
}
.pause-menu {
opacity: 1;
transition: opacity 0.2s ease;
&.hide {
opacity: 0;
pointer-events: none; // Disable clicks when hidden
}
}
button {
&:hover {
sound-in: "ui.button.over"; // Play sound on hover
}
&:active {
sound-in: "ui.button.click";
}
}
Interactive Elements
Button Click Events
<button onclick="@MyFunction">Click Me</button>
<button onclick="@(() => DoSomething(5))">With Parameter</button>
void MyFunction()
{
Log.Info("Button clicked!");
}
void DoSomething(int value)
{
Log.Info($"Value: {value}");
}
CSS Audio
Play sounds directly from CSS:
button {
&:hover {
sound-in: "ui.button.over";
}
&:active {
sound-in: "ui.button.click";
}
}
Modular Components
Break UI into reusable components:
HealthBars.razor:
@using Sandbox;
@inherits PanelComponent
<div class="healthbars">
<div class="health">
<div class="fill" style="width: @HealthPercent%;"></div>
</div>
<div class="armor">
<div class="fill" style="width: @ArmorPercent%;"></div>
</div>
</div>
@code {
[Property] public Player Player { get; set; }
float HealthPercent => (Player.Health / Player.MaxHealth) * 100f;
float ArmorPercent => (Player.Armor / Player.MaxArmor) * 100f;
protected override int BuildHash()
{
return System.HashCode.Combine(Player.Health, Player.Armor);
}
}
MainHUD.razor:
@using Sandbox;
@inherits PanelComponent
<div class="hud">
<HealthBars Player="@player" />
<Vitals Player="@player" />
</div>
@code {
Player player => Scene.GetAllComponents<Player>().FirstOrDefault();
}
Practical Example: Complete HUD
@using Sandbox;
@inherits PanelComponent
<div class="hud">
<div class="vitals">
<label class="coins">💰 @player.Coins</label>
<label class="timer">⏱️ @FormatTime(timer)</label>
</div>
<div class="healthbar">
<div class="fill" style="width: @HealthPercent%;"></div>
</div>
<div class="hotbar">
@for (int i = 0; i < inventory.SlotCount; i++)
{
var item = inventory.GetSlot(i);
<div class="slot @(i == inventory.ActiveSlot ? "active" : "")">
@if (item != null)
{
<img src="@item.Icon" />
<label>@item.Count</label>
}
</div>
}
</div>
</div>
@code {
Player player => Scene.GetAllComponents<Player>().FirstOrDefault();
Inventory inventory => player?.Inventory;
float timer = 0f;
float HealthPercent => (player.Health / player.MaxHealth) * 100f;
protected override void OnUpdate()
{
timer += Time.Delta;
}
protected override int BuildHash()
{
return System.HashCode.Combine(
player.Health,
player.Coins,
inventory.ActiveSlot,
(int)timer
);
}
string FormatTime(float seconds)
{
int minutes = (int)(seconds / 60);
int secs = (int)(seconds % 60);
return $"{minutes:00}:{secs:00}";
}
}
Styling (HUD.razor.scss):
.hud {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.vitals {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 24px;
font-weight: bold;
text-stroke-size: 2px;
text-stroke-color: black;
}
}
.healthbar {
position: absolute;
bottom: 40px;
left: 20px;
width: 300px;
height: 30px;
background-color: rgba(0, 0, 0, 0.5);
.fill {
height: 100%;
background-color: #ff0000;
transition: width 0.2s ease;
}
}
.hotbar {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
.slot {
width: 64px;
height: 64px;
background-color: rgba(0, 0, 0, 0.7);
border: 2px solid rgba(255, 255, 255, 0.2);
pointer-events: all;
&.active {
border-color: yellow;
box-shadow: 0 0 10px yellow;
}
img {
width: 100%;
height: 100%;
}
label {
position: absolute;
bottom: 2px;
right: 2px;
font-size: 14px;
text-stroke-size: 1px;
text-stroke-color: black;
}
}
}
}
Common Pitfalls
Missing BuildHash Variables
// ❌ UI won't update when health changes
protected override int BuildHash()
{
return 0; // Static hash!
}
// ✅ UI updates when health changes
protected override int BuildHash()
{
return System.HashCode.Combine(player.Health);
}
Wrong Update Method
// ❌ In PanelComponent
public override void Tick() { } // Won't be called!
// ✅ In PanelComponent
protected override void OnUpdate() { }
Pointer Events
// ❌ Can't click buttons, UI blocks game interaction
.hud {
pointer-events: all; // Everything blocks clicks
}
// ✅ Only buttons are clickable
.hud {
pointer-events: none;
button {
pointer-events: all; // Only this is clickable
}
}
Null Reference
<!-- ❌ Crashes if player is null -->
<label>@player.Health</label>
<!-- ✅ Safe null check -->
<label>@(player?.Health ?? 0)</label>
CSS Properties Reference
Common Properties
| Property | Example | Use |
|---|---|---|
position |
absolute, relative |
Layout control |
display |
flex, block, none |
Display mode |
flex-direction |
row, column |
Flex layout |
gap |
8px |
Spacing between items |
transition |
all 0.2s ease |
Smooth animations |
opacity |
0 to 1 |
Transparency |
pointer-events |
none, all |
Click handling |
text-stroke-size |
2px |
Text outline width |
text-stroke-color |
black |
Text outline color |
sound-in |
"ui.click" |
Play sound on enter |
sound-out |
"ui.hover" |
Play sound on exit |
Positioning
.element {
position: absolute;
top: 20px; // Distance from top
left: 20px; // Distance from left
right: 20px; // Distance from right
bottom: 20px; // Distance from bottom
// Center horizontally
left: 50%;
transform: translateX(-50%);
}
Workflow
- Create Razor Component - Generates
.razorand.scssfiles - Add to Scene - Create Screen Panel or World Panel
- Structure HTML - Use
<div>,<label>,<button>, etc. - Add Data Binding - Use
@for C# expressions - Implement BuildHash - Include all dynamic variables
- Style with SCSS - Use flexbox, positioning, transitions
- Add Interactivity - Use
onclickfor buttons - Test Performance - Verify BuildHash prevents unnecessary redraws
Resources
- S&box Docs: Razor UI Tutorial
- Style Properties: Supported CSS Properties
- Examples: Check existing
.razorfiles in S&box projects - Video Tutorial: S&box UI Development 2024 (comprehensive walkthrough)
Quick Reference
@using Sandbox;
@inherits PanelComponent
<!-- HTML Structure -->
<div class="my-ui">
<label>@myValue</label>
<button onclick="@MyMethod">Click</button>
</div>
@code {
// C# Logic
[Property] public int MyValue { get; set; }
protected override void OnUpdate()
{
// Update logic
}
protected override int BuildHash()
{
return System.HashCode.Combine(MyValue);
}
void MyMethod()
{
Log.Info("Clicked!");
}
}
// SCSS Styling
.my-ui {
position: absolute;
display: flex;
flex-direction: column;
label {
font-size: 24px;
}
button {
&:hover {
sound-in: "ui.hover";
}
}
}