Mastering Avalonia UI Styling: From Basic Custom Styles to Professional Themes

Welcome back to the Avalonia UI series! By now, today, we’re diving into one of the most exciting aspects of UI development: making your applications look stunning with custom styles and themes.

In this hands-on tutorial, we’ll transform a basic Avalonia application into a polished, professional-looking interface. Whether you’re aiming for a sleek dark theme or a vibrant custom design, this guide has you covered.

Understanding Avalonia’s Styling Architecture

Think of Avalonia’s styling system like CSS for desktop applications, but with superpowers. Here’s how it breaks down:

  • Styles: Rules that define how controls look and behave
  • Resources: Reusable values (colors, brushes, templates) stored in dictionaries
  • Themes: Complete visual packages that transform your entire application
  • Selectors: Powerful ways to target specific controls or states

The beauty of this system is its flexibility. You can start with simple property changes and evolve into complex, animated themes.

Your First Custom Style: Making Buttons Pop

Let’s start with something immediately visible – styling buttons. Open your App.axaml file and replace the existing styles section:

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="AvaloniaStyleDemo.App">
    <Application.Styles>
        <FluentTheme />
        
        <Style Selector="Button.modern-button">
            <Setter Property="Background" Value="#3B82F6"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="CornerRadius" Value="8"/>
            <Setter Property="Padding" Value="16,8"/>
            <Setter Property="FontWeight" Value="SemiBold"/>
            <Setter Property="Cursor" Value="Hand"/>
            
            <!-- Hover effect -->
            <Style Selector="^:pointerover /template/ ContentPresenter">
                <Setter Property="Background" Value="#2563EB"/>
                <Setter Property="Foreground" Value="White" />
            </Style>
            
            <!-- Pressed effect -->
            <Style Selector="^:pressed /template/ ContentPresenter">
                <Setter Property="Background" Value="#1D4ED8"/>
                <Setter Property="RenderTransform" Value="scale(0.98)"/>
            </Style>
        </Style>
    </Application.Styles>
</Application>

Now, in your MainWindow.axaml, add a button with this style:

<Button Classes="modern-button" Content="Click Me!" 
        HorizontalAlignment="Center" VerticalAlignment="Center"/>

Run the application (F5), and you’ll see a beautifully styled button with smooth hover and press effects!

Creating a Resource Dictionary: Your Style Toolbox

As your application grows, managing styles in App.axaml becomes unwieldy. Let’s create a proper resource dictionary. Right-click on your project in Rider, select “Add” → “Avalonia Resource Dictionary”, and name it StylesDictionary.axaml.

note that we are not adding style in ResourceDictionary, only resource

Here’s how to structure your resources for maximum reusability:

<ResourceDictionary xmlns="https://github.com/avaloniaui"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    
    <!-- Color Palette -->
    <Color x:Key="PrimaryColor">#3B82F6</Color>
    <Color x:Key="PrimaryHover">#2563EB</Color>
    <Color x:Key="PrimaryPressed">#1D4ED8</Color>
    <Color x:Key="BackgroundColor">#F8FAFC</Color>
    <Color x:Key="SurfaceColor">#FFFFFF</Color>
    <Color x:Key="TextPrimary">#1E293B</Color>
    <Color x:Key="TextSecondary">#64748B</Color>
    
    <!-- Brushes -->
    <SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource PrimaryColor}"/>
    <SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/>
    <SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
    
    <!-- Typography -->
    <FontFamily x:Key="PrimaryFont">Inter, Segoe UI, sans-serif</FontFamily>
    <x:Double x:Key="FontSizeSmall">12</x:Double>
    <x:Double x:Key="FontSizeNormal">14</x:Double>
    <x:Double x:Key="FontSizeLarge">18</x:Double>
    <x:Double x:Key="FontSizeXLarge">24</x:Double>
    
    <!-- Spacing -->
    <Thickness x:Key="PaddingSmall">8</Thickness>
    <Thickness x:Key="PaddingNormal">16</Thickness>
    <Thickness x:Key="PaddingLarge">24</Thickness>
    
