UI Toolkit Convention

UI Toolkit Convention

A generic convention for building runtime UI with Unity UI Toolkit: UXML documents, USS stylesheets, C# VisualElement implementations, and the folder structure of a View module. It complements csharp-convention.md — everything about C# formatting, naming, documentation, and type design defers to that document; this one adds only the UI-specific rules.

The core idea: one component = one name = one folder. Every artifact of a component — its C# type, UXML document, USS classes, element names, and asset files — derives from a single canonical component name, so finding, styling, and refactoring a component never requires guessing.


Table of Contents

  1. Core Principles
  2. The Component Unit
  3. View Module Structure
  4. Naming
  5. UXML Rules
  6. USS Rules
  7. Theme & Design Tokens
  8. C# VisualElement Implementation
  9. Assets
  10. Review Checklist

1. Core Principles

  • Separation of concerns by file type. UXML declares structure (hierarchy, names, classes). USS declares appearance (all visual styling, driven by design tokens). C# declares behavior (state, events, layout math). A style rule in the wrong layer — an inline style= in UXML, a color literal in C# — is a defect, not a shortcut.
  • Full words, always. Element names, USS classes, asset files, and icon ids are written in kebab-case with complete words: content-container, navigation-button, navigation-rail. Never abbreviate: nav-btn, bg, desc are not names.
  • Tokens over magic values. Colors, radii, spacing, and font sizes exist exactly once, as USS variables in the theme. A raw rgb(...) inside a component stylesheet is a bug waiting to fork the design.
  • The vocabulary is the source of truth. Component names come from a maintained glossary (ui-vocabulary.md) that defines each component and its responsibility. New components extend the glossary first, then get built.
  • Prototype in the Builder, settle into USS. UI Builder is a sketching tool; inline styles are fine while a component is still finding its look. Default names and hidden experiments never survive into a commit — and once a component settles, its inline styles move into USS for good.

2. The Component Unit

Every visual component lives in one folder holding all of its artifacts, named after the component in PascalCase:

ButtonCard/
├── ButtonCard.cs      // Behavior: the VisualElement subclass.
├── ButtonCard.uxml    // Structure: the element tree.
└── ButtonCard.uss     // Appearance: the component's classes, consuming theme tokens.
  • The three files always share the component's exact name.
  • A component without custom styling may omit the .uss; a component built purely in code may omit the .uxml. The pairing rule still holds: whatever artifacts exist, they carry the component name and live together.
  • Component-private assets (a one-off decorative sprite, a special material) may live in the component folder too; anything used by two or more components moves to the shared asset folders (see 9).
  • Component folders group files; they do not add namespace segments. The namespace stops at the role folder (see below): Elements/ButtonCard/ButtonCard.cs lives in MyApp.View.Elements, not MyApp.View.Elements.ButtonCard. This is the one deliberate exception to the folder-equals-namespace rule in csharp-convention.md, and it exists to avoid ButtonCard.ButtonCard qualified names.

3. View Module Structure

The View module is organized into role folders, each holding component folders or role-wide shared types. Reference layout:

MyApp.View/
├── MyApp.View.asmdef
├── ViewScope.cs             // Composition root: DI registrations for the view layer.
├── ViewController.cs        // Top-level orchestration of the view lifecycle.
├── AppShell/                // The chrome: root layers that host everything else.
│   ├── AppShell.cs / .uxml
│   ├── AppShellLayout.cs    // Shared layer types live at the role folder root.
│   ├── LayoutMetrics.cs
│   ├── ContentArea/
│   ├── NavigationShell/
│   └── SurfaceRegion/
├── Elements/                // Reusable controls, one component folder each.
│   ├── ContentCard.cs       // Family base classes at the role folder root.
│   ├── ElementsFactory.cs   // The factory for a family lives with the family.
│   ├── IElementsFactory.cs
│   ├── ButtonCard/
│   ├── IconButton/
│   └── SwitchCard/
├── Pages/                   // Screen-level compositions, one folder per page.
│   ├── PageContent.cs
│   ├── PageFactory.cs
│   ├── IPageFactory.cs
│   └── StorageSettingsPage/
├── Theme/                   // Runtime theme, design tokens, shared styles.
│   ├── RuntimeTheme.tss
│   ├── Tokens.uss
│   ├── Shared.uss
│   └── Defaults.uss
├── Fonts/                   // Shared fonts.
├── Sprites/                 // Shared sprites and icons.
└── Editor/                  // Editor-only tooling, own asmdef.

