VodiSoft
Instead of scattering colours across controls, we build a theming system: design tokens, inheritable styles with Style<T>, interaction states with visual states, automatic dark mode with OnLight/OnDark, and a user-switchable accent colour with DynamicResource. All in C# markup, without a single line of XAML.

Every app starts the same way: you write BackgroundColor(Colors.Blue) on a button. Then on a second one. Then someone asks for dark mode and you have to find all 40 places that blue appears. Then they ask to let users pick their own accent colour, and the whole thing falls apart.

The fix is to stop writing colours inside controls. In this post we build a three-layer theming system:

  1. Design tokens — a single source of truth for colour
  2. Style<T> — inheritable, typed styles
  3. Theming strategiesOnLight/OnDark for the OS, DynamicResource for the user

The result is a screen where the user switches theme and accent colour instantly, and the app remembers the choice on the next launch. Everything is C# markup; the library used is FmgLib.MauiMarkup 10.2.1.

Layer 1 — Design tokens

The dullest step, and the one that pays the most: name your colours and keep them in one place.

public static class AppColors
{
    public static readonly Color Primary = "#512BD4".ToColor();
    public static readonly Color Teal    = "#0F766E".ToColor();
    public static readonly Color Amber   = "#B45309".ToColor();
    public static readonly Color Danger  = "#DC2626".ToColor();

    public static readonly Color Text     = "#111827".ToColor();
    public static readonly Color TextDark = "#F3F4F6".ToColor();
    public static readonly Color Muted    = "#6B7280".ToColor();

    public static readonly Color Surface     = "#FFFFFF".ToColor();
    public static readonly Color SurfaceDark = "#1C1C1E".ToColor();
    public static readonly Color Border      = "#E5E7EB".ToColor();
    public static readonly Color BorderDark  = "#374151".ToColor();
    public static readonly Color Gray200     = "#E5E7EB".ToColor();
    public static readonly Color Gray600     = "#4B5563".ToColor();

    public static readonly Color PageBackground     = "#F9FAFB".ToColor();
    public static readonly Color PageBackgroundDark = "#0B0B0C".ToColor();
    public static readonly Color FieldFocus         = "#EEF2FF".ToColor();
    public static readonly Color FieldFocusDark     = "#312E81".ToColor();
}

"#512BD4".ToColor() is a string extension from FmgLib.MauiMarkup. It runs on Color.Parse, so it handles hex as well as named colours like "Rebeccapurple". When you want to be explicit about the format there are ToColorFromArgb() and ToColorFromRgba() too. The extension lives in the FmgLib.MauiMarkup.Core namespace, which template-generated projects already import globally in Imports.cs.

Same job as Colors.xaml in XAML, except IntelliSense works, a typo is a build error, and “who uses this colour?” is one Find Usages away.

One rule: token names describe the role (Surface, Danger, Muted), not the colour (White, Red1). Danger can become orange tomorrow; Red1 cannot. For the same reason, turn even the values you think are used “in one place only” — page background, focus tint — into tokens. They rarely stay in one place.

Layer 2 — Style<T>

Style<T> is the typed counterpart of the XAML <Style> element. The nice part: you write setters with the same fluent methods you use on controls.

public static class AppStyles
{
    public const string AccentKey = "AccentColor";

    public static Style<Label> BodyText { get; } = new(e => e
        .FontSize(14)
        .TextColor(t => t.OnLight(AppColors.Text).OnDark(AppColors.TextDark)));

    public static Style<Label> Heading { get; } = new(BodyText, e => e
        .FontSize(24)
        .FontAttributes(FontAttributes.Bold));

    public static Style<Label> Caption { get; } = new(BodyText, e => e
        .FontSize(12)
        .TextColor(AppColors.Muted));

    public static Style<Entry> DefaultEntry { get; } = new(e => e
        .FontSize(15)
        .TextColor(t => t.OnLight(AppColors.Text).OnDark(AppColors.TextDark)));
}

new(BodyText, e => ...) is exactly XAML's BasedOn: Heading inherits everything from BodyText and only writes the difference. Change the font family once and it lands everywhere.

Implicit or named?

This distinction confuses people, but the rule is short:

public static ResourceDictionary Default { get; } = new()
{
    { AccentKey, AppColors.Primary },   // keyed value
    BodyText,                            // unkeyed style → every Label
    DefaultEntry                         // unkeyed style → every Entry
};

A style added without a key applies automatically to every control of that type. That's why Heading and Caption are not in the dictionary — they would clash with BodyText and leave the winner ambiguous. They stay as static properties and get applied where wanted:

new Label().Text("Appearance").Style(AppStyles.Heading),
new Label().Text("Subtitle text"),                        // picks up the implicit BodyText style

Attaching the dictionary is one line — in App.cs for the whole app, on a page for that page only:

this.Resources(new ResourceDictionary().MergedDictionaries(AppStyles.Default));

The dictionary is built once and stays there. You never have to rebuild it when the theme changes — the reason follows shortly.

Layer 3 — Interaction states

A static style isn't enough: a button has to look different while pressed, hovered or disabled. VisualState<T> goes straight into the style:

public static Style<Button> PrimaryButton { get; } = new(e => e
    .FontSize(15)
    .CornerRadius(10)
    .Padding(new Thickness(18, 12))
    .TextColor(Colors.White)
    .BackgroundColor(t => t.DynamicResource(AccentKey)))
{
    new VisualState<Button>(VisualStates.Button.Normal, e => e
        .Opacity(1)
        .Scale(1)),

    new VisualState<Button>(VisualStates.Button.PointerOver, e => e
        .Opacity(0.9)),

    new VisualState<Button>(VisualStates.Button.Pressed, e => e
        .Scale(0.97)),

    new VisualState<Button>(VisualStates.Button.Disabled, e => e
        .BackgroundColor(t => t.OnLight(AppColors.Gray200).OnDark(AppColors.Gray600))
        .TextColor(AppColors.Muted)),
};

public static Style<Button> DangerButton { get; } = new(PrimaryButton, e => e
    .BackgroundColor(AppColors.Danger));

Don't use magic strings for state names; constants like VisualStates.Button.Pressed exist, and a typo becomes a build error.

Always define Normal. The Visual State Manager only restores properties that some state sets. Write Scale(0.97) in Pressed without writing Scale(1) in Normal, and the button stays shrunk after the first tap. This is the single most common VSM mistake.

Note that DangerButton derives from PrimaryButton: it inherits every visual state and only changes the colour.

One-off states on a control

For states that don't deserve a style, write them on the control:

new Entry()
    .Placeholder("E-mail")
    .VisualStateGroups(
        new VisualStateGroupList
        {
            new VisualState<Entry>(VisualStates.VisualElement.Normal, e => e
                .BackgroundColor(Colors.Transparent)),

            new VisualState<Entry>(VisualStates.VisualElement.Focused, e => e
                .BackgroundColor(t => t
                    .OnLight(AppColors.FieldFocus)
                    .OnDark(AppColors.FieldFocusDark))),
        })

States written directly into a VisualStateGroupList land in the CommonStates group — which is what you want in most cases.

Dark mode: OnLight / OnDark

Inside a setter you can supply a theme pair instead of a single value:

.TextColor(t => t.OnLight(AppColors.Text).OnDark(AppColors.TextDark))

It is worth knowing what this produces, because the whole theming story depends on it: the expression does not compute a value and write it. It creates MAUI's AppThemeBinding — the same object XAML builds for {AppThemeBinding Light=… Dark=…}. MAUI pushes theme changes from the Application down the element tree, and the binding listens for them and re-evaluates itself.

In practice that means the colour updates both when the user flips the system theme and when the app sets UserAppTheme itself. No rebuilding the screen, no second Build() call, no clearing and refilling the resource dictionary.

Version note: this behaviour arrived in 10.2.1. Earlier versions resolved OnLight/OnDark once at build time, so controls already on screen did not react to a later theme change — restarting the app or rebuilding the visual tree was the only way.

The same shape works on live controls:

this.BackgroundColor(e => e
    .OnLight(AppColors.PageBackground)
    .OnDark(AppColors.PageBackgroundDark))

The rule: anything with a theme pair should come from tokens. The moment you write OnLight(Colors.White), that white has escaped the theming system.

There are sibling builders, but they work differently on purpose: OnPlatform and OnIdiom describe values that cannot change while the app runs (a phone does not turn into a tablet), so they are resolved once. The theme is the only dimension that varies over time, which is why it is the only one that needs a binding.

The user's colour: DynamicResource

OnLight/OnDark follows the operating system. But what if the user picks the accent colour themselves? That's why PrimaryButton says:

.BackgroundColor(t => t.DynamicResource(AccentKey))

StaticResource reads a value once; DynamicResource listens to the key. Change the value in the dictionary and every control using that key updates itself:

Application.Current!.Resources[AppStyles.AccentKey] = AppColors.Teal;

One line — and every primary button on screen turns green.

Switching the theme from code is one line too:

Application.Current!.UserAppTheme = AppTheme.Dark;   // AppTheme.Unspecified = follow the system