</ResourceDictionary>

Don’t forget to include this resource dictionary in your App.axaml:

<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceInclude Source="/StylesDictionary.axaml"/>            </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
</Application.Resources>

be careful mixing Resources and Styles in the same ResourceDictionary. In Avalonia, styles need to be in a <Styles> collection, not a <ResourceDictionary>.

Building a Complete Card Component Style

Let’s create something more complex – a card component that’s popular in modern UI design:

Now Right-click on your project in Rider, select “Add” → “Avalonia Styles”, and name it Styles.axaml.

<!-- Add this to your Styles.axaml -->
<Style Selector="Border.card">
        <Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
        <Setter Property="CornerRadius" Value="12" />
        <Setter Property="Padding" Value="{StaticResource PaddingLarge}" />
        <Setter Property="BoxShadow" Value="0 0 5 0 DarkGray" />
        <Setter Property="BorderThickness" Value="1" />
        <Setter Property="BorderBrush" Value="#E2E8F0" />

        <Style Selector="^:pointerover">
            <Setter Property="BoxShadow" Value="0 0 15 0 DarkGray" />
        </Style>
    </Style>

<Style Selector="TextBlock.card-title">
    <Setter Property="FontSize" Value="{StaticResource FontSizeLarge}"/>
    <Setter Property="FontWeight" Value="Bold"/>
    <Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
    <Setter Property="Margin" Value="0,0,0,8"/>
</Style>

<Style Selector="TextBlock.card-description">
    <Setter Property="FontSize" Value="{StaticResource FontSizeNormal}"/>
    <Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
    <Setter Property="TextWrapping" Value="Wrap"/>
    <Setter Property="LineHeight" Value="20"/>
</Style>

Now you can use it in your views:

<Border Classes="card" Margin="20">
    <StackPanel>
        <TextBlock Classes="card-title" Text="Welcome to Avalonia"/>
        <TextBlock Classes="card-description" 
                   Text="This is a beautifully styled card component that demonstrates the power of Avalonia's styling system. Notice the subtle shadows and smooth hover effects."/>
        <Button Classes="primary" Content="Get Started" 
                HorizontalAlignment="Left" Margin="0,16,0,0"/>
    </StackPanel>
</Border>

Creating Theme Switcher with MVVM

Let’s implement a theme switcher using Community Toolkit MVVM. First, create a ThemeService:

using CommunityToolkit.Mvvm.ComponentModel;
using Avalonia;
using Avalonia.Markup.Xaml.Styling;
using Avalonia.Styling;

namespace AvaloniaStyleDemo.Services;

public partial class ThemeService : ObservableObject
{
    [ObservableProperty] private bool _isDarkMode;
    public string CurrentTheme => IsDarkMode ? "Dark" : "Light";

    public ThemeService()
    {
        CheckThemeChange();
    }

    public void ToggleTheme()
    {
        IsDarkMode = !IsDarkMode;
    }

    private void CheckThemeChange()
    {
        Application.Current.RequestedThemeVariant = IsDarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
        OnPropertyChanged(nameof(CurrentTheme));
    }

    partial void OnIsDarkModeChanged(bool value)
    {
        if (Application.Current != null)
        {
            CheckThemeChange();
        }
    }
}

Update your MainWindowViewModel:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AvaloniaStyleDemo.Services;

namespace AvaloniaStyleDemo.ViewModels;

public partial class MainWindowViewModel : ObservableObject
{
    private readonly ThemeService _themeService;

    [ObservableProperty]
    private string _greeting = "Welcome to Avalonia!";
    public string ThemeText => _themeService.CurrentTheme;

    public MainWindowViewModel()
    {
        _themeService = new ThemeService();
    }

    [RelayCommand]
    private void ToggleTheme()
    {
        _themeService.ToggleTheme();
        OnPropertyChanged(nameof(ThemeText));
    }
}

