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


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 aViewLocator
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 installedA
ViewLocator
that mapsViewModels
to matchingViews
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.axaml
→ShellView.axaml
MainWindowViewModel.cs
→ShellViewModel.cs
Inside each file, also rename the classes to
ShellView
andShellViewModel
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 belongThe
ContentControl
will load whichever page ViewModel is set toCurrentViewModel
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:
Take the
CurrentViewModel
Ask
ViewLocator
to find the matchingView
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
andContentControl
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 theViewModel
and is in the right namespaceDon’t forget to inherit your ViewModels from a shared base like
ViewModelBase : ObservableObject
Use
[NotifyPropertyChangedFor]
if you’re binding styles to computed propertiesUse
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.
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!