Rules behind the layout:

  • Namespaces follow role folders only: MyApp.View, MyApp.View.AppShell, MyApp.View.Elements, MyApp.View.Pages. Component folders inside them add no segments. Depth stays at one role level, per csharp-convention.md.
  • Family infrastructure lives with the family. The abstract base of a card family, the factory that creates elements, and the interfaces they implement sit at the root of the role folder — never in their own top-level folders, and never in a distant Common/ dump.
  • Composition root types sit at the module root (ViewScope, ViewController), matching the "entry-point types at module root" rule.
  • No orphan component folders at the module root. Every component belongs to a role: chrome → AppShell/, reusable control → Elements/, screen → Pages/. If a component fits no role, that is a naming/design conversation, not a reason for a root folder.

4. Naming

4.1 One canonical name, five derived forms

Each component has a single PascalCase canonical name recorded in ui-vocabulary.md. Everything else derives mechanically:

Artifact Form Example
C# type / files / folder PascalCase NavigationRail
UXML root element name kebab-case navigation-rail
USS block class kebab-case .navigation-rail
USS part class block + __ + part .navigation-rail__header
USS modifier class block + -- + state .navigation-rail--docked

4.2 Element names: full words in kebab-case

Element name attributes are lookup keys for C# and landmarks for readers of the UXML. They are kebab-case, full words, and descriptive of role — never of appearance:

Good Bad Why
content-container ContentContainer PascalCase belongs to C#, not UXML.
navigation-button nav-btn Abbreviations forbidden.
icon-image img Full words only.
name-label Label1 Role, not enumeration.
toggle-track ToggleFalse Role, not the state it represents.
(no name attribute) VisualElement Never commit default Builder names.
  • A suffix vocabulary keeps names predictable — the last word states what the element is: -container, -surface, -area, -shell, -region, -column, -header, -footer, -bar, -rail, -button, -label, -icon, -image, -group, -overlay, -track, -thumb.
  • Names are unique within their component's tree (they only need to be unambiguous for Q<T>(name) lookups scoped to the component).
  • An element gets a name only if C# queries it or it is a structural landmark. Purely decorative wrappers carry a class (or nothing) — an empty name="" attribute is removed, not committed.

4.3 USS classes: BEM with the component as block

USS classes live in a global namespace — every stylesheet on the panel can collide. The block prefix is what prevents .button from one component restyling half the app:

.button-card { }                    /* Block: the component root. */
.button-card__icon-container { }    /* Part: a child the block styles. */
.button-card__name-label { }
.button-card--pressed { }           /* Modifier: a state toggled from C#. */
  • Parts and modifiers use full words, same as element names.
  • Never create a bare generic class (.button, .label, .background) in a component stylesheet — those belong to the theme defaults or to nobody.

4.4 The vocabulary file

ui-vocabulary.md lists every component: canonical name plus a one-sentence responsibility ("NavigationRail — provides access to primary destinations in an app."). It is the naming authority: reviews reject components whose names are absent from or contradict the vocabulary. Keep it in the repository next to the convention documents.