And in your MainWindow.axaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:AvaloniaStyleDemo.ViewModels"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="AvaloniaStyleDemo.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Title="Avalonia Style Demo">

    <Design.DataContext>
        <vm:MainWindowViewModel/>
    </Design.DataContext>

    <Grid Margin="20" RowDefinitions="Auto,*">
        <StackPanel
            Grid.Row="0"
            HorizontalAlignment="Right"
            Orientation="Horizontal">
            <TextBlock
                Margin="0,0,10,0"
                Text="{Binding ThemeText, StringFormat='{}{0} theme'}"
                VerticalAlignment="Center" />
            <ToggleSwitch Command="{Binding ChangeThemeCommand}" />
        </StackPanel>

        <ScrollViewer Grid.Row="1">
            <StackPanel Margin="0,20" Spacing="20">

                <Border Classes="card">
                    <StackPanel>
                        <TextBlock Classes="card-title" Text="{Binding Greeting}" />
                        <TextBlock Classes="card-description" Text="This application demonstrates advanced styling and theming capabilities in Avalonia UI. Try switching between light and dark themes to see how the colors adapt." />
                        <Button
                            Classes="primary"
                            Content="Styled Button"
                            HorizontalAlignment="Left"
                            Margin="0,16,0,0" />
                    </StackPanel>
                </Border>

                <Border Classes="card">
                    <StackPanel>
                        <TextBlock Classes="card-title" Text="Components Showcase" />
                        <TextBlock Classes="card-description" Text="Here are some common UI components with our custom styling:" />

                        <StackPanel
                            Margin="0,16,0,0"
                            Orientation="Horizontal"
                            Spacing="10">
                            <Button Classes="primary" Content="Primary" />
                            <Button Content="Secondary" />
                            <Button Content="Disabled" IsEnabled="False" />
                        </StackPanel>

                        <Separator Margin="0,16" />

                        <TextBox Margin="0,0,0,10" Watermark="Enter some text..." />
                        <ComboBox Margin="0,0,0,10" PlaceholderText="Select an option">
                            <ComboBoxItem Content="Option 1" />
                            <ComboBoxItem Content="Option 2" />
                            <ComboBoxItem Content="Option 3" />
                        </ComboBox>

                        <CheckBox Content="I agree to the terms" Margin="0,10" />
                        <Slider
                            Margin="0,10"
                            Maximum="100"
                            Minimum="0"
                            Value="50" />

                    </StackPanel>
                </Border>

            </StackPanel>
        </ScrollViewer>
    </Grid>
</Window>

You might have noticed that button has /template/ ContentPresenter and Border did not has that but why:

Understanding Avalonia Control Styling: Self-Rendering vs Templated Controls

Understanding why some Avalonia controls can be styled directly while others require template selectors is crucial for effective UI development. The key lies in recognizing two fundamental architectural patterns that controls follow: self-rendering and templated controls.

Self-Rendering Controls: Direct Visual Responsibility

Self-rendering controls handle their own visual appearance through an overridden Render() method. When you examine controls like Border, Rectangle, or TextBlock, you’ll find that they directly use their properties to draw themselves on screen. A Border control, for instance, immediately applies its Background, BorderBrush, and CornerRadius properties during its render cycle. This direct relationship means that styling these controls is straightforward – setting Border:pointerover with a Background property will work because the Border’s render method directly consumes that property value.

These controls typically inherit from the base Control class or Decorator and implement their visual logic internally. The render method acts as the bridge between the control’s properties and what appears on screen, making property changes immediately visible without any intermediate layers.

Templated Controls: Delegation Through Templates

Templated controls operate fundamentally differently. Controls like Button, TextBox, and CheckBox inherit from TemplatedControl or ContentControl and contain no render methods whatsoever. Instead, they delegate all visual rendering to template components defined in their control themes. When you examine a Button’s default template, you’ll discover it consists of a ContentPresenter that actually handles the visual rendering, while the Button itself only manages behavioral logic like click events, command binding, and state management.