Both mechanisms work the same way: you change a value, and whatever listens updates itself. Neither requires rebuilding the dictionary or the styles.

The view model that remembers

Both settings and their persistence belong in one place. This class does three things: hold the choice, store it with Preferences, and apply it at startup.

public class ThemeViewModel : INotifyPropertyChanged
{
    const string ThemePreferenceKey = "app_theme";
    const string AccentPreferenceKey = "app_accent";

    AppTheme _theme;
    Color _accent;
    bool _isBusy;

    public ThemeViewModel()
    {
        // The user's last choice: otherwise follow the system and use the brand colour.
        _theme = (AppTheme)Preferences.Default.Get(ThemePreferenceKey, (int)AppTheme.Unspecified);
        _accent = Preferences.Default.Get(AccentPreferenceKey, AppColors.Primary.ToHex()).ToColor();

        ApplyTheme();
        ApplyAccent();
    }

    public AppTheme Theme
    {
        get => _theme;
        set
        {
            if (!Set(ref _theme, value))
                return;

            Preferences.Default.Set(ThemePreferenceKey, (int)value);
            ApplyTheme();
        }
    }

    public Color Accent
    {
        get => _accent;
        set
        {
            if (!Set(ref _accent, value))
                return;

            Preferences.Default.Set(AccentPreferenceKey, value.ToHex());
            ApplyAccent();
        }
    }

    public bool IsBusy { get => _isBusy; set => Set(ref _isBusy, value); }

    void ApplyTheme()
    {
        if (Application.Current is { } app)
            app.UserAppTheme = _theme;
    }

    void ApplyAccent()
    {
        if (Application.Current is { } app)
            app.Resources[AppStyles.AccentKey] = _accent;
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
            return false;

        field = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        return true;
    }
}

Three details matter here:

  • ApplyTheme() / ApplyAccent() in the constructor. This is the half of the feature people forget: the choice is saved but never applied at startup, so the user sets it again every launch.
  • Set returns a bool. If the value did not really change we don't touch Preferences and don't re-apply the theme — which suits MAUI too, since writing the same UserAppTheme raises no notification anyway.
  • The colour is stored with ToHex() and read back with ToColor(). Preferences only holds primitives, so the pair is a natural fit.

Outside a template-generated project the usings are: System.ComponentModel, System.Runtime.CompilerServices, FmgLib.MauiMarkup, FmgLib.MauiMarkup.Core, Microsoft.Maui.Storage.

The screen that ties it together

public class ThemePage : ContentPage, IFmgLibHotReload
{
    readonly ThemeViewModel vm = new();

    public ThemePage() => this.InitializeHotReload();

    public void Build() => this
        .BindingContext(vm)
        .Resources(new ResourceDictionary().MergedDictionaries(AppStyles.Default))
        .BackgroundColor(e => e
            .OnLight(AppColors.PageBackground)
            .OnDark(AppColors.PageBackgroundDark))
        .Content(
            new VerticalStackLayout()
            .Spacing(20)
            .Padding(24)
            .Children(
                new Label().Text("Appearance").Style(AppStyles.Heading),

                new Label().Text("Change the colour and theme of the app here."),

                BuildCard(
                    new Label().Text("Theme").Style(AppStyles.Caption),
                    new HorizontalStackLayout()
                    .Spacing(8)
                    .Children(
                        ThemeButton("System", AppTheme.Unspecified),
                        ThemeButton("Light", AppTheme.Light),
                        ThemeButton("Dark", AppTheme.Dark)
                    ),

                    new Label().Text("Accent colour").Style(AppStyles.Caption),
                    new HorizontalStackLayout()
                    .Spacing(8)
                    .Children(
                        AccentSwatch(AppColors.Primary),
                        AccentSwatch(AppColors.Teal),
                        AccentSwatch(AppColors.Amber)
                    )
                ),

                BuildCard(
                    new Label().Text("Preview").Style(AppStyles.Caption),

                    new Entry()
                        .Placeholder("E-mail")
                        .VisualStateGroups(
                            new VisualStateGroupList
                            {
                                new VisualState<Entry>(VisualStates.VisualElement.Normal, e => e
                                    .BackgroundColor(Colors.Transparent)),

                                new VisualState<Entry>(VisualStates.VisualElement.Focused, e => e
                                    .BackgroundColor(t => t
                                        .OnLight(AppColors.FieldFocus)
                                        .OnDark(AppColors.FieldFocusDark))),
                            }),

                    new Button()
                        .Text("Save")
                        .Style(AppStyles.PrimaryButton)
                        .IsEnabled(e => e
                            .Getter(static (ThemeViewModel v) => v.IsBusy)
                            .Convert((bool busy) => !busy)),

                    new Button()
                        .Text("Delete account")
                        .Style(AppStyles.DangerButton)
                )
            )
        );