5. UXML Rules

  • UXML declares structure; USS owns appearance — enforced by component maturity. While a component is under active visual iteration, inline style= attributes written by UI Builder are tolerated: churning half-decided values through USS classes helps nobody. The moment a component settles — its look is approved, it is reused, it rarely changes — extraction becomes mandatory: every inline style moves into the component's .uss, and reviews treat a settled component with inline styles as unfinished work.

  • Each document starts with a <Style> reference to its component stylesheet:

    <ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="False">
        <Style src="ButtonCard.uss"/>
        <ui:VisualElement name="button-card" class="button-card">
            <ui:VisualElement name="icon-container" class="button-card__icon-container">
                <ui:VisualElement name="icon-image" class="button-card__icon-image"/>
            </ui:VisualElement>
            <ui:VisualElement class="button-card__text-container">
                <ui:Label name="name-label" class="button-card__name-label" text="Button name"/>
                <ui:Label name="description-label" class="button-card__description-label" text="What this button does"/>
            </ui:VisualElement>
        </ui:VisualElement>
    </ui:UXML>
    
  • Attribute order: name, class, then everything else — the two identity attributes come first so a reader can orient instantly.

  • Custom components are referenced as custom element tags by their namespace (<MyApp.View.Elements.IconButton/>), not rebuilt inline and not <ui:Template> instances. Reserve <ui:Template>/<ui:Instance> for static, logic-free includes.

  • Placeholder text values are allowed only where they aid UI Builder preview and runtime code always overwrites them. Anything else that is preview-only — hidden experiment blocks, display: none stubs, disabled mockups — is deleted before commit.

  • Runtime documents set editor-extension-mode="False".

  • One component per .uxml file; the file contains exactly one root element, named and classed after the component.