This architectural separation creates the styling challenge. When you set a property on a templated control, that property must travel through the templating system to reach the actual rendering element. The Button’s Foreground property gets passed to its ContentPresenter via {TemplateBinding Foreground}, but the ContentPresenter is what ultimately draws the text color. If the template includes more specific selectors targeting the ContentPresenter directly, those selectors will override any properties set on the Button itself due to CSS-like specificity rules.

The Specificity Problem

The template architecture creates a specificity hierarchy where template selectors consistently win over control-level selectors. Consider a Button with a hover state: the default template includes a selector like Button:pointerover /template/ ContentPresenter#PART_ContentPresenter that directly sets the ContentPresenter’s properties. Your selector Button:pointerover only affects the Button’s properties, but since the ContentPresenter has its own more specific styling rules, the template’s rules take precedence. This isn’t a flaw in the system but rather a deliberate design that allows templates to provide consistent visual behavior while preventing local property values from breaking the intended appearance.

Practical Recognition Patterns

Identifying whether a control is self-rendering or templated becomes straightforward once you understand the patterns. Self-rendering controls typically have simple, focused purposes – drawing shapes, displaying text, or providing basic decoration. They inherit from Control or Decorator and you can often style them directly with pseudoclass selectors. Templated controls, conversely, provide complex interactive behavior and inherit from TemplatedControl or ContentControl. They require template selectors for visual properties but can be styled directly for behavioral properties like IsEnabled, layout properties like Margin, or transform properties like RenderTransform.

This architectural understanding transforms styling from guesswork into a systematic approach. Instead of trial-and-error with selectors, you can examine a control’s inheritance hierarchy and template structure to immediately know whether to target the control directly or drill down into its template components. The framework’s consistency means that once you grasp these fundamental patterns, styling any Avalonia control becomes predictable and efficient.

Linux-Specific Considerations for Avalonia Styling

When developing on Linux, keep these points in mind:

Font Rendering: Linux font rendering can differ from Windows/macOS. Test your typography choices across different Linux distributions if possible.

System Integration: Avalonia respects system themes on Linux. Your custom themes should gracefully fall back to system defaults when appropriate.

Performance: Some complex animations or effects might perform differently on Linux depending on the graphics drivers and desktop environment.

Best Practices for Maintainable Styling

As your application grows, following these practices will save you countless hours:

Organize Your Styles: Create separate resource dictionaries for different components or themes:

  • Colors.axaml – Color palette and brushes
  • Typography.axaml – Font families and text styles
  • Buttons.axaml – Button variants
  • Cards.axaml – Card and container styles

Use Consistent Naming: Adopt a naming convention like:

  • PrimaryButton, SecondaryButton, DangerButton
  • HeadingLarge, HeadingMedium, HeadingSmall
  • SurfaceColor, PrimaryColor, AccentColor

Think in Design Systems: Instead of styling individual elements, create a cohesive design system with:

  • Consistent spacing scales (8px, 16px, 24px, 32px)
  • Limited color palette (primary, secondary, accent, neutral)
  • Typographic hierarchy (h1, h2, body, caption)

Testing Your Themes Across Different Scenarios

Before shipping your styled application, test these scenarios:

  • Theme Switching: Ensure smooth transitions between light/dark modes
  • High Contrast: Test accessibility with high contrast system themes
  • Different Screen Sizes: Verify your styles work on various resolutions
  • Performance: Check animation performance with complex UIs

Common Pitfalls and How to Avoid Them

Hardcoded Values: Always use resources instead of magic numbers. Instead of Margin="16", use Margin="{StaticResource PaddingNormal}".

Overly Specific Selectors: Keep selectors simple. Button.primary is better than Window > Grid > StackPanel > Button.primary.

Forgetting State Styles: Always consider hover, pressed, disabled, and focused states for interactive elements.

Ignoring Accessibility: Ensure sufficient color contrast and keyboard navigation support.

Good Luck

Mohammed Chami
Mohammed Chami
Articles: 45

Leave a Reply

Your email address will not be published. Required fields are marked *