Build Scalable Desktop Apps with Avalonia: Multi-Page UI, Shared Layout, and State Management

Freeman MadudiliFreeman Madudili
11 min read

Building multi-page UIs in Avalonia can feel a bit messy at first. You’ve got your ViewModels, UserControls, and a ContentControl to swap views — but keeping things clean, reactive, and scalable takes a bit of structure.

In this walkthrough, I’ll show you how I set up a proper multi-page layout using Avalonia UI and CommunityToolkit.Mvvm. We’ll use the dependency injection container, shared state, and a layout shell that keeps things modular and easy to maintain.

This guide assumes you already know the basics of .NET and Avalonia, and want to take your app structure a bit further. Maybe you’re working on a real desktop app, or just want to move past scattered views and copy-pasted code.

We’ll build a small demo project with:

  • A shared layout shell (header, nav, content)

  • Page switching using ContentControl and a ViewLocator

  • A shared config service injected into every page

  • Navigation state and styling to reflect the current view

  • A structure that can grow into something much bigger

Everything will be built from scratch and tested along the way.
Let’s get into it.


Scaffolding the Project

Before anything else, let’s set up the project using Avalonia’s official MVVM template. This gives us a clean starting point with Views, ViewModels, and a working ViewLocator.

Create the Project

Open your terminal and run:

dotnet new avalonia.mvvm -n AvaloniaMultiPageStarter
cd AvaloniaMultiPageStarter

This scaffolds a .NET app with:

  • Avalonia UI + Fluent theme

  • Basic MVVM structure

  • CommunityToolkit.Mvvm already installed

  • A ViewLocator that maps ViewModels to matching Views

If you don’t have the Avalonia templates installed, run this first:

dotnet new install Avalonia.Templates

More on that here: Avalonia Getting Started Guide


Step 2: Understand the Structure

Your project should now look like this:

AvaloniaMultiPageStarter/
├── App.axaml / App.axaml.cs
├── MainWindow.axaml / MainWindow.axaml.cs
├── ViewModels/
│   └── MainWindowViewModel.cs
├── Views/
│   └── MainWindow.axaml
├── ViewLocator.cs

We’ll refactor this soon to create a proper layout shell and pages. But for now, you can run the default template with:

dotnet run

You should see a basic window with a counter button. That’s your starting point.

Next, we’ll create a layout shell with a header, sidebar navigation, and a dynamic content area for pages.


Building the Layout Shell

Now that we’ve scaffolded the project, let’s turn the window into a layout shell — something that stays consistent across pages, like a root layout.

We’ll include:

  • A top header bar

  • A sidebar with navigation buttons

  • A dynamic content area in the middle
    This structure will let us switch pages cleanly while keeping the layout intact.

Rename and Reorganize

We’re treating the main window as a layout shell, so rename:

  • MainWindow.axamlShellView.axaml

  • MainWindowViewModel.csShellViewModel.cs

  • Inside each file, also rename the classes to ShellView and ShellViewModel

This is just a naming change for clarity. We’ll wire up real navigation next.

File structure update:

Views/
└── Layout/
    └── ShellView.axaml
ViewModels/
└── Layout/
    └── ShellViewModel.cs

Create the folders if needed. This keeps things organized as the app grows.

Build the Layout UI

Let’s add a header, a sidebar, and a content area using DockPanel.
Open ShellView.axaml and replace the content with:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="clr-namespace:AvaloniaMultiPageStarter.ViewModels.Layout"
        x:Class="AvaloniaMultiPageStarter.Views.Layout.ShellView"
        x:DataType="vm:ShellViewModel"
        Width="900" Height="600"
        Title="Avalonia Multi-Page UI">

    <DockPanel>

        <!-- Header -->
        <Border DockPanel.Dock="Top" Background="#333" Padding="12">
            <TextBlock Text="🧩 Avalonia Multi-Page UI" Foreground="White" FontSize="18"/>
        </Border>

        <!-- Navigation Sidebar -->
        <StackPanel DockPanel.Dock="Left" Background="#f2f2f2" Width="180" Margin="10" Spacing="8">
            <Button Content="Home" Command="{Binding ShowHomeCommand}" />
            <Button Content="Settings" Command="{Binding ShowSettingsCommand}" />
            <Button Content="About" Command="{Binding ShowAboutCommand}" />
        </StackPanel>

        <!-- Page Content -->
        <ContentControl Content="{Binding CurrentViewModel}" Margin="12"/>
    </DockPanel>
</Window>

How it works

  • The DockPanel places the header and sidebar where they belong

  • The ContentControl will load whichever page ViewModel is set to CurrentViewModel

  • Page switching is handled entirely in your ViewModel

