The design system is a stack of independent USS files imported by a single master stylesheet, plus one runtime helper script. This document walks through the layers and the load-bearing decisions behind them.
DesignSystemRuntime.cs ← C# helper, auto-attaches to UIDocument
│
▼
DesignSystem.uss ← master, @imports the layers below
│
├── DesignTokens.uss ← :root variables only — no element rules
├── Typography.uss ← .ds-h1 / .ds-h2 / .ds-h3 / .ds-body-1 / .ds-caption
├── Icons.uss ← .ds-icon base + 63 .ds-icon--<name> + parent-state cascade
├── Buttons.uss ← .ds-btn variants, sizes, icon button
├── Inputs.uss ← .ds-input, .ds-search, .ds-dropdown, .ds-textarea
├── TabsAndFilters.uss ← .ds-tabs, .ds-tab, .ds-view-toggle
├── Cards.uss ← .ds-animal-card, .ds-info-row, .ds-swatch-row
├── Navigation.uss ← .ds-side-nav, .ds-side-rail, .ds-bottom-nav, .ds-profile
├── Badges.uss ← .ds-badge, .ds-tag, .ds-chip, .ds-avatar, .ds-notif-*
├── Controls.uss ← .ds-toggle, .ds-check, .ds-radio, .ds-slider, .ds-range, scoped scrollbars
├── Overlays.uss ← .ds-modal, .ds-dialog, .ds-toast, .ds-sheet, .ds-empty
├── Feedback.uss ← .ds-progress, .ds-spinner, .ds-skeleton, .ds-pagination, showcase helpers
└── Mobile.uss ← .mobile-prefixed overrides — loaded LAST
A consumer attaches one stylesheet (DesignSystem.uss) to their UIDocument. The @import chain pulls everything else.
DesignTokens.uss declares CSS custom properties on :root. Every other file references them via var(--…):
/* DesignTokens.uss */
:root {
--color-primary: #22C55E;
--space-3: 12px;
}
/* Buttons.uss */
.ds-btn {
background-color: var(--color-primary);
padding-left: var(--space-3);
}To re-theme: override :root in a higher-priority stylesheet attached after the design system:
/* MyTheme.uss — attach after DesignSystem.uss on the same UIDocument */
:root {
--color-primary: #FF6B35; /* warm orange */
--color-bg: #FAFAFA; /* light theme */
--color-text-primary: #111827;
}No need to touch Buttons.uss or any other file. The cascade re-paints automatically.
To switch themes at runtime, scope the overrides under a class instead of :root and toggle the class on .ds-root:
/* In a stylesheet loaded after DesignSystem.uss */
.theme-light {
--color-primary: #16A34A;
--color-bg: #F8FAFC;
--color-surface: #FFFFFF;
--color-text-primary: #0F172A;
--color-text-secondary: #475569;
/* ...the rest of the token set... */
}// Toggle from any handler
toggle.RegisterValueChangedCallback(evt => {
if (evt.newValue) root.AddToClassList("theme-light");
else root.RemoveFromClassList("theme-light");
});The whole tree picks up the new values via the var() cascade. To make the swap animate (it's instant by default — children don't inherit transitions), pair the override with a universal transition rule:
.ds-root,
.ds-root * {
transition-property: background-color, color, border-color, border-top-color, border-right-color, border-bottom-color, border-left-color, -unity-background-image-tint-color;
transition-duration: 240ms;
transition-timing-function: ease-in-out;
}This is exactly how the showcase's day / night toggle works — see Assets/Showcase/Resources/ShowcaseTheme.uss for the reference implementation.
Some elements have a coloured background that doesn't change when the theme flips, but their text colour does. The notification dot is the canonical example: .ds-notif-dot is always red (the danger token), but .ds-notif-dot__count uses var(--color-text-primary) which inverts to dark slate under a light theme — black text on red. Re-route in your theme override:
.theme-light .ds-notif-dot__count {
color: var(--color-text-on-accent); /* white in the light theme */
}Same pattern applies to any "always-coloured surface, theme-aware text" pair.
Unity 6's clone-time StyleVariableResolver NREs when it encounters var(...) inside a UXML style="..." attribute. The crash surfaces as "The UXML file set for the UIDocument could not be cloned." Author colour and dimension overrides as USS classes:
<!-- WRONG — crashes Unity 6 on clone -->
<ui:VisualElement style="background-color: var(--color-surface);" />
<!-- RIGHT — class drives the var() resolution at USS parse time -->
<ui:VisualElement class="ds-card-surface" />.ds-card-surface { background-color: var(--color-surface); }Inline var(...) works fine inside USS files themselves — it's only the inline UXML attribute that breaks.
Unity 6 USS clamps border-radius per axis to half the side length. A naive border-radius: 999px on a non-square element renders as an ellipse, not a CSS-style pill. The token set therefore ships explicit pill-radius values:
--radius-pill-9: 9px; /* 18×18 (toggle knob, slider thumb) */
--radius-pill-12: 12px; /* 24×24 (chip, tag, card check) */
--radius-pill-16: 16px; /* 32×32 (spinner, profile avatar) */
--radius-pill-20: 20px; /* 40×40 (avatar-md) */
...Each token equals half the height of an element it's designed for. A 24-px chip uses --radius-pill-12; a 40-px avatar uses --radius-pill-20. Picking the right token is your responsibility — the comment on each token names its intended consumers.
USS specificity ties resolve by source order. When two rules with the same specificity target the same element, the rule loaded later wins. The system uses this deliberately:
/* Icons.uss — loaded 3rd */
.ds-icon { width: 20px; height: 20px; }
/* Inputs.uss — loaded 5th, AFTER Icons.uss */
.ds-search__icon { width: 18px; height: 18px; }A <VisualElement class="ds-icon ds-icon--search ds-search__icon"> element gets the 18×18 size from .ds-search__icon, not the 20×20 from .ds-icon, because Inputs.uss loads later.
This is the pattern by which "general icon" rules are specialised by per-component slot rules without writing higher-specificity selectors.
Mobile.uss is intentionally loaded last so its .mobile-prefixed overrides always win. If you reorder the imports, the responsive pass breaks first.
A common request: "an icon inside a hovered button should retint." The naive solution is per-component :hover .icon rules, multiplied across every consumer. The system instead ships one cascade in Icons.uss:
.ds-btn:hover .ds-icon,
.ds-tab:hover .ds-icon,
.ds-nav-item:hover .ds-icon,
.ds-rail-item:hover .ds-icon,
.ds-bottom-nav__item:hover .ds-icon,
.ds-pagination__btn:hover .ds-icon,
.ds-stepper__btn:hover .ds-icon {
-unity-background-image-tint-color: var(--color-text-primary);
}A new interactive container can opt into the cascade by adding its selector to the list. The icon picks up hover / active / pressed / disabled tints automatically, no per-consumer rule needed.
Filled-background controls (primary / secondary / tertiary / danger buttons, active tabs) override the cascade with on-accent ink so the glyph stays legible against the bg fill:
.ds-btn--primary .ds-icon,
.ds-btn--secondary .ds-icon,
.ds-btn--tertiary .ds-icon,
.ds-btn--danger .ds-icon {
-unity-background-image-tint-color: var(--color-text-on-accent);
}Soft-bg controls (.ds-nav-item.is-active, .ds-rail-item.is-active, .ds-bottom-nav__item.is-active) keep their primary-tinted glyph via per-__icon rules in their own files. The cascade list intentionally does not include these, to avoid double-overriding what their own file specifies.
DesignSystemRuntime.cs is auto-attached via two hooks:
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void RegisterAutoAttach() {
SceneManager.sceneLoaded += OnSceneLoaded;
}
static void OnSceneLoaded(Scene scene, LoadSceneMode mode) {
AttachToAllUIDocuments(); // iterate, AddComponent if missing
}Every UIDocument in every scene gets a DesignSystemRuntime MonoBehaviour. The runtime then:
- Injects toggle knobs. Unity's
Toggleelement doesn't render the iOS-style sliding pill — its checkmark is a square box. The runtime queries.ds-toggle .unity-toggle__inputand adds a.ds-toggle__knobchild if one isn't already present. Idempotent. Runs once at attach time and re-scans every 250 ms to cover toggles cloned in lazily by screen managers (e.g. a Settings panel that creates its toggles on first open). - Drives spinner rotation. USS
transitioncan't loop natively. The runtime incrementstransform.rotateby 6° every frame on.ds-spinner.is-spinningelements. - Animates skeleton shimmer.
.ds-skeletonplaceholders get a child.ds-skeleton__shimmerelement which slides viatranslatefrom -100 % to +100 % across a 1.4 s loop.
Two helpers are exposed public static so screen managers can call them eagerly after a template clone:
DesignSystemRuntime.EnsureToggleKnobs(root);
DesignSystemRuntime.EnsureSkeletonShimmers(root);This avoids the one-frame "flat pill" flash on the very first appearance of a screen, while the periodic re-scan still picks up anything cloned later.
The boundary is intentional:
| Concern | Lives in |
|---|---|
| Colours, spacing, sizing, radii, motion timing | USS (tokens) |
| Layout — flex, position, alignment | USS |
| State variants — hover / active / disabled / .is-active | USS pseudo-classes + state classes |
| State transitions that USS can express | USS transition |
| State transitions that USS can't loop or animate | C# (runtime) — spinner rotation, skeleton shimmer |
| DOM-shape requirements Unity can't author in UXML | C# (runtime) — toggle knob injection |
| Localised text, dynamic image sources, click handlers | Consumer C# (your screen manager) |
If you find yourself writing C# to set a colour, the colour belongs in a token. If you find yourself writing USS for a click handler, you've taken a wrong turn.
.mobile is a single class added to your screen root. Every responsive rule lives in Mobile.uss and is prefixed with .mobile:
/* Buttons.uss */
.ds-btn { height: 36px; }
/* Mobile.uss */
.mobile .ds-btn { height: 48px; } /* same selector, .mobile prefix */The selector .mobile .ds-btn has higher specificity (0,2,0) than .ds-btn (0,1,0), so the mobile rule always wins when the screen root carries .mobile.
Pattern across the system:
- Buttons / inputs / tabs grow to 48 px tall (touch target minimum).
- Slider thumbs grow 18 px → 24 px with recomputed
margin-top: -12pxfor centring. - Modals widen, side rails compact, bottom-nav bar takes over from side-nav.
You toggle the class once at screen build time:
if (PanelSettingsHelper.IsMobileLayout()) // your own platform check
root.AddToClassList("mobile");Same UXML, same component classes — the layout flips.
If a new component family doesn't fit Cards.uss or Overlays.uss:
- Create
<Family>.ussnext to the existing layer files. - Use only
var(--…)token references. - Append to
DesignSystem.uss's@importchain — typically beforeMobile.uss. - Add
.mobileoverrides for the new family inMobile.uss.
The @import order is the single source of truth for cascade. Don't try to control order via specificity tricks; let the import sequence carry it.
Unity's UI Toolkit ScrollView is a compound element with internal classes outside the .ds- namespace. The system styles them in Controls.uss, scoped to .ds-root so the rules don't leak into editor windows or other UI Toolkit panels in the same project:
.ds-root .unity-scroller__low-button,
.ds-root .unity-scroller__high-button { display: none; }
.ds-root .unity-scroll-view__vertical-scroller { width: 8px; }
.ds-root .unity-scroll-view__horizontal-scroller { height: 8px; }
.ds-root .unity-base-slider__tracker {
background-color: transparent;
border-width: 0;
}
.ds-root .unity-base-slider__dragger {
background-color: var(--color-border-strong);
border-radius: 4px;
border-width: 0;
transition-property: background-color;
transition-duration: var(--transition-fast);
}
.ds-root .unity-base-slider__dragger:hover {
background-color: var(--color-text-secondary);
}Both axes share the same thumb pattern. The thumb auto-themes through --color-border-strong and --color-text-secondary, so a theme override repaints the scrollbar with no extra rules.
Why scoped to .ds-root instead of the universal .unity-base-slider__dragger? The same internal classes are used by Unity's editor windows, the Inspector, the UI Builder, and any other UI Toolkit panel in the project. A naked .unity-base-slider__dragger { … } rule would re-skin every scrollbar in every editor tool — not what consumers want when dropping the design system into an existing project.
The repo doubles as a host Unity project that builds the live web demo. The host pieces are deliberately separated from the drop-in design system folder so consumers can copy Assets/DesignSystem/ into their own project and leave the host behind:
Assets/
├── DesignSystem/ ← drop-in design system (copy this into your project)
├── Showcase/ ← host scene + supporting scripts (host-only)
│ ├── Showcase.unity
│ ├── Resources/
│ │ ├── ShowcaseTheme.uss ← .theme-light token override + universal transition
│ │ ├── UnityDefaultRuntimeTheme.tss
│ │ └── sinanata.jpg ← profile photo for the AVATAR section
│ └── Runtime/
│ ├── ShowcaseBootstrap.cs
│ └── ShowcaseDocOverlay.cs
├── Editor/BuildCli.cs ← Unity batchmode entry for WebGL builds
└── WebGLTemplates/ShowcaseTemplate/
└── index.html ← custom template (touch-action, viewport-fit, dark loading bar)
[RuntimeInitializeOnLoadMethod(AfterSceneLoad)] static method that runs once per Play / build start. It:
- Loads the showcase UXML and the default Unity TSS via
Resources.Load. - Creates two GameObjects, each with a
UIDocument. TheirPanelSettingsare also created in code (scaleMode = ConstantPixelSize,themeStyleSheet = UnityDefaultRuntimeTheme, sortingOrder 0 for the showcase doc, 1 for the doc-overlay) — no.assetfiles to maintain. - Loads the showcase-only
ShowcaseTheme.ussviaResources.Load<StyleSheet>and adds it to the showcase root'sstyleSheetslist. Loaded after the design system stylesheet (which the UXML imports first) so its rules win specificity ties. - Wires the day / night toggle, the GitHub / Steam promo links (
Application.OpenURL), and adds.mobileto the showcase root whenScreen.width < 768. - Calls
DesignSystemRuntime.AttachToAllUIDocuments()so the spinner / knob / shimmer runtime catches the bootstrap-created documents —SceneManager.sceneLoadedfires before ourAfterSceneLoadinit in some Unity versions.
A MonoBehaviour attached to the second UIDocument (the higher-sortingOrder one). It builds an inspection panel programmatically (no UXML) and listens for PointerMoveEvent / PointerDownEvent on the showcase root.
When the cursor moves:
- Walk up
evt.target.parentchain. - Stop on the first element that carries a
ds-*class which is not in the container excludelist (ds-section,ds-row,ds-swatch-row,ds-section__title,ds-root). - Also short-circuit if any ancestor carries
.showcase-chrome— that's the promo banner, which uses real ds-* classes for visual consistency but isn't a component the visitor came to inspect. - Render the chain in a floating panel anchored next to the leaf (or full-bottom on mobile). Click the panel to pin / unpin. Click the leaf-classes line to copy the class list via
GUIUtility.systemCopyBuffer. - If the cursor sits on only-containers (or chrome) for 2 s, fade the panel + outline highlight via inline
transition-property: opacity(240 ms ease-out) and remove them from the picking tree until the cursor returns to a real component.
The overlay's root is pickingMode = Ignore so showcase events still reach the showcase document below; only the inspection panel itself is pickingMode = Position to capture pin / copy clicks.
Three things, all showcase-only:
- A universal opacity / colour transition rule on
.ds-root, .ds-root *so theme swaps animate. - A
.theme-lightblock that redefines every colour token fromDesignTokens.uss. - Targeted overrides like
.theme-light .ds-notif-dot__count { color: var(--color-text-on-accent); }for elements whose colour-on-coloured-background pairing breaks under a naive theme swap.
This file is loaded only by the showcase bootstrap. Consumers copying Assets/DesignSystem/ into their own project don't get it — they author their own theme overrides scoped to their own classes.
The host project builds with Tools/Build/Build-Showcase.ps1 — a Windows-only PowerShell orchestrator that mirrors the production-tested Leap of Legends Build-All.ps1 pattern:
- Preflight (Unity exe found, stale
Temp/UnityLockfileremoved). - Optional cache clear (
-ClearCachefor stale-Burst recovery). - Unity batchmode invocation with live progress display from
DisplayProgressbar:log markers. - JSON build report (
-cliReportPathflag honoured byAssets/Editor/BuildCli.cs::BuildWebGL) so the orchestrator validates success without scraping the log. - Unity process-tree cleanup on Ctrl+C / error so orphan AssetImportWorkers don't keep
Temp/UnityLockfileheld. - Burst-AOT cache auto-retry on the specific
bcl.exe exit 3 + empty stderrpattern. - Native NTSTATUS crash-code labels (
STATUS_ACCESS_VIOLATIONetc.) when Unity crashes below the C# layer. - Optional
-Serve(local HTTP server) and-Deploy(single-commit force-push togh-pagesviagit worktree).
Full docs in Tools/Build/README.md.