Theming & Modifiers

Apply primitive theme tokens, substance layers, and composable modifier helpers to style HELIX widgets.

HELIX styling is made up of two complementary layers:

  • Theme values flow down the widget tree through HThemeProvider providers and are resolved at build time from BuildContext.
  • Modifiers attach directly to a widget and apply style or behavior deltas to the underlying VisualElement when the widget is mounted or reconciled.

Neither layer requires you to write USS directly.


Theme providers

Wrap any subtree in HThemeProvider to override one or more theme components for everything inside:

return new HThemeProvider(
  components: new List<ThemeComponent> {
    new PrimitiveBaseThemeComponent {
      colors = PrimitiveColorScheme.From(MaterialColors.Blue, Brightness.Dark)
    }
  }
) {
  new SettingsPanel()
}.Fill();

Descendants resolve values from the nearest provider. If no provider supplies a value, HELIX falls back to global/default theme values.

You can stack multiple providers to override different components at different levels of the tree. For example, an app-level palette provider at the root with a per-dialog radius override deeper in.

Resolving tokens in Build

Inside any Build method, use GetThemed<T> to read a resolved value and subscribe to changes automatically:

public override Widget Build(BuildContext context) {
  var surface    = context.GetThemed(PrimitiveTheme.Surface);
  var text       = context.GetThemed(PrimitiveTheme.Text);
  var typography = context.GetThemed(PrimitiveBaseTheme.Typography);

  return new HBox(background: surface) {
    new HText("Hello").Body(context)
  }.Padding(16);
}

When you call GetThemed<T>, HELIX registers the property as a dependency. If the active theme changes that value, the element schedules a rebuild automatically.


Primitive tokens

The universal widgets use a set of primitive theme tokens for colors, typography, spacing, border radius, and component styles. These tokens give you a single place to change the look of an entire app.

Color tokens

PrimitiveThemeIntended use
SurfaceBackground of cards and panels
ContainerElevated container background
ContainerLowRecessed container background
TextPrimary body text
TextVariantSecondary / muted text and borders

Component style tokens

PrimitiveThemeIntended use
ButtonButton substance and state colors
TextFieldText-field border, fill, and caret colors
SliderTrack and thumb colors
ScrollbarScrollbar track and handle colors

Design value tokens

PrimitiveBaseThemeIntended use
TypographyFont size, weight, and line-height definitions
ColorsFull color scheme used to derive the above
SpacingCommon spacing increments
RadiusBorder-radius values for inputs, cards, and dialogs

Typography helpers on HText

HText extension methods resolve typography from the active theme and apply it in one call:

new HText("Section title").Heading(context);   // large, prominent
new HText("Body copy").Body(context);           // standard reading size
new HText("Field label").Caption(context);      // small, secondary
new HText("Hero text").Display(context);        // largest, display-size

Custom Themes

While HELIX ships with a sensible default theme, you can customize almost any visual aspect. There are three levels of customization: generating a dynamic color palette, overriding individual widget tokens, and defining entirely new theme properties for your own custom widgets.

Seed-based color generation

HELIX uses a Material 3-style color palette generator. Instead of manually specifying values for every background, container, and text color, you can generate a cohesive scheme from a single seed color.

Pass a seed color and a target Brightness to From. This produces a unified color scheme, which you can then pass to a PrimitiveBaseThemeComponent inside an HThemeProvider:

using System.Collections.Generic;
using HELIX.Types;
using HELIX.Widgets;
using HELIX.Widgets.Theming;
using HELIX.Widgets.Universal;
using HELIX.Widgets.Universal.Theme;
using UnityEngine;

public class CustomThemeRoot : StatelessWidget<CustomThemeRoot> {
  public override Widget Build(BuildContext context) {
    // Generate a cohesive dark theme palette using a custom violet seed color
    var myColorScheme = PrimitiveColorScheme.From(
      seedColor: new Color(0.5f, 0.2f, 0.9f),
      brightness: Brightness.Dark
    );

    return new HThemeProvider(
      components: new List<ThemeComponent> {
        new PrimitiveBaseThemeComponent {
          colors = myColorScheme
        }
      }
    ) {
      new MyMainContent()
    }.Fill();
  }
}