    static View BuildCard(params IView[] children) =>
        new Border()
            .Stroke(e => e.OnLight(AppColors.Border).OnDark(AppColors.BorderDark))
            .StrokeThickness(1)
            .StrokeShape(new RoundRectangle().CornerRadius(14))
            .BackgroundColor(e => e.OnLight(AppColors.Surface).OnDark(AppColors.SurfaceDark))
            .Padding(16)
            .Content(
                new VerticalStackLayout()
                    .Spacing(10)
                    .Children(children));

    View ThemeButton(string text, AppTheme theme) =>
        new Button()
            .Text(text)
            .Style(AppStyles.PrimaryButton)
            .Opacity(e => e
                .Getter(static (ThemeViewModel v) => v.Theme)
                .Convert((AppTheme current) => current == theme ? 1.0 : 0.45))
            .OnClicked(b => vm.Theme = theme);

    View AccentSwatch(Color color) =>
        new Border()
            .WidthRequest(36)
            .HeightRequest(36)
            .BackgroundColor(color)
            .StrokeThickness(0)
            .StrokeShape(new RoundRectangle().CornerRadius(18))
            .GestureRecognizers(
                new TapGestureRecognizer().OnTapped((s, e) => vm.Accent = color));
}

Small helpers like BuildCard and ThemeButton are the quiet win of C# markup: reusing a card in XAML needs a ControlTemplate or a separate ContentView, while here a method does it. The selected theme button dimming is a binding too — change Theme and all three opacities update themselves.

Which tool when?

Need Tool Changes at runtime?
OS light/dark theme OnLight / OnDark Yes — creates a binding
A colour the user picks DynamicResource + Resources[key] = ... Yes — listens to the key
Per-platform / per-idiom values OnPlatform / OnIdiom No — resolved once, as it should be
A shared look for every control of a type Unkeyed (implicit) Style<T>
Variants of one type (primary/danger) Style derived with BasedOn
Pressed / focused / disabled looks VisualState<T>
Swapping a whole set (density, brand) MergedDictionaries

Common mistakes

No Normal state. Every property you change in Pressed must also be written in Normal, or the control never returns to its original look.

Adding a named style to the dictionary without a key. Add Heading unkeyed and every Label becomes a heading. Unkeyed means implicit.

Saving the setting but not applying it at startup. Preferences.Set(...) is only half of it; the value read while the view model is constructed has to be written to UserAppTheme and to the resource dictionary.

Rebuilding styles by hand on a theme change. Workarounds like Resources.MergedDictionaries.Clear() followed by Add(...) are common; they are unnecessary. OnLight/OnDark creates a binding and DynamicResource listens to its key, so both update themselves. Clearing and refilling the dictionary rebuilds every style, and calling Build() again resets scroll position and focus.

A value written on the control overrides the style. .Style(AppStyles.PrimaryButton).BackgroundColor(Colors.Green) makes green win — and visual states can no longer restore that property either. If you need an exception, derive a new style with BasedOn.

Confusing StaticResource with DynamicResource. Anything that will change at runtime has to be dynamic; a static resource is read once and never looked at again.

A raw hex instead of a token. You do it once, and six months later you are hunting that hex across 12 files.

Summary

  • Put colours in tokens, tokens in styles, styles in a dictionary; leave no raw colour inside a control.
  • Build a style hierarchy with BasedOn so writing a variant means writing the difference.
  • Interaction states live inside the style through VisualState<T>; never skip Normal.
  • OnLight/OnDark creates a binding and DynamicResource listens to a key — both repaint the screen themselves, with nothing for you to rebuild.
  • Remember to persist the choice and apply it at startup.

Once this is in place, “let's add dark mode” turns from a full morning into an afternoon — and a brand colour change becomes a one-line edit.

Related Articles

FmgLib.MauiMarkup or CommunityToolkit.Maui.Markup? An Honest Comparison

There are two ways to build a .NET MAUI UI in C# instead of XAML, and both use fluent methods. Nearly every difference between them follows from a single architectural choice: hand-curated extensions or a source generator? This post walks through the differences, when each library is the better pick, and what actually happens — measured, not guessed — when you put both in one project.

Read More

VodiSoft

Turn your software idea into a revenue-generating product

For web, mobile, .NET, SaaS and integration projects, we can map the risks, timeline and fastest path to commercial value.

Get a Quote Meeting