Inside the ViewModel: ShellViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AvaloniaMultiPageStarter.ViewModels.Pages;

namespace AvaloniaMultiPageStarter.ViewModels.Layout;

public partial class ShellViewModel : ViewModelBase
{
    [ObservableProperty]
    private ViewModelBase? currentViewModel;

    public ShellViewModel()
    {
        ShowHome();
    }

    [RelayCommand]
    private void ShowHome()
    {
        CurrentViewModel = new HomeViewModel();
    }

    [RelayCommand]
    private void ShowSettings()
    {
        CurrentViewModel = new SettingsViewModel();
    }

    [RelayCommand]
    private void ShowAbout()
    {
        CurrentViewModel = new AboutViewModel();
    }
}

(We’ll create those page ViewModels shortly. For now, they can be placeholders.)

Run the app

dotnet run

You should see:

  • A grey header bar

  • Sidebar with 3 buttons

  • A content area (currently empty)

We have not setup navigation yet but the root layout is in place.

Next up, we’ll wire up real views, use the built-in ViewLocator, and get navigation working cleanly.


ViewLocator and Dynamic Views

With our layout shell in place, it’s time to render actual pages inside the content area. In Avalonia, this is usually done with a ContentControl and something called a ViewLocator.

What is a ViewLocator?

The ViewLocator maps your ViewModel to the matching View.
It looks at the class name (like HomeViewModel) and tries to load a UserControl called HomeView.

This is already included in the Avalonia MVVM template, so you don’t need to write it from scratch. You’ll find it in ViewLocator.cs.

Here’s what it looks like:

public class ViewLocator : IDataTemplate
{
    public Control? Build(object? data)
    {
        if (data is null)
            return null;

        var name = data.GetType().FullName!.Replace("ViewModel", "View");
        var type = Type.GetType(name);

        if (type != null)
            return (Control)Activator.CreateInstance(type)!;

        return new TextBlock { Text = "Not Found: " + name };
    }

    public bool Match(object? data)
    {
        return data is ViewModelBase;
    }
}

So when you do this in your XAML:

<ContentControl Content="{Binding CurrentViewModel}" />

Avalonia will:

  1. Take the CurrentViewModel

  2. Ask ViewLocator to find the matching View

  3. Create it and render it in the layout

Create the Pages

We’ll now add three pages: HomeView, SettingsView, and AboutView, and place them in Views/Pages/.

Create these files with matching namespaces and class names.

Folder structure:

Views/
└── Pages/
    ├── HomeView.axaml
    ├── SettingsView.axaml
    └── AboutView.axaml

HomeView.axaml

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="AvaloniaMultiPageStarter.Views.Pages.HomeView"
             xmlns:vm="clr-namespace:AvaloniaMultiPageStarter.ViewModels.Pages"
             x:DataType="vm:HomeViewModel">
    <Grid>
        <TextBlock Text="🏠 Home Page" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
    </Grid>
</UserControl>

Create similar files for SettingsView and AboutView. Just change the emojis and text.


Step 2: Create the ViewModels

Add placeholder ViewModels in ViewModels/Pages/.

HomeViewModel.cs

namespace AvaloniaMultiPageStarter.ViewModels.Pages;

public class HomeViewModel : ViewModelBase { }

Same for SettingsViewModel and AboutViewModel.

Now, when you click the buttons, the matching page will show in the content area.

If Avalonia can’t find a matching view, you’ll see a fallback message like:

Not Found: AvaloniaMultiPageStarter.Views.Pages.HomeView

This usually means:

  • Class name or namespace doesn’t match

  • View file isn’t defined

  • Typo in the ViewModel name


Navigation State and Highlighting

At this point, the navigation works, but there’s no visual feedback for which page is currently active. Let’s fix that by:

  • Tracking the current page in the ViewModel

  • Binding that state to the buttons

  • Styling the active button with a custom class

Track the Current Page

In ShellViewModel.cs, define an enum to represent your app’s pages:

public enum PageType
{
    Home,
    Settings,
    About
}

Now add a bindable CurrentPage property:

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsHomeActive))]
[NotifyPropertyChangedFor(nameof(IsSettingsActive))]
[NotifyPropertyChangedFor(nameof(IsAboutActive))]
private PageType currentPage;

The [NotifyPropertyChangedFor] attributes tell the compiler to also raise change notifications for those dependent properties when CurrentPage changes.

Add Boolean Properties for UI Binding

Still in the same ShellViewModel, add computed properties that determine which page is active:

public bool IsHomeActive => CurrentPage == PageType.Home;
public bool IsSettingsActive => CurrentPage == PageType.Settings;
public bool IsAboutActive => CurrentPage == PageType.About;

These properties are automatically kept in sync because of the source generator we set up above.

Update your navigation commands to also set the current page:

[RelayCommand]
private void ShowHome()
{
    CurrentViewModel = new HomeViewModel();
    CurrentPage = PageType.Home;
}

[RelayCommand]
private void ShowSettings() {
    CurrentViewModel = _services.GetRequiredService<SettingsViewModel>();;
    CurrentPage = PageType.Settings;
}

[RelayCommand]
private void ShowAbout() {
    CurrentViewModel = _services.GetRequiredService<AboutViewModel>();;
    CurrentPage = PageType.About;
}

Bind Styles to Button State

In ShellView.axaml, remove any hardcoded Foreground or Background values from your buttons and update them like this:

<Button Content="Home"
        Command="{Binding ShowHomeCommand}"
        Classes.active="{Binding IsHomeActive}" />

<Button Content="Settings"
        Command="{Binding ShowSettingsCommand}"
        Classes.active="{Binding IsSettingsActive}" />

<Button Content="About"
        Command="{Binding ShowAboutCommand}"
        Classes.active="{Binding IsAboutActive}" />

The Classes.active="{Binding IsHomeActive}" syntax will add the active class to the button if the condition is true. This feature makes conditional styling very straightforward.

Define Styles for Navigation Buttons

In App.axaml, add the following styles to make navigation feel polished and responsive.

Base Button Style

<Style Selector="StackPanel > Button">
    <Setter Property="Background" Value="Transparent"/>\
    <Setter Property="Foreground" Value="Black"/>
    <Setter Property="BorderBrush" Value="Transparent"/>
    <Setter Property="FontWeight" Value="Normal"/>
    <Setter Property="Cursor" Value="Hand"/>
    <Setter Property="Padding" Value="8"/>
    <Setter Property="HorizontalContentAlignment" Value="Left"/>
    <Setter Property="BorderThickness" Value="0"/>
</Style>

Hover Style

<Style Selector="StackPanel > Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
    <Setter Property="Background" Value="#333"/>
</Style>

Active State Style

<Style Selector="Button.active">
    <Setter Property="Background" Value="#333" />
    <Setter Property="Foreground" Value="White" />
    <Setter Property="FontWeight" Value="Bold" />
</Style>

Run the App

You should now see:

  • Active buttons are styled differently

  • Hover effects work for inactive buttons

  • Navigation feels smooth and reactive


Shared State with Dependency Injection

Now that we’ve got multiple pages and clean navigation, let’s connect them through shared state. We'll use dependency injection to make that state accessible anywhere, and make changes reflect across views.

What We’re Building

We’ll create a central config service that holds app-wide state (like the current username). Then:

  • Inject it into multiple pages

  • Update state from one page (e.g. Settings)

  • Reactively reflect changes in another page (e.g. Home)

This shows how to structure reactive, cross-view state cleanly in Avalonia using the MVVM toolkit.

Define the Interface and Service

Create the shared config interface:

Interfaces/IAppConfigService.cs

using System.ComponentModel;

public interface IAppConfigService : INotifyPropertyChanged
{
    string Username { get; set; }
    bool IsDarkMode { get; set; }
}

Now the implementation:

Services/AppConfigService.cs

using CommunityToolkit.Mvvm.ComponentModel;
using AvaloniaMultiPageStarter.Interfaces;

public partial class AppConfigService : ObservableObject, IAppConfigService
{
    [ObservableProperty]
    private string username = "Your Name";

    [ObservableProperty]
    private bool isDarkMode;
}

The [ObservableProperty] attribute will generate full backing properties with PropertyChanged notifications.

Set Up Dependency Injection

Install the Microsoft DI package (if it’s not already included):

dotnet add package Microsoft.Extensions.DependencyInjection

Now create a bootstrapper to register services.

Bootstrapper.cs

using Microsoft.Extensions.DependencyInjection;
using AvaloniaMultiPageStarter.Services;
using AvaloniaMultiPageStarter.ViewModels;
using AvaloniaMultiPageStarter.ViewModels.Pages;
using AvaloniaMultiPageStarter.ViewModels.Layout;
using AvaloniaMultiPageStarter.Interfaces;

public static class Bootstrapper
{
    public static ServiceProvider Init()
    {
        var services = new ServiceCollection();

        services.AddSingleton<IAppConfigService, AppConfigService>();

        services.AddSingleton<ShellViewModel>();
        services.AddTransient<HomeViewModel>();
        services.AddTransient<SettingsViewModel>();
        services.AddTransient<AboutViewModel>();

        return services.BuildServiceProvider();
    }
}

Then update your app to use the DI container:

App.axaml.cs

private readonly ServiceProvider _services = Bootstrapper.Init();

public override void OnFrameworkInitializationCompleted()
{
    if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
    {
        var shellViewModel = _services.GetRequiredService<ShellViewModel>();

        desktop.MainWindow = new ShellView
        {
            DataContext = shellViewModel
        };
    }

    base.OnFrameworkInitializationCompleted();
}

Inject and Sync Shared State

Let’s make the ShellViewModel aware of both the config and the DI container:

ShellViewModel.cs

private readonly IServiceProvider _services;
private readonly IAppConfigService _config;

public ShellViewModel(IServiceProvider services, IAppConfigService config)
{
    _services = services;
    _config = config;
    ShowHome();
}

[RelayCommand]
private void ShowHome()
{
    CurrentViewModel = _services.GetRequiredService<HomeViewModel>();
    CurrentPage = PageType.Home;
}

[RelayCommand]
private void ShowSettings() {
    CurrentViewModel = _services.GetRequiredService<SettingsViewModel>();;
    CurrentPage = PageType.Settings;
}

[RelayCommand]
private void ShowAbout() {
    CurrentViewModel = _services.GetRequiredService<AboutViewModel>();;
    CurrentPage = PageType.About;
}

Display Shared Data in HomeViewModel.cs

public partial class HomeViewModel : ViewModelBase
{
    private readonly IAppConfigService _config;

    public string Username => _config.Username;

    public HomeViewModel(IAppConfigService config)
    {
        _config = config;

        _config.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(_config.Username))
                OnPropertyChanged(nameof(Username));
        };
    }
}

Update Shared Data in SettingsViewModel.cs

public partial class SettingsViewModel : ViewModelBase
{
    private readonly IAppConfigService _config;

    [ObservableProperty]
    private string inputName;

    public SettingsViewModel(IAppConfigService config)
    {
        _config = config;
        InputName = _config.Username;
    }

    [RelayCommand]
    private void SaveName()
    {
        if (!string.IsNullOrWhiteSpace(InputName))
            _config.Username = InputName;
    }
}

The Views

HomeView.axaml

<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
    <TextBlock Text="🏠 Home" FontSize="24"/>
    <TextBlock Text="Current User:" FontWeight="Bold"/>
    <TextBlock Text="{Binding Username}" FontSize="18"/>
</StackPanel>

SettingsView.axaml

<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="16">
    <TextBlock Text="⚙️ Settings" FontSize="24"/>

    <TextBlock Text="Change Username:" />
    <TextBox Text="{Binding InputName}" Width="200"/>

    <Button Content="Save" Command="{Binding SaveNameCommand}" Width="100"/>
</StackPanel>

Result

  • The Home page shows the current username

  • The Settings page updates it

  • Changes reflect across views instantly

  • State is injected cleanly through DI container


Final Thoughts

We started with a basic Avalonia MVVM template and ended up with a clean, scalable multi-page UI, complete with shared layout, navigation, and reactive app-wide state.

This setup gives you:

  • A consistent layout shell that stays in place across pages

  • Simple but powerful page switching using ViewLocator and ContentControl

  • Navigation highlighting that reflects the current state in the UI

  • A shared state service that’s injected via DI and reflects changes across pages instantly

Why this approach works

  • You only ever bind to the current ViewModel in one place (ShellView)

  • Each page is self-contained and focused

  • Global state lives in one shared service, not scattered

  • Styles and view logic stay clean and separated

It’s modular, testable, and easier to extend. You can drop in new pages, add onboarding, hook up real databases, or apply themes — and the structure holds up.

A few common pitfalls

  • If a view shows Not Found: SomeView, double-check that the view name matches the ViewModel and is in the right namespace

  • Don’t forget to inherit your ViewModels from a shared base like ViewModelBase : ObservableObject

  • Use [NotifyPropertyChangedFor] if you’re binding styles to computed properties

  • Use Classes.active="{Binding SomeBool}" only with Avalonia 11 or newer

If you found this helpful, feel free to fork the demo repo, adapt it to your app, and let me know what you build.

0
Subscribe to my newsletter

Read articles from Freeman Madudili directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Freeman Madudili
Freeman Madudili

Hi, I’m Freeman, a Software Developer specializing in C#, .NET, and Azure. With a passion for creating seamless web and cloud solutions, I’ve built and deployed apps that solve real-world problems. Follow along as I share insights, projects, and tips from my journey in tech!