Overriding individual tokens

If you only want to customize specific elements (such as the default button style or the surface background color), you can assign overrides directly to a PrimitiveThemeComponent.

Because component fields use the ThemeOptional<T> wrapper, they implicitly convert from their raw types. Any property you leave unassigned will automatically fall back to the defaults of the parent scope:

var myOverriddenTheme = new PrimitiveThemeComponent {
  // Override specific color tokens directly
  surface = new Color(0.05f, 0.05f, 0.05f),
  text = Color.white,

  slider = new HSliderStyle { /* Customization */ }
};

return new HThemeProvider(
  components: new List<ThemeComponent> { myOverriddenTheme }
) {
  new MyMainContent()
};

Creating new theme properties for custom widgets

If you are building custom application-level widgets and want them to use their own scoped styling tokens, you can extend the system by registering new properties.

To define a custom theme:

  1. Declare custom properties: Use ExtractMaybe<T, V> to define your properties, pointing them to their corresponding fields in your theme component.
  2. Implement a custom component: Subclass ThemeComponent and define the fields using ThemeOptional<T>. Make sure to assign the list of properties to the lookupScope in the constructor.
  3. Resolve the properties in build: Retrieve the scoped value using context.GetThemed(...).

Here is a complete example of a custom game HUD theme:

using System.Collections.Generic;
using HELIX.Widgets;
using HELIX.Widgets.Theming;
using UnityEngine;

// 1. Declare the theme properties
public static class HudTheme {
  public static readonly ThemeProperty<Color> HealthBarColor = ThemeProperty.ExtractMaybe(
    "hud-health-bar-color",
    HudThemeComponent.Default,
    component => component.healthBarColor
  );

  public static readonly ThemeProperty<Color> ManaBarColor = ThemeProperty.ExtractMaybe(
    "hud-mana-bar-color",
    HudThemeComponent.Default,
    component => component.manaBarColor
  );

  public static readonly IReadOnlyList<ThemeProperty> Properties = new ThemeProperty[] {
    HealthBarColor, ManaBarColor
  };
}

// 2. Define the custom theme component
public class HudThemeComponent : ThemeComponent {
  public static readonly HudThemeComponent Default = new() {
    healthBarColor = Color.green,
    manaBarColor = Color.blue
  };

  public ThemeOptional<Color> healthBarColor;
  public ThemeOptional<Color> manaBarColor;

  public HudThemeComponent() {
    lookupScope = HudTheme.Properties;
  }
}

// 3. Resolve the properties in a custom widget
public class StatusBars : StatelessWidget<StatusBars> {
  public override Widget Build(BuildContext context) {
    Color hpColor = context.GetThemed(HudTheme.HealthBarColor);
    Color mpColor = context.GetThemed(HudTheme.ManaBarColor);

    return new HColumn(gap: 8) {
      new HBox(background: hpColor).Size(width: 200, height: 16),
      new HBox(background: mpColor).Size(width: 200, height: 16)
    };
  }
}

Computed theme values

Sometimes a theme value needs to be dynamically derived from other theme properties. For example, text contrast colors might be computed based on the active background color, or a button's highlight outline might depend on the primary brand color.

In HELIX, you can define computed values by calling the Compute method on a ThemeProperty<T>.

Inside a .Compute(...) lambda, you receive the current ThemeProviderElement as a provider parameter. When resolving dependencies, you should always call the Get<T> extension method on the target property (e.g. MyThemeProperty.Get(provider)) rather than invoking provider.GetThemed(...) directly.

In certain scenarios (such as diagnostics generation, initial setup, or offline rendering), the provider passed to the compute lambda can be null. The extension method handles null by falling back to the global default values of the property.

