Early experimental release

Build Avalonia UI with Akbura

A declarative .NET UI language and compiler with reactive state, typed styling, and direct access to Avalonia controls.

.NET Avalonia Reactive state AKCSS
Counter.akbura
using Avalonia.Controls;

state int count = 0;

<StackPanel Spacing="12">
    <TextBlock Text={$"Count: {count}"}/>
    <Button Click={count++}>
        Increment
    </Button>
</StackPanel>

Declarative components

Compose native Avalonia controls with concise, readable markup.

Reactive state

Declare state, bindings, effects, and commands close to the UI that uses them.

Native Avalonia

Use Avalonia controls, properties, routed events, and host integration directly.

Typed AKCSS

Style controls with semantic property binding, utilities, and reusable layers.

NuGet

Feature Support level Notes
Compatibility with Avalonia Full Akbura components can be used directly in AXAML views and vice versa – any Avalonia control (including custom controls) works without extra attributes or imports.
MarkupExtensions Partial Common markup extensions (StaticResource, DynamicResource, Binding) work exactly as in Avalonia. The IServiceProvider supplies IProvideValueTarget, IRootObjectProvider, IUriContext, IXamlTypeResolver, and IAvaloniaXamlIlEagerParentStackProvider. The old {} syntax is replaced by ${} (no quotes). XML‑element syntax for markup extensions, MarkupExtensionOptionAttribute, and some advanced scenarios are not supported.
Binding Full Bindings are fully supported: both ReflectionBinding and CompiledBinding work, including BindingPath parsing, mode selection, converters, etc.
TemplateContent Full Properties decorated with [TemplateContent] are automatically handled – Akbura generates an IDeferredContent implementation, so templates work out of the box.

Getting Started

Experimental

Akbura is under active development. Syntax, generated code, and runtime APIs may change between releases.

Create an application from the template

Install the current template package from NuGet:

dotnet new install Akbura.Templates::12.0.4-alpha.6

Create and run an Avalonia desktop application:

dotnet new akbura.app -n MyApp cd MyApp dotnet run

The application template includes Akbura, AKCSS, Debug diagnostics, and optional dependency injection. Select a DI provider when creating the project if needed:

dotnet new akbura.app -n MyApp --di Microsoft.Extensions.DependencyInjection dotnet new akbura.app -n MyApp --di Splat.Locator

Add Akbura to an existing project

Install Akbura into an existing Avalonia project:

dotnet add package Akbura --version 12.0.4-alpha.6

If the template package is installed, create a component from the project directory:

dotnet new akbura.component -n Counter --namespace MyApp.Components -o Components

To create both Counter.akbura and its C# code-behind partial class Counter.akbura.cs, use:

dotnet new akbura.partial-component -n Counter --namespace MyApp.Components -o Components

Install editor support

Install the extension for the IDE you use:

Both extensions provide language support for .akbura and .akcss files. The NuGet package compiles these files during the project build.

Create your first component

Create Counter.akbura:

using Avalonia.Controls;

namespace MyApp.Components;

state int count = 0;

<StackPanel Spacing="12">
    <TextBlock Text={$"Count: {count}"}/>
    <Button Click={count++}>Increment</Button>
</StackPanel>

Use the generated component directly inside an Avalonia AXAML view:

<UserControl
    xmlns="https://github.com/avaloniaui"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:components="using:MyApp.Components">

    <components:Counter />

</UserControl>

Akbura components are Avalonia controls, so no separate host control is required.

What Akbura Is

Akbura is a library and compiler for Avalonia, not a replacement framework.

It adds a declarative component language, reactive state, typed expressions, hooks, commands, and AKCSS while continuing to use native Avalonia controls and APIs.

An .akbura file declares one component:

Pages/Counter.akbura -> Demo.Pages.Counter

Generated components are partial and can be extended with regular C#.

Components and Markup

Akbura markup uses Avalonia controls directly:

state string title = "Dashboard";
state bool isOpen = false;

<StackPanel>
    <TextBlock Text={title}/>
    <Button Click={isOpen = true}>Open</Button>
    <Border IsVisible={isOpen}/>
</StackPanel>

Attributes may contain literals, C# expressions, bindings, and markup extensions.

Conditional Markup describes $if, $else if, and $else inside content, including branch-local C# scope and the feature's current availability.

Dictionary Resources

Use x.key to insert a child into a dictionary content slot, such as an Avalonia control's Resources. x.Key is an alias of the same directive:

using Avalonia.Controls;
using Avalonia.Media;

state int resourceIndex = 0;

<StackPanel>
    <StackPanel.Resources>
        <SolidColorBrush x.key="AccentBrush" Color="Red" />
        <SolidColorBrush x.Key={resourceIndex + 1} Color="Blue" />
    </StackPanel.Resources>

    <Button Click={resourceIndex++}>Move the numbered resource</Button>
