Important: This repository is community driven, not officially supported by avalonia team and is not part of the official Avalonia project—it's only a proof of concept demonstrating how markup can be written purely in C#. For real-world projects use Avalonia's supported XAML approach.
Write Avalonia UI with C#
Avalonia.Markup.Declarative is a C#-first authoring layer over Avalonia controls. The current API is compiled-binding-first and source-generator-driven, with public patterns intentionally aligned with Avalonia's DataContext, binding, style, and selector model.
Add the Avalonia.Markup.Declarative NuGet package to your project
You can easily create a new project from the command line using the official template:
dotnet new install Declarative.Avalonia.Templates
dotnet new avalonia-declarative -n MyAppUse a self-contained declarative component when the view and its reactive state belong to the same feature. Add CommunityToolkit.Mvvm to the app project and keep the component-local state in a nested ObservableObject.
using Avalonia.Data;
using CommunityToolkit.Mvvm.ComponentModel;
public class CounterComponent() : ViewBase<CounterComponent.State>(new State())
{
public sealed partial class State : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CounterLabel))]
public partial decimal? Counter { get; set; } = 0;
[ObservableProperty]
public partial string StatusText { get; set; } = "Hello world";
public string CounterLabel => $"Counter: {Counter}";
}
protected override object Build(State state) =>
new StackPanel()
.Children(
new TextBlock()
.Text(state, x => x.StatusText),
new TextBlock()
.Text(state, x => x.CounterLabel),
new NumericUpDown()
.Value(state, x => x.Counter, BindingMode.TwoWay),
new Button()
.Content("Increment")
.OnClick(_ => state.Counter++)
);
}To compose constructor-injected views, prefer ViewFactory.Create<T>(). If you use DI, register UseComponentControlFactory(...) on AppBuilder.
Use ViewBase<TViewModel> when you want a classic Avalonia or WPF-style view model. Generated setters expose compiled-binding overloads, so the binding syntax stays close to native Avalonia.
using Avalonia.Data;
public class MainView() : ViewBase<MainViewModel>(new MainViewModel())
{
protected override object Build(MainViewModel vm) =>
new StackPanel()
.Children(
new TextBox()
.Text(vm, x => x.Message, BindingMode.TwoWay),
new TextBlock()
.Text(vm, x => x.Message),
new Button()
.Content("Reset")
.OnClick(_ => vm.Message = string.Empty)
);
}Equivalent XAML for the same view:
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyApp.ViewModels"
x:Class="MyApp.MainView"
x:DataType="vm:MainViewModel">
<StackPanel>
<TextBox Text="{CompiledBinding Message, Mode=TwoWay}" />
<TextBlock Text="{CompiledBinding Message}" />
<Button Content="Reset"
Click="ResetClick" />
</StackPanel>
</UserControl>If you assign DataContext from the outside, the same generated setters also support DataContext-relative compiled bindings such as new TextBlock().Text<MainViewModel>(x => x.Message);.
Generated compiled-binding setters also apply automatic conversion for common primitive and nullable mismatches, so bindings like new Slider().Value(vm, x => x.Counter, BindingMode.TwoWay) work when Counter is int, and new CheckBox().IsChecked(vm, x => x.Enabled) work when Enabled is bool. Prefer plain member access such as x => x.Counter: a numeric-conversion cast like x => (double)x.Counter is both unnecessary (the auto-converter handles it) and unsupported — Avalonia's expression parser rejects value-converting Convert nodes. Type casts that navigate to a member of a derived type, such as x => ((DerivedType)x).Property, are supported. For lossy numeric TwoWay conversions, convert-back truncates toward zero.
When you need the full binding feature set — reflection bindings (Binding), a pre-built compiled binding, a TemplateBinding, a MultiBinding, or a relative-source/element-name binding — every generated property, attached-property and style setter also exposes an overload that accepts a BindingBase directly:
using Avalonia.Data;
new TextBlock()
.Text(new Binding("ReflectionProperty")) // DataContext-relative reflection binding
.Foreground(new Binding("Theme.Accent") { Source = appState }); // explicit source
new TextBlock()
.Text(CompiledBinding.Create<MyViewModel, string>(x => x.Title, source: vm));
// attached properties and styles get the same overload
new Border().Grid_Row(new Binding(nameof(vm.Row)) { Source = vm });
new Style<TextBlock>().Text(new Binding(nameof(vm.Name)));This is the escape hatch for anything the strongly-typed x => x.Member expression overloads don't cover (custom converters passed on the binding, RelativeSource, ElementName, string format, multi-value bindings, etc.). The same thing is available on any AvaloniaProperty via control.BindValue(TextBlock.TextProperty, binding).
-
ViewBasesupports .NET 6.0+ hot reload. -
Keeping declarative views in an assembly without XAML can still produce the smoothest hot reload experience.
-
Current Avalonia and .NET toolchains are much better at mixing AXAML and C# markup in the same application, so the limitation is much smaller than it used to be.
-
To enable AMD hot reload integration explicitly:
AppBuilder.Configure<Application>() .UseHotReload() .SetupWithLifetime(lifetime);
The optional, dev-only Declarative.Avalonia.AgentTools package runs an in-process MCP (Model
Context Protocol) server on loopback in debug builds, so an AI agent iterating on your UI can see and
drive the running app — closing the edit → hot-reload → verify loop without a human relaying what the
window looks like.
using Declarative.Avalonia.AgentTools; // under #if DEBUG
AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.UseAgentInspector() // loopback MCP server on 127.0.0.1:5599
#endif
.SetupWithLifetime(lifetime);It exposes screenshots (with before/after pixel-diffing), the visual tree with bounds, per-control layout
reports, an automated layout audit, property / property-source / view-model inspection, pixel↔control
hit-testing, and recent build/binding/runtime errors. An opt-in tier (EnableInteraction) also drives the
app — click, type, select, resize, switch theme — plus an escape hatch to set a view-model property or run
a command directly to reach awkward states.
Keep the call under #if DEBUG: the package pulls in a web stack and a remote-control surface and must not
ship in Release; it binds to loopback only. See docs/agent-tools.md for the full
guide.
The package ships with the source generator that produces markup extensions for:
- referenced Avalonia framework assemblies
- controls declared in your own project
- opted-in third-party assemblies
If you downloaded source code or cloned this repo, add the source generator project as an analyzer reference:
<ItemGroup>
<ProjectReference Include="..\..\Avalonia.Markup.Declarative.SourceGenerator\Avalonia.Markup.Declarative.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>Make sure that the path to the source generator project is correct relative to your project.
Note: If you are using this library as a NuGet package, the source generator is included automatically.
Framework extensions are generated automatically for the supported Avalonia assemblies that your project references. To generate extensions for a third-party library, add an assembly attribute that points to any type from that assembly:
using Avalonia.Markup.Declarative;
using ReactiveUI.Avalonia;
[assembly: GenerateMarkupExtensionsForAssembly(typeof(RoutedViewHost))]No standalone tool installation or manual avalonia-amd-gen step is required anymore.