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
VisualElementwhen 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
| PrimitiveTheme | Intended use |
|---|---|
| Surface | Background of cards and panels |
| Container | Elevated container background |
| ContainerLow | Recessed container background |
| Text | Primary body text |
| TextVariant | Secondary / muted text and borders |
Component style tokens
| PrimitiveTheme | Intended use |
|---|---|
| Button | Button substance and state colors |
| TextField | Text-field border, fill, and caret colors |
| Slider | Track and thumb colors |
| Scrollbar | Scrollbar track and handle colors |
Design value tokens
| PrimitiveBaseTheme | Intended use |
|---|---|
| Typography | Font size, weight, and line-height definitions |
| Colors | Full color scheme used to derive the above |
| Spacing | Common spacing increments |
| Radius | Border-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-sizeCustom 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:
- Declare custom properties: Use ExtractMaybe<T, V> to define your properties, pointing them to their corresponding fields in your theme component.
- Implement a custom component: Subclass ThemeComponent
and define the fields using ThemeOptional<T>.
Make sure to assign the list of properties to the
lookupScopein the constructor. - 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:
| Flag | Meaning |
|---|---|
| Hovered | Pointer is over the element |
| Pressed | Element is being clicked or tapped |
| Focused | Element has keyboard focus |
| Selected | Two-state control is in the "on" position |
| Disabled | Element is not interactive |
| Error | Element is in an invalid state |
Button variants
HButton ships with built-in variants that map to different substance combinations:
| Variant | Treatment |
|---|---|
| Flat/ FlatTwoState | Filled, high-contrast surface |
| Soft / SoftTwoState | Softer filled treatment |
| Outline | Transparent fill with visible border |
| Ghost | Minimal, no fill or border at rest |
| TwoState | Toggleable 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();| Helper | What 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:
| Controller | Purpose |
|---|---|
| WidgetStateController | Reads and writes the active WidgetState flags externally |
| ButtonController | Controls click behavior, programmatic press, and disable state |
| SliderController | Sets 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.