USS Theming — ScriptableObjects, Variables, Runtime Switching
Approach: Swap full StyleSheet assets for major themes + patch CSS custom properties for user tweaks
Designer control: Colours, typography, spacing, animation
Persistence: PlayerPrefs
Note: Code samples are demonstrative — not production-complete
System Overview
UITheme (ScriptableObject)
│ stores all designable values as serialized fields
│ inspector-editable, no code required for designers
│
▼
UIThemePreset (USS asset, one per theme)
│ declares :root custom properties matching the ScriptableObject fields
│ applied as the base stylesheet on the Panel Settings
│
▼
ThemeManager (MonoBehaviour singleton)
│ swaps StyleSheet assets for theme changes
│ patches individual custom properties for user tweaks
│ writes preferences to PlayerPrefs
│
▼
Component USS files (buttons.uss, panels.uss, typography.uss …)
│ consume var(--token-name) throughout
│ never hardcode colour or size values
The key principle: USS files never contain hardcoded values. Every colour, size, duration, and font reference goes through a custom property (var(--token)). The theme system controls what those tokens resolve to. Swapping a theme changes everything in one operation.
Design Tokens — the Token Naming Convention
All custom properties follow a three-level naming convention:
--category-role-variant
--color-primary base primary colour
--color-primary-hover primary on hover
--color-primary-disabled primary when disabled
--color-surface panel background
--color-surface-raised card / elevated surface
--color-on-surface text sitting on a surface
--color-on-primary text sitting on a primary button
--size-text-body body text size
--size-text-label label text size
--size-text-title-sm small title
--size-text-title-lg large title
--space-xs 4px
--space-sm 8px
--space-md 16px
--space-lg 24px
--space-xl 40px
--radius-sm 4px
--radius-md 8px
--radius-lg 16px
--radius-pill 999px
--duration-fast 0.1s
--duration-normal 0.25s
--duration-slow 0.4s
--ease-out cubic-bezier(0.0, 0.0, 0.2, 1)
Naming things this way means a designer can read any USS rule and know exactly what token to change to affect it, without reading C# code.
UITheme ScriptableObject
The ScriptableObject is the designer-facing interface. Every property maps to a CSS custom property token. Field names mirror token names to make the mapping obvious.
[CreateAssetMenu(fileName = "NewTheme", menuName = "Training App/UI Theme")]
public class UITheme : ScriptableObject
{
[Header("Meta")]
public string ThemeName = "Default";
public StyleSheet StyleSheetAsset; // the USS file to swap in
[Header("Colours — Primary")]
public Color ColorPrimary = new Color(0.27f, 0.51f, 0.95f);
public Color ColorPrimaryHover = new Color(0.38f, 0.60f, 1.00f);
public Color ColorPrimaryDisabled = new Color(0.27f, 0.51f, 0.95f, 0.4f);
public Color ColorOnPrimary = Color.white;
[Header("Colours — Surface")]
public Color ColorBackground = new Color(0.06f, 0.06f, 0.10f);
public Color ColorSurface = new Color(0.10f, 0.12f, 0.18f);
public Color ColorSurfaceRaised = new Color(0.14f, 0.17f, 0.24f);
public Color ColorOnSurface = new Color(0.90f, 0.90f, 0.95f);
public Color ColorOnSurfaceMuted = new Color(0.55f, 0.57f, 0.65f);
[Header("Colours — Semantic")]
public Color ColorSuccess = new Color(0.20f, 0.80f, 0.50f);
public Color ColorWarning = new Color(0.95f, 0.75f, 0.20f);
public Color ColorError = new Color(0.95f, 0.35f, 0.35f);
[Header("Colours — Glass / Overlay")]
[Range(0f, 1f)]
public float GlassFillAlpha = 0.12f;
[Range(0f, 1f)]
public float GlassBorderAlpha = 0.15f;
[Header("Typography")]
public Font BodyFont;
public float SizeTextBody = 16f;
public float SizeTextLabel = 13f;
public float SizeTextCaption = 11f;
public float SizeTextTitleSm = 20f;
public float SizeTextTitleLg = 32f;
[Header("Spacing")]
public float SpaceXS = 4f;
public float SpaceSM = 8f;
public float SpaceMD = 16f;
public float SpaceLG = 24f;
public float SpaceXL = 40f;
[Header("Border Radius")]
public float RadiusSM = 4f;
public float RadiusMD = 8f;
public float RadiusLG = 16f;
[Header("Animation")]
public float DurationFast = 0.1f;
public float DurationNormal = 0.25f;
public float DurationSlow = 0.4f;
}
Designers create theme assets via Assets → Create → Training App → UI Theme, fill in the inspector, and hand the asset to a developer to register. No code is touched.
ThemeRegistry ScriptableObject
A single asset that lists all available themes. Referenced by ThemeManager and the settings dropdown. Designers add themes here by dragging assets in — no code required.
[CreateAssetMenu(fileName = "ThemeRegistry", menuName = "Training App/Theme Registry")]
public class ThemeRegistry : ScriptableObject
{
public UITheme DefaultTheme;
public List<UITheme> AllThemes = new();
public UITheme GetByName(string name)
=> AllThemes.Find(t => t.ThemeName == name) ?? DefaultTheme;
public List<string> GetThemeNames()
=> AllThemes.ConvertAll(t => t.ThemeName);
}
USS Structure — Component Files
USS files are split by concern. They never hardcode values — everything goes through tokens.
base-variables.uss
This file is the fallback. It declares every token with a default value so the UI is never broken if a theme hasn't been applied yet. Loaded first in Panel Settings.
:root {
/* Colours */
--color-primary: rgb(69, 130, 242);
--color-primary-hover: rgb(97, 153, 255);
--color-primary-disabled: rgba(69, 130, 242, 0.4);
--color-on-primary: rgb(255, 255, 255);
--color-background: rgb(15, 15, 26);
--color-surface: rgb(26, 31, 46);
--color-surface-raised: rgb(36, 43, 64);
--color-on-surface: rgb(230, 230, 242);
--color-on-surface-muted: rgb(140, 145, 166);
--color-success: rgb(51, 204, 128);
--color-warning: rgb(242, 191, 51);
--color-error: rgb(242, 89, 89);
/* Typography */
--size-text-body: 16px;
--size-text-label: 13px;
--size-text-caption: 11px;
--size-text-title-sm: 20px;
--size-text-title-lg: 32px;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-pill: 999px;
/* Animation */
--duration-fast: 0.1s;
--duration-normal: 0.25s;
--duration-slow: 0.4s;
}
theme-dark.uss / theme-high-contrast.uss
One file per theme. Overrides only the tokens that differ from the default. Swapping this file in is the entire theme change operation.
/* theme-high-contrast.uss — only overrides what changes */
:root {
--color-primary: rgb(255, 255, 0);
--color-primary-hover: rgb(255, 255, 153);
--color-on-primary: rgb(0, 0, 0);
--color-background: rgb(0, 0, 0);
--color-surface: rgb(20, 20, 20);
--color-on-surface: rgb(255, 255, 255);
--color-on-surface-muted: rgb(200, 200, 200);
}
buttons.uss
.btn-primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
font-size: var(--size-text-label);
transition-property: background-color, scale;
transition-duration: var(--duration-fast), var(--duration-fast);
transition-timing-function: ease-out;
}
.btn-primary:hover {
background-color: var(--color-primary-hover);
scale: 1.03 1.03;
}
.btn-primary:active {
scale: 0.97 0.97;
}
.btn-primary:disabled {
background-color: var(--color-primary-disabled);
opacity: 0.6;
}
.btn-secondary {
background-color: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
border-width: 1px;
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
font-size: var(--size-text-label);
transition-property: border-color, color;
transition-duration: var(--duration-fast);
}
.btn-secondary:hover {
border-color: var(--color-primary-hover);
color: var(--color-primary-hover);
}
.btn-ghost {
background-color: transparent;
color: var(--color-on-surface-muted);
border-width: 0;
padding: var(--space-sm);
border-radius: var(--radius-sm);
transition-property: color, background-color;
transition-duration: var(--duration-fast);
}
.btn-ghost:hover {
color: var(--color-on-surface);
background-color: rgba(255, 255, 255, 0.06);
}
.btn-tab {
background-color: transparent;
color: var(--color-on-surface-muted);
border-width: 0 0 2px 0;
border-color: transparent;
border-radius: 0;
padding: var(--space-sm) var(--space-md);
font-size: var(--size-text-label);
transition-property: color, border-color;
transition-duration: var(--duration-fast);
}
.btn-tab--active {
color: var(--color-primary);
border-color: var(--color-primary);
}
panels.uss
.panel-root {
flex-grow: 1;
background-color: var(--color-background);
padding: var(--space-lg);
}
.panel-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom-width: 1px;
border-color: rgba(255, 255, 255, 0.08);
}
.panel-body {
flex-grow: 1;
gap: var(--space-md);
}
.panel-footer {
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top-width: 1px;
border-color: rgba(255, 255, 255, 0.08);
}
.glass-panel {
background-color: rgba(255, 255, 255, 0.08);
border-width: 1px;
border-color: rgba(255, 255, 255, 0.12);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.card {
background-color: var(--color-surface-raised);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-sm);
transition-property: background-color;
transition-duration: var(--duration-fast);
}
.card:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.card--locked {
opacity: 0.45;
}
.divider {
height: 1px;
background-color: rgba(255, 255, 255, 0.08);
margin: var(--space-md) 0;
}
.progress-track {
height: 6px;
background-color: var(--color-surface-raised);
border-radius: var(--radius-pill);
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--color-primary);
border-radius: var(--radius-pill);
transition-property: width;
transition-duration: var(--duration-slow);
}
typography.uss
.title-large {
font-size: var(--size-text-title-lg);
color: var(--color-on-surface);
-unity-font-style: bold;
letter-spacing: 0.5px;
}
.title-medium {
font-size: var(--size-text-title-sm);
color: var(--color-on-surface);
-unity-font-style: bold;
}
.body-text {
font-size: var(--size-text-body);
color: var(--color-on-surface);
}
.label-text {
font-size: var(--size-text-label);
color: var(--color-on-surface);
-unity-font-style: bold;
}
.caption-text {
font-size: var(--size-text-caption);
color: var(--color-on-surface-muted);
}
.input-label {
font-size: var(--size-text-caption);
color: var(--color-on-surface-muted);
margin-bottom: var(--space-xs);
-unity-font-style: bold;
letter-spacing: 0.5px;
text-transform: uppercase; /* Note: check Unity 6.3 text-transform support */
}
inputs.uss
.input-group {
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.input-field {
background-color: var(--color-surface-raised);
border-color: rgba(255, 255, 255, 0.12);
border-width: 1px;
border-radius: var(--radius-md);
color: var(--color-on-surface);
padding: var(--space-sm) var(--space-md);
font-size: var(--size-text-body);
transition-property: border-color;
transition-duration: var(--duration-fast);
}
.input-field:focus {
border-color: var(--color-primary);
}
.input-field--error {
border-color: var(--color-error);
}
/* Slider track */
.input-field .unity-base-slider__tracker {
background-color: var(--color-surface-raised);
border-radius: var(--radius-pill);
height: 4px;
}
/* Slider fill */
.input-field .unity-base-slider__dragger {
background-color: var(--color-primary);
border-radius: var(--radius-pill);
width: 18px;
height: 18px;
margin-top: -7px;
border-width: 0;
}
/* Tab bar */
.tab-bar {
flex-direction: row;
border-bottom-width: 1px;
border-color: rgba(255, 255, 255, 0.08);
margin-bottom: var(--space-md);
}
animations.uss
/* Panel entry — add class on Show(), remove on Hide() */
.fade-in {
opacity: 1;
transition-property: opacity;
transition-duration: var(--duration-normal);
}
.hidden {
display: none;
}
/* Step list items */
.step-item {
flex-direction: row;
align-items: center;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
gap: var(--space-sm);
color: var(--color-on-surface-muted);
transition-property: color, background-color;
transition-duration: var(--duration-fast);
}
.step-item--active {
color: var(--color-on-surface);
background-color: rgba(255, 255, 255, 0.05);
}
.step-item--complete {
color: var(--color-success);
}
/* Reduced motion support — add to root element when user opts in */
.reduced-motion * {
transition-duration: 0s !important;
}
USS Load Order in Panel Settings
Order matters — later files override earlier ones for the same selector.
Panel Settings → Style Sheets (loaded in this order):
1. base-variables.uss ← token defaults, never removed
2. theme-dark.uss ← swapped for theme changes
3. typography.uss ← consumes tokens
4. panels.uss
5. buttons.uss
6. inputs.uss
7. animations.uss
Only slot 2 changes at runtime. Everything else stays loaded.
ThemeManager
Handles stylesheet swapping and individual token patching. The patching path is used for user tweaks (e.g. a custom accent colour) that don't warrant a full theme swap.
public class ThemeManager : MonoBehaviour
{
public static ThemeManager Instance { get; private set; }
[SerializeField] private ThemeRegistry _registry;
[SerializeField] private PanelSettings _panelSettings; // drag in from inspector
private UITheme _activeTheme;
private StyleSheet _activeThemeSheet;
// ── Lifecycle ──────────────────────────────────────────────────────────
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
var savedTheme = PlayerPrefs.GetString("ThemeName", _registry.DefaultTheme.ThemeName);
Apply(_registry.GetByName(savedTheme));
}
// ── Public API ─────────────────────────────────────────────────────────
// Called by SettingsHost when a theme is selected
public void Apply(string themeName)
=> Apply(_registry.GetByName(themeName));
public void Apply(UITheme theme)
{
if (theme == null) return;
SwapThemeStyleSheet(theme);
PatchTokensFromTheme(theme);
_activeTheme = theme;
PlayerPrefs.SetString("ThemeName", theme.ThemeName);
}
// Patch a single colour token — for user accent colour customisation
public void PatchColor(string token, Color color)
{
var root = GetPanelRoot();
if (root == null) return;
root.style.SetCustomProperty(token, color);
}
public void SetHighContrast(bool on)
{
var root = GetPanelRoot();
if (root == null) return;
root.EnableInClassList("high-contrast", on);
PlayerPrefs.SetInt("HighContrast", on ? 1 : 0);
}
public void SetReducedMotion(bool on)
{
var root = GetPanelRoot();
if (root == null) return;
root.EnableInClassList("reduced-motion", on);
PlayerPrefs.SetInt("ReducedMotion", on ? 1 : 0);
}
// ── Internal ──────────────────────────────────────────────────────────
private void SwapThemeStyleSheet(UITheme theme)
{
if (theme.StyleSheetAsset == null) return;
var root = GetPanelRoot();
if (root == null) return;
// Remove previous theme sheet if present
if (_activeThemeSheet != null && root.styleSheets.Contains(_activeThemeSheet))
root.styleSheets.Remove(_activeThemeSheet);
// Insert theme sheet at index 1 (after base-variables, before components)
root.styleSheets.Insert(1, theme.StyleSheetAsset);
_activeThemeSheet = theme.StyleSheetAsset;
}
private void PatchTokensFromTheme(UITheme theme)
{
var root = GetPanelRoot();
if (root == null) return;
// Colours
root.style.SetCustomProperty("--color-primary", theme.ColorPrimary);
root.style.SetCustomProperty("--color-primary-hover", theme.ColorPrimaryHover);
root.style.SetCustomProperty("--color-primary-disabled", theme.ColorPrimaryDisabled);
root.style.SetCustomProperty("--color-on-primary", theme.ColorOnPrimary);
root.style.SetCustomProperty("--color-background", theme.ColorBackground);
root.style.SetCustomProperty("--color-surface", theme.ColorSurface);
root.style.SetCustomProperty("--color-surface-raised", theme.ColorSurfaceRaised);
root.style.SetCustomProperty("--color-on-surface", theme.ColorOnSurface);
root.style.SetCustomProperty("--color-on-surface-muted", theme.ColorOnSurfaceMuted);
root.style.SetCustomProperty("--color-success", theme.ColorSuccess);
root.style.SetCustomProperty("--color-warning", theme.ColorWarning);
root.style.SetCustomProperty("--color-error", theme.ColorError);
// Spacing
root.style.SetCustomProperty("--space-xs", new Length(theme.SpaceXS, LengthUnit.Pixel));
root.style.SetCustomProperty("--space-sm", new Length(theme.SpaceSM, LengthUnit.Pixel));
root.style.SetCustomProperty("--space-md", new Length(theme.SpaceMD, LengthUnit.Pixel));
root.style.SetCustomProperty("--space-lg", new Length(theme.SpaceLG, LengthUnit.Pixel));
root.style.SetCustomProperty("--space-xl", new Length(theme.SpaceXL, LengthUnit.Pixel));
// Typography
root.style.SetCustomProperty("--size-text-body", new Length(theme.SizeTextBody, LengthUnit.Pixel));
root.style.SetCustomProperty("--size-text-label", new Length(theme.SizeTextLabel, LengthUnit.Pixel));
root.style.SetCustomProperty("--size-text-caption", new Length(theme.SizeTextCaption, LengthUnit.Pixel));
root.style.SetCustomProperty("--size-text-title-sm", new Length(theme.SizeTextTitleSm, LengthUnit.Pixel));
root.style.SetCustomProperty("--size-text-title-lg", new Length(theme.SizeTextTitleLg, LengthUnit.Pixel));
// Radius
root.style.SetCustomProperty("--radius-sm", new Length(theme.RadiusSM, LengthUnit.Pixel));
root.style.SetCustomProperty("--radius-md", new Length(theme.RadiusMD, LengthUnit.Pixel));
root.style.SetCustomProperty("--radius-lg", new Length(theme.RadiusLG, LengthUnit.Pixel));
// Animation
root.style.SetCustomProperty("--duration-fast", new TimeValue(theme.DurationFast, TimeUnit.Second));
root.style.SetCustomProperty("--duration-normal", new TimeValue(theme.DurationNormal, TimeUnit.Second));
root.style.SetCustomProperty("--duration-slow", new TimeValue(theme.DurationSlow, TimeUnit.Second));
}
private VisualElement GetPanelRoot()
=> _panelSettings?.visualTree?.rootVisualElement;
}
SetCustomProperty Extension
Unity 6.3 doesn't yet expose a fully clean public API for setting arbitrary custom properties on a IStyle. This extension method handles the current workaround using the inline style override path. Verify against your Unity version — this API surface is actively evolving.
public static class StyleExtensions
{
public static void SetCustomProperty(this IStyle style, string property, Color value)
{
// Unity 6.x: custom property patching on VisualElement inline styles
// The exact API depends on Unity version — check release notes for your build.
// This is the pattern used in Unity's own samples as of 6.3.
var colorValue = new StyleColor(value);
style.SetCustomProperty(property, colorValue.ToString());
}
public static void SetCustomProperty(this IStyle style, string property, Length value)
{
style.SetCustomProperty(property, value.ToString());
}
public static void SetCustomProperty(this IStyle style, string property, TimeValue value)
{
style.SetCustomProperty(property, $"{value.value}s");
}
}
UserPreferences ScriptableObject
Separate from theme presets — this holds the user's personal overrides and accessibility flags. Saved to PlayerPrefs as JSON.
[CreateAssetMenu(fileName = "UserPreferences", menuName = "Training App/User Preferences")]
public class UserPreferences : ScriptableObject
{
[Header("Theme")]
public string SelectedTheme = "Default";
[Header("Accessibility")]
public bool HighContrast = false;
public bool ReducedMotion = false;
[Header("Custom Accent")]
public bool UseCustomAccent = false;
public Color CustomAccent = new Color(0.27f, 0.51f, 0.95f);
private const string PrefsKey = "UserPreferences";
public void Save()
{
PlayerPrefs.SetString(PrefsKey, JsonUtility.ToJson(this));
PlayerPrefs.Save();
}
public void Load()
{
if (PlayerPrefs.HasKey(PrefsKey))
JsonUtility.FromJsonOverwrite(PlayerPrefs.GetString(PrefsKey), this);
}
public void Apply(ThemeManager themeManager)
{
themeManager.Apply(SelectedTheme);
themeManager.SetHighContrast(HighContrast);
themeManager.SetReducedMotion(ReducedMotion);
if (UseCustomAccent)
themeManager.PatchColor("--color-primary", CustomAccent);
}
}
Wiring It into the Settings Host
The Settings Host now connects the View's events to both the theme system and the preferences object.
public class SettingsPanelHost : BasePanelHost, IPokeTarget
{
[SerializeField] private UserPreferences _prefs;
public event Action OnBackRequested;
private SettingsPanelView _view;
public override void Generate()
{
base.Generate();
_view = new SettingsPanelView(Root);
_view.OnBackClicked += () => OnBackRequested?.Invoke();
_view.OnThemeChanged += name =>
{
_prefs.SelectedTheme = name;
_prefs.Save();
ThemeManager.Instance.Apply(name);
};
_view.OnHighContrastChanged += on =>
{
_prefs.HighContrast = on;
_prefs.Save();
ThemeManager.Instance.SetHighContrast(on);
};
_view.OnMasterVolumeChanged += value =>
{
_prefs.MasterVolume = value; // add to UserPreferences if needed
AudioManager.SetMaster(value);
};
Hide();
}
public override void Show()
{
_view?.SetValues(
AudioManager.Master,
AudioManager.Music,
AudioManager.SFX,
_prefs.SelectedTheme,
_prefs.HighContrast
);
base.Show();
}
public void OnPoked(string buttonId) => _view?.HandlePoke(buttonId);
}
Designer Workflow
With this setup, a designer can:
-
Create a new theme: Assets → Create → Training App → UI Theme, fill in the inspector colour pickers and sliders, drag the corresponding USS file into the StyleSheetAsset field.
-
Register the theme: Drag the new asset into the
ThemeRegistry.AllThemeslist. It will appear automatically in the settings dropdown. -
Adjust spacing or type scale: Open any theme asset, change the spacing or font size values, hit play. No code touched.
-
Test in editor: ThemeManager.Apply() can be called from a custom inspector button (or just hit play with the desired theme asset set as DefaultTheme).
The USS files themselves are still designer-editable for fine-grained control — but because they only contain var(--token) references, changing a value in the token system propagates everywhere without hunting through component files.
File Structure
Assets/
├── Settings/
│ ├── ThemeRegistry.asset
│ ├── UserPreferences.asset
│ └── Themes/
│ ├── Theme_Default.asset
│ ├── Theme_Dark.asset
│ ├── Theme_Light.asset
│ └── Theme_HighContrast.asset
│
└── UI/
├── Styles/
│ ├── base-variables.uss ← token defaults
│ ├── theme-dark.uss ← theme overrides (one per theme)
│ ├── theme-high-contrast.uss
│ ├── typography.uss
│ ├── panels.uss
│ ├── buttons.uss
│ ├── inputs.uss
│ └── animations.uss
│
└── Scripts/
├── UITheme.cs
├── ThemeRegistry.cs
├── UserPreferences.cs
├── ThemeManager.cs
└── StyleExtensions.cs