6. USS Rules

  • Component stylesheets contain only that component's block, parts, and modifiers. Anything two components share is a theme default, a design token, or a shared behavior class — it moves to Theme/.

  • Shared behavior classes factor commonality out instead of repeating long block-prefixed rules. When several components genuinely share a look (.card-surface for every card's background and radius, .ripple-host for ripple targets), that class lives in Theme/Shared.uss and components adopt it deliberately alongside their block class. Shared classes are kebab-case full words, owned by the theme, and never defined inside a component stylesheet — this is also the sanctioned way to keep class names short where real commonality exists.

  • A component stylesheet referenced from its UXML applies to that component's subtree — but ancestor stylesheets cascade into nested components too, which is exactly how an unprefixed .icon-container in a card's sheet would leak into an icon button nested inside the card. That is why block prefixes stay mandatory for component-private classes even though every component has its own file.

  • Every color, radius, spacing, and font size is a var(--token) (see 7). Raw literals appear only in Tokens.uss:

    .button-card {
        background-color: var(--color-surface-container);
        border-radius: var(--radius-card);
        padding: var(--spacing-large);
        margin-bottom: var(--spacing-large);
        transition-duration: 0.1s;
    }
    
    .button-card__name-label {
        color: var(--color-on-surface);
        font-size: var(--font-size-title);
    }
    
    .button-card--pressed {
        background-color: var(--color-surface-container-highest);
    }
    
  • Style by class, look up by name. USS selectors use classes; #name selectors are forbidden — the name attribute belongs to C# lookups, so styles never break when behavior code renames an element, and vice versa.

  • Type selectors (Label, Button) are reserved for Theme/Defaults.uss; a component stylesheet never styles by type.

  • Keep specificity flat: single-class selectors by default. The only sanctioned nesting is a modifier driving a part: .navigation-rail--docked .navigation-rail__close-button.

  • Interaction states use pseudo-classes first (:hover, :active, :focus, :checked, :disabled); invent a --modifier class only for states the pseudo-class system cannot express, and toggle it from C# with EnableInClassList.

  • Transitions and animations are declared in USS (transition-duration, transition-property) — C# only switches the classes or writes the target values.

  • No empty rules (:root { } leftovers), no commented-out declarations, no dead selectors.

  • Font assignment (-unity-font-definition) happens once in the theme, never per component.


7. Theme & Design Tokens

The Theme/ folder is the single source of visual truth:

Theme/
├── RuntimeTheme.tss   // The theme entry point assigned to PanelSettings.
├── Tokens.uss         // All design tokens: the only file with raw values.
├── Shared.uss         // Behavior classes adopted by multiple components.
└── Defaults.uss       // Type-selector defaults: base Label, ScrollView, font.
  • RuntimeTheme.tss composes the theme:

    @import url("unity-theme://default");
    @import url("Tokens.uss");
    @import url("Shared.uss");
    @import url("Defaults.uss");
    
  • Tokens.uss defines the design system as USS variables on :root, named --<category>-<role> in full words:

    :root {
        /* Colors follow the design system's role names, not appearances. */
        --color-background: rgb(20, 19, 20);
        --color-surface-container: rgb(28, 27, 29);
        --color-on-surface: rgb(255, 255, 255);
        --color-on-surface-secondary: rgba(255, 255, 255, 0.5);
    
        --radius-card: 60px;
        --radius-icon-container: 50px;
    
        --spacing-small: 10px;
        --spacing-medium: 25px;
        --spacing-large: 50px;
    
        --font-size-title: 45px;
        --font-size-body: 30px;
    }
    
  • Token names describe role, not value: --color-on-surface-secondary, never --color-white-50. Retheming must be possible by editing Tokens.uss alone.

  • Layout constants that live in C# (reference resolutions, column widths, breakpoints) are the same idea on the code side: one LayoutMetrics static class of named constants, referenced everywhere, duplicated nowhere — including the hardcoded pixel values that tend to creep into element methods.


8. C# VisualElement Implementation

C# style — naming, docs, formatting, member order — follows csharp-convention.md. The rules below are UI Toolkit-specific.

8.1 Declaration and template loading

  • Components meant to appear in UXML or UI Builder are declared with the modern attribute API: [UxmlElement] public partial class ButtonCard : ContentCard. Code-only components (created exclusively via factories) omit the attribute.

  • The constructor loads the component's template through the project's single template provider, keyed by the type name, then clones it into itself and takes its block class:

    public ButtonCard()
    {
        VisualTreeAsset template = UITemplates.Get(nameof(ButtonCard));
        template.CloneTree(this);
        AddToClassList(BlockClassName);
        // ...queries and callbacks follow.
    }
    

    The provider's backing mechanism (a direct-reference catalog, an addressable group, …) is a project decision the convention does not fix. It only requires that the provider is one place, synchronous, keyed by nameof(...), and editor-safe — element constructors run in edit mode too, so UI Builder can instantiate and preview the component via drag and drop.

  • Author-time configurable properties get [UxmlAttribute] so they are editable in UI Builder.

8.2 Query once, cache forever

Element lookups happen exactly once, in the constructor, into readonly fields. Members never call Q<T>() per access — repeated queries walk the hierarchy every time and scatter the name strings:

[UxmlElement]
public partial class ButtonCard : ContentCard
{
    private const string BlockClassName = "button-card";
    private const string PressedClassName = "button-card--pressed";
    private const string NameLabelName = "name-label";
    private const string DescriptionLabelName = "description-label";

    private readonly Label _nameLabel;
    private readonly Label _descriptionLabel;

    public ButtonCard()
    {
        VisualTreeAsset template = UITemplates.Get(nameof(ButtonCard));
        template.CloneTree(this);
        AddToClassList(BlockClassName);

        _nameLabel = this.Q<Label>(NameLabelName);
        _descriptionLabel = this.Q<Label>(DescriptionLabelName);

        RegisterCallback<ClickEvent>(clickEvent => Clicked());
    }

    public string Name
    {
        get => _nameLabel.text;
        set => _nameLabel.text = value;
    }
}
  • Every element name used in Q<T>() is a private const string — one place to update when the UXML changes, and the compiler-visible twin of the UXML name attribute.
  • Class names toggled from C# are constants too.

8.3 Styling from C#: classes for state, style API for geometry

  • Visual states are class switches, so their appearance stays in USS:

    public bool Pressed
    {
        set => EnableInClassList(PressedClassName, value);
    }
    
  • Direct style writes are reserved for computed geometry: measured offsets, uniform scales, percent-based translations, animated targets — values that genuinely come from runtime math and cannot be a static class. A color, radius, or padding assigned from C# is styling in the wrong layer.

8.4 Events and lifecycle

  • Events are named as past-tense facts without prefixes: Clicked, ValueChanged, CloseRequested. Initialize with an empty delegate so raising never needs a null check: public event Action Clicked = () => { };. Handler methods are named On<Subject><Event>: OnCloseButtonClicked.

  • External subscriptions are bound to panel presence. Anything that outlives the element (an event bus, a service) is subscribed in AttachToPanelEvent and unsubscribed in DetachFromPanelEvent, symmetrically — otherwise the subscriber holds the element alive forever:

    public NavigationAppBar()
    {
        RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
        RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
    }
    
    private void OnAttachToPanel(AttachToPanelEvent attachEvent) => _eventBus.Subscribe<LayoutChangedEvent>(this);
    
    private void OnDetachFromPanel(DetachFromPanelEvent detachEvent) => _eventBus.Unsubscribe<LayoutChangedEvent>(this);
    
  • Every RegisterCallback has a matching UnregisterCallback when the subscription target can outlive the element or be replaced (see the app-bar swap pattern).

  • Layout math that reacts to GeometryChangedEvent is debounced through the element scheduler (schedule.Execute(...) resumed as a one-shot) so multiple triggers in one frame collapse into a single pass.

8.5 Separation and construction

  • Elements are views. They expose properties and events; they never call storage, services, or navigation directly. Pages and controllers compose elements and connect them to injected dependencies.
  • Elements and pages are constructed via factories registered in the composition root (ViewScope); dependencies arrive through injection, never through service location inside an element.
  • The composition root registers per family with section comments, transient for elements, singleton for infrastructure — and is the only place that knows the DI container exists.

9. Assets

  • Asset file names are kebab-case full words: file-folder.png, question-mark.png, inter-variable.ttf. The same rule as element names, for the same reason.
  • Icon ids used in code and icon libraries are kebab-case full words: arrow-forward, lock-in-goals — an id is a name, not a compressed token.
  • Shared sprites live in Sprites/, shared fonts in Fonts/. A sprite referenced by a single component may live in that component's folder until a second consumer appears.
  • All template and asset lookups go through one provider — a catalog of direct references, an addressable group, whichever mechanism the project chooses. Code never hardcodes asset paths, and lookup keys derive from type names via nameof(...) wherever a type exists to name them.
  • Assets that are used repeatedly (materials for effects, frequently cloned templates) are resolved once and cached — a per-click or per-frame asset load is a defect.

10. Review Checklist

Use this as a quick gate before merging (or as a compact contract in a prompt):

Structure

  • [ ] Component artifacts (.cs, .uxml, .uss) share one name and one folder.
  • [ ] Component sits under its role folder (AppShell/, Elements/, Pages/) — no orphan folders at the module root.
  • [ ] Namespace equals module + role folder; component folders add no segments.
  • [ ] Family bases and factories live at the role folder root.

Naming

  • [ ] Component name exists in ui-vocabulary.md and matches its meaning.
  • [ ] Element names are kebab-case full words ending in a vocabulary suffix (-container, -button, -label, …); no abbreviations, no PascalCase, no Builder defaults, no empty name="".
  • [ ] USS classes are BEM with the component block prefix; no bare generic classes.

UXML

  • [ ] Settled components contain no inline style= attributes; a <Style> reference points at the component USS.
  • [ ] name first, class second in attribute order.
  • [ ] No hidden prototype content or display:none stubs; placeholder text only where runtime overwrites it.
  • [ ] Reusable pieces referenced as custom element tags, not rebuilt inline.

USS

  • [ ] Every color/radius/spacing/font-size is a var(--token); raw values only in Tokens.uss.
  • [ ] Cross-component commonality lives as shared classes in Theme/Shared.uss, not copy-pasted between component sheets.
  • [ ] No #name selectors; no type selectors outside theme defaults; specificity flat.
  • [ ] States via pseudo-classes or --modifier classes; transitions declared in USS.
  • [ ] No empty or dead rules; fonts assigned only in the theme.

C#

  • [ ] [UxmlElement] only on Builder-facing components; template loaded through the single template provider keyed by nameof.
  • [ ] All Q<T>() lookups happen once in the constructor into readonly fields; element/class names are private const string.
  • [ ] State styling via EnableInClassList; direct style writes only for computed geometry.
  • [ ] Events past-tense with empty-delegate initializers; external subscriptions bound to attach/detach; callbacks unregistered symmetrically.
  • [ ] Elements stay dumb views; construction via factories; DI only in the composition root. Everything else per csharp-convention.md.

Assets

  • [ ] Asset files and icon ids kebab-case full words; shared assets in Sprites//Fonts/.
  • [ ] Asset lookups go through the single provider; repeated loads resolved once and cached, never per-click or per-frame.