</StackPanel>

The directive describes the entry in the parent dictionary. It does not set a Key property on the brush, name the element, or replace x.Name.

A mutable IDictionary<TKey, TValue> accepts children compatible with TValue; the key expression is checked against TKey. Non-generic IDictionary uses object keys and values. This applies to dictionary properties, [Content] properties, dictionaries used as element content, and dictionary component parameters. Explicit interface implementations are supported.

A dictionary parameter named Content has a per-component mutable backing dictionary. Other dictionary parameters retain the usual parameter rules: provide their dictionary through an attribute or declare a default initializer, for example param IDictionary<int, SolidColorBrush> Entries = new Dictionary<int, SolidColorBrush>();. Entries inside <MyComponent.Entries> populate that dictionary; they do not assign the parameter itself or satisfy a missing required receiver.

Quoted keys remain strings, including their spaces and case. For an int-keyed dictionary, use x.key={42}; x.key="42" is a type error. In an object-keyed dictionary these produce different keys. No implicit ToString() conversion is performed. Missing keys, keys outside a dictionary, incompatible types, and x.key="A" x.Key="B" on the same child produce semantic diagnostics.

Updating owned entries

Akbura evaluates each key expression once for the corresponding update and reconciles the declaration's whole set of entries. Changing resourceIndex moves the compatible existing brush to its new key and removes its old entry. Two owned entries can exchange keys without a temporary collision caused by adding one before removing the other.

Ownership belongs to a content slot, not to the dictionary as a whole. Akbura does not call Resources.Clear() or overwrite foreign entries. An entry is removed only while its key still refers to the owned object; externally replaced values are left alone. Structural Hot Reload also removes owned entries when their declaration or property element disappears.

Dictionary lookups and insertion use the actual dictionary's equality rules. A case-insensitive dictionary may therefore reject "A" and "a" as a collision, even if the compiler cannot prove it statically. A conflicting foreign entry or duplicate desired key fails the update rather than being overwritten. Read-only dictionaries, non-generic fixed-size dictionaries, and ambiguous mutable dictionary contracts are unsupported write targets.

If a custom dictionary mutator throws, reconciliation attempts to restore the previous owned entries. This is best-effort rollback: arbitrary dictionaries may publish notifications during removal, insertion, or restoration. Notification atomicity is not guaranteed; if restoration also fails, both failures are reported. A new ownership snapshot is committed only after successful reconciliation.

Avalonia Styles

Native Avalonia Style and Setter objects can be declared directly in markup:

using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;

state bool highlighted = false;

<StackPanel>
    <StackPanel.Styles>
        <Style Selector="Button">
            <Setter
                Property="Background"
                Value={highlighted ? Brushes.Red : Brushes.Blue} />

            <Style Selector="^:pointerover">
                <Setter Property="Opacity" Value="0.7" />
            </Style>
        </Style>
    </StackPanel.Styles>

    <Button Click={highlighted = !highlighted}>Change the style</Button>
</StackPanel>

Each child is added through its applicable typed Add overload: setters and nested styles are different content routes. This fallback is also available to custom types with suitable accessible instance Add methods; ambiguous overloads produce a diagnostic.

Style subtrees are fully initialized before being attached to a live host. Currently, each applicable component render recreates and replaces the owned style subtree, even when its values are unchanged. Hot Reload also creates a new subtree. The surrounding live control tree and foreign styles are preserved; this does not rely on changing an already attached Setter.Value in place.

Avalonia property references

For a destination whose declared type is AvaloniaProperty or one of its derived types, a quoted value is resolved to a compatible static property field:

<Setter Property="Button.Background" Value="Red" />

This refers to Button.BackgroundProperty, including properties inherited from a base class. Styled, attached, and direct property fields are supported. The same mechanism applies to custom CLR properties of type AvaloniaProperty, regardless of the holder class or property name.

Inside a known style target, the owner can be omitted:

<Style Selector="Button /template/ Border">
    <Setter Property="Background" Value="Red" />
</Style>

Here Background is resolved for the selected Border, not the first Button in the selector. The referenced property's value type supplies the contextual conversion for Value; its actual CLR type remains object.

The supported literal selector forms include type names, namespace-qualified type names with |, classes, names, simple pseudo-classes, :is(Type), ^, descendant and child combinators, /template/, and comma-separated lists. Nested ^ inherits the parent target. ControlTheme.TargetType also supplies target context. Advanced selector expressions such as :not(...), nth-child functions, and property filters are not currently supported as markup literals.

For type-less, dynamic, or ambiguous targets, use an owner-qualified reference or a statically resolvable C# field:

<Setter Property="Button.Background" Value="Red" />
<Setter Property={Button.BackgroundProperty} Value="Red" />

Akbura does not guess a target from the nearest visual parent. An unqualified reference in a selector list must resolve consistently for all target branches.

Property metadata and custom assignments

[DependsOn], [AssignBinding], and [Content] have independent roles:

  • [DependsOn] orders assignment actions. It does not create a subscription, watcher, effect, or hook.
  • [AssignBinding] stores the binding object in the holder property instead of applying the binding to that property.
  • [Content] chooses the destination for implicit element content.

For example, a custom holder can use different names from Avalonia's Setter:

using Avalonia;
using Avalonia.Data;
using Avalonia.Metadata;

namespace MyApp.Markup;

public sealed class CustomAssignment
{
    public AvaloniaProperty? Target { get; set; }

    [Content]
    [AssignBinding]
    [DependsOn(nameof(Target))]
    public object? Payload { get; set; }
}

For an object property depending on one unambiguous Avalonia property reference, Akbura uses that reference's value type for ordinary value conversion. It does not change the declared object type or apply this convention to unrelated dependencies.

These three content routes share the assignment contract:

using Avalonia.Controls;
using MyApp.Markup;

<CustomAssignment Payload="Red" Target="Button.Background" />

<CustomAssignment Target="Button.Background">
    Red
</CustomAssignment>

<CustomAssignment Target="Button.Background">
    <CustomAssignment.Payload>Red</CustomAssignment.Payload>
</CustomAssignment>

Target is assigned before Payload, including when the dependency is declared through a property element. Independent assignments retain source order. Invalid dependency metadata and cycles are diagnosed. A dependency not assigned in the declaration does not create an artificial assignment.

If a dynamic non-generic AvaloniaProperty does not reveal its value type, use a qualified/static reference or an explicitly typed value expression for contextual conversion. Conflicting property dependencies are diagnosed rather than choosing an arbitrary one.

With [AssignBinding], this stores the extension result as a binding object:

using Avalonia.Controls;
using Akbura.Markup;
using MyApp.Markup;

<CustomAssignment
    Target="Button.Background"
    Payload=${Binding AccentBrush} />

ProvideValue is still evaluated with the normal service provider. An extension declared to return object follows the same delivery policy when its actual result is a binding. The holder's own runtime contract decides how that object is later used. See Markup Extensions for extension evaluation and AXAML syntax differences for the directive spelling.

Reactive State

Declare local reactive values with state:

state int selectedIndex = 0;
state string query = "";

State may also connect to object properties:

state MyViewModel vm = new MyViewModel();

state string name = bind vm.Name;
state string fullName = out vm.FullName;
state string surname = in vm.Surname;

Parameters

Parameters define a component's public API:

param int UserId = 1;
param string Title;
param bind string Search = "";
param out TaskItem SelectedTask;

Parameters without default values are required. bind enables two-way flow, while out publishes a value to the parent.

Binding

Akbura supports Avalonia bindings directly in markup:

<TextBlock Text=${Binding Title} />
<TextBox Text=${Binding Search, Mode=TwoWay} />

Bindings are resolved against the expected property type and may be compiled when a data type is known.

Inside item templates, Akbura can infer the item type from ItemsSource. A template must contain a single root control, so multiple child controls should be wrapped in a panel:

<ItemsControl ItemsSource={Vm.Items}>
    <ItemsControl.ItemTemplate x.ItemName="item">
        <StackPanel Spacing="6">
            <TextBlock Text=${Binding Title} />

            <Button Click={() => Open(item)}>
                Open {item.Id.ToString("D")} — {item.Title}
            </Button>
        </StackPanel>
    </ItemsControl.ItemTemplate>
</ItemsControl>

x.ItemName exposes the current item as a typed variable. The item can participate in property expressions, event handlers, method calls, and inline content expressions.

Use x.DataType when the item type cannot be inferred automatically.

Markup Extensions

Akbura does not restrict markup attributes to a special binding-only syntax. Regular markup extensions can be used directly alongside C# expressions and literals:

<TextBlock Text=${Binding Title} />
<Border Background=${StaticResource CardBackground} />
<Border BorderBrush=${DynamicResource AccentBrush} />

Custom markup extensions are also supported when they expose a compatible constructor and ProvideValue method:

<TextBlock Text=${Format 1, Value={count}‎} />

The compiler resolves the extension type, constructor arguments, properties, ProvideValue, and the conversion to the target Avalonia property type.

Effects and Hooks

useEffect runs after rendering and can react to dependencies:

using Akbura.Hooks;

state int count = 0;

useEffect(
    () => Console.WriteLine(count),
    [count]);

useEffect without a dependency list runs after every successful render. An empty list runs it once until the component is detached or the hook is reset. Changing dependencies cancels the previous run and invokes its cleanup before the next run.

Debouncing state

useDebounce keeps the initial value available immediately, then publishes changes after the source has stayed unchanged for the requested delay:

using Akbura.Hooks;

state int count = 0;
state int debouncedCount = useDebounce(count, 300);
state int debouncedEffectedCount = useDebounce(count, x => x + 1, 300);

Declare the source state before a hook that consumes it. For the State<T> argument, the compiler passes the live state object; ordinary expressions such as count + 1 continue to read its value. The hook result remains the same state object across renders, and separate calls have independent results and delays.

Both integer milliseconds and TimeSpan are accepted, from zero through Int32.MaxValue milliseconds. A source value change, replacement of the source state, or change to the delay restarts the full delay. Unrelated renders and updates of the debounced result do not restart it. Changes merged into one render count as one input. Values are captured from that render; mutable objects are not deep-copied.

The selector is called once for the initial result, then after the delay. It should be pure: initialization may be retried if the first render fails. Each pending run keeps the selector from the render that started it. Replacing an inline selector alone does not restart the delay, and captured values are not automatic dependencies.

For additional dependencies, use the callback overload:

state string query = "";
state string debouncedQuery = "";

useDebounce(
    () => { debouncedQuery = query; },
    TimeSpan.FromMilliseconds(350),
    [query]);

Callbacks run on the UI dispatcher, including zero-delay callbacks. The callback form also accepts Func<CancellationToken, Task>; its task is awaited and failures are observed by the effect runtime. Cancellation stops a pending delay or queued callback. Already-running asynchronous callbacks must cooperate with the token. Dependencies and delay select a run; an unrelated render does not replace the callback already captured by that run.

Detaching cancels pending work and preserves hook state. Reattaching starts a full new delay using the latest source. Hot Reload currently recreates hook-owned state; ordinary component state follows the existing preservation rules.

Writing a composable hook

A user-defined hook runs during each component render and combines primitives. useHookState returns persistent state immediately; useEffect registers work to run after a successful render. Their order must stay the same on every render.

using Akbura;
using Akbura.CompilerAnotations;
using Akbura.ComponentTree;
using Akbura.Hooks;

public static class MyHooks
{
    [UseHook]
    public static State<int> useDoubled(
        [Self] this AkburaControl control,
        State<int> source)
    {
        var value = source.Value;
        var result = control.useHookState(() => value * 2);

        control.useEffect(
            () => { result.Value = value * 2; },
            [source, value]);

        return result;
    }
}

The component uses it as state int doubled = useDoubled(count);. Its internal state belongs to the component and requests rendering when it changes. The author does not manage slot numbers, attach the returned state again, or maintain a second registry. Wrapping a hook does not allocate an additional slot: only the primitives participate in the shared sequence.

useHookState accepts an initial value, a lazy Func<T>, or a StateInfo<T> descriptor. Initialization happens only when a new slot is needed; later initial values do not overwrite existing state. Static descriptors can be shared across calls, while each component and primitive position has its own state instance.

Call primitives only while a render is collecting hooks. Do not call them from conditions that change the sequence, event handlers, effect callbacks, lazy state initializers, or after await. Put conditional behavior inside an effect and include the condition in its dependencies. The runtime checks the sequence's shape and types; it cannot distinguish every swap of otherwise identical calls in arbitrary C#.

Older hooks that create state and subscriptions once must explicitly use [UseHook(IsInitializer = true)] until migrated to useHookState and resource-owning effects. Akbura's existing Avalonia-property state hooks use that compatibility contract. A normal [UseHook] returning State<T> uses the composable per-render contract.

See Composable Hooks for derived state, refs, timers, throttle, async loading, external subscriptions and reducers built from these primitives.

Commands

Commands expose typed operations with reactive execution state:

command int Refresh(int userId);

<Button Click={async () => {
    var result = await Refresh.Execute(42);
    Console.WriteLine(result);
}‎}>
    Refresh
</Button>

Command facades provide Execute, CanExecute, and IsExecuting.

AKCSS

AKCSS is Akbura's typed styling language:

@akcss {
    .card {
        Padding: (10, 20);
        Background: White;
    }
}

<Border class="card"/>

AKCSS supports reusable classes, utilities, @apply, conditional rules, resources, imports, and C# interceptors.

Project Status

Akbura is experimental and is not yet intended as a stable production library.

Current limitations include:

  • markup-level @if, @else, @for, and @foreach are not supported;
  • some APIs and generated code may change without backward compatibility.

Community and Feedback

Ideas, bug reports, documentation improvements, and focused pull requests are welcome.

;