Here is an example showing how the built-in Surface color is computed from Colors:

public static readonly ThemeProperty<Color> Surface = ThemeProperty.ExtractMaybe(
  "primitive-c-surface",
  PrimitiveThemeComponent.Default,
  component => component.surface
).StyleLoader().Compute(provider => {
  // Always resolve dependencies via the Get() extension method
  PrimitiveColorScheme colorScheme = PrimitiveBaseTheme.Colors.Get(provider);
  return colorScheme.surface.main;
});

Substances

Button, slider, text-field, and prompt styles are built on SubstanceLayers, which are visual materials that respond to WidgetState flags:

FlagMeaning
HoveredPointer is over the element
PressedElement is being clicked or tapped
FocusedElement has keyboard focus
SelectedTwo-state control is in the "on" position
DisabledElement is not interactive
ErrorElement is in an invalid state

Button variants

HButton ships with built-in variants that map to different substance combinations:

VariantTreatment
Flat/ FlatTwoStateFilled, high-contrast surface
Soft / SoftTwoStateSofter filled treatment
OutlineTransparent fill with visible border
GhostMinimal, no fill or border at rest
TwoStateToggleable selected/unselected control

Pass a concrete HButtonStyle to HButton when you need fully custom behavior beyond the built-in variants.


Modifiers

Modifier objects are small, composable pieces of style or behavior that are attached to a widget and applied as deltas during reconciliation. When a modifier disappears on a rebuild, HELIX automatically resets the properties that modifier owned.

Fluent helpers

The fluent API covers the most common cases and chains naturally:

new HBox(background: MaterialColors.Red) {
  new HText("Alert")
}.Padding(12).Margin(8).Size(height: 48).Clip();
HelperWhat it does
Fill<T>Flex grow, flex shrink, width: 100%
Shrink<T>Flex shrink only
Tight<T>Collapses flex growth
TightStretch<T>Tight + cross-axis stretch
Expand<T>Flex grow only (fills remaining space)
Size<T>Sets preferred, min, and/or max size
Positioned<T>Absolute position with inset overrides
Stretch<T>Cross-axis stretch on a positioned element
Padding<T>Inner spacing
Margin<T>Outer spacing
Display<T>Show/hide without removing from layout
Visibility<T>Show/hide with layout preserved
Opacity<T>Alpha value
Clip<T>overflow: hidden
Const<T>Reconciliation retention hint

Explicit modifier instances

Pass an explicit modifiers array when the fluent style is less readable or when you need types not covered by a helper:

new HColumn(
  modifiers: new Modifier[] {
    PaddingModifier.Of(16),
    BackgroundStyleModifier.Of(context.GetThemed(PrimitiveTheme.Surface)),
    BorderModifier.Of(Border.All(1, context.GetThemed(PrimitiveTheme.TextVariant)))
  }
) {
  new HText("Panel")
};

Modifier uniqueness

A widget can hold at most one non-fallback modifier of each type. This keeps reconciliation deterministic. Each modifier owns a single slice of element state, so there is no ambiguity about which modifier wins.

Fallback modifiers are defaults applied by the widget itself, such as the implicit flex-fill that StatefulWidget uses. A user modifier of the same type always replaces its fallback counterpart, so overriding built-in layout behavior is as simple as chaining .Shrink() or .Size(...).


Widget state controllers

Interactive widgets expose controller objects when external coordination is needed:

ControllerPurpose
WidgetStateControllerReads and writes the active WidgetState flags externally
ButtonControllerControls click behavior, programmatic press, and disable state
SliderControllerSets slider value and observes drag state

Use controllers when:

  • Multiple widgets must share the same interaction state.
  • State is owned outside the widget tree (for example in a game system or a service).
  • Another system needs to imperatively enable, disable, focus, or update an element.

Without a controller, interactive widgets create and own one internally. You only need to reach for a controller when external coordination is genuinely required.

On this page