User Controls & Custom Controls

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Sooner or later a desktop app needs a piece of UI that is used in more than one place, or that other teams must be able to restyle. WPF offers three ways to package it: compose existing controls in a UserControl, derive from Control and describe the look in a replaceable template, or derive from FrameworkElement and draw it yourself. This page explains how to choose, then builds one of each of the first two for Bookshelf Desktop: a BookCard UserControl and a templated StarRating custom control with a routed event, template parts, an automation peer and a default style in Themes/Generic.xaml. It ends with packaging both in a control library.

The page was written against these versions, current in October 2026:

  • .NET 10 LTS, latest patch 10.0.13, with C# 14. .NET 11 is at release candidate 2 (general availability is expected 10 to 13 November 2026). The WPF control authoring model is unchanged between .NET Framework 4.8 and .NET 8, 9 and 10; the APIs below exist in all of them.

  • Visual Studio 2026 18.11.1, whose WPF item templates include User Control and Custom Control, and whose project templates include WPF Custom Control Library and WPF Class Library.

  • Windows App SDK 2.5.1 for the WinUI 3 comparison.

  • CommunityToolkit.Mvvm 8.4.2 for the view model in the usage examples.

The earlier pages of this section are assumed: dependency properties, routed events and resources are summarised only where a control needs them. See Dependency Properties & Attached Properties, Routed Events & Input and Resources & Resource Dictionaries. Styles, templates and triggers, themes and accessibility have their own pages in this section; this page covers only what is specific to authoring a control, and mentions those pages in prose.

Before Writing a Control

The official control authoring overview begins with a warning that many needs do not call for a new control at all. WPF’s content model, styles, data templates, control templates and triggers can change the look of an existing control so far that a new class is often unnecessary.

Need Try first Page of this section

Put an icon and text on a button

Content is an object; put a panel in it

Controls & the Content Model

Make every TextBlock or Button look the same

A Style, ideally implicit

Styles, Templates & Triggers

Show an item differently in a list

A DataTemplate

Converters, Collection Views & Data Templates

Restructure a control (a RadioButton as a traffic light)

Replace its ControlTemplate

Styles, Templates & Triggers

Change appearance when a property changes

A Trigger or DataTrigger

Styles, Templates & Triggers

A new control is justified when you need new behaviour (new state, new events, new keyboard handling), when you want a packaged component with a stable contract that many screens reuse, or when the same visual group repeats across screens and a DataTemplate would be copied each time.

The Three Authoring Models

The documentation names three base classes for a new WPF control, each trading simplicity for flexibility.

Model You write Consumers can retemplate it? Typical use

Derive from UserControl

A XAML file with a root UserControl, plus code-behind; existing controls are composed in the XAML

No: a UserControl "will not support templates"

A reusable screen fragment used like part of an application

Derive from Control (or a subclass other than UserControl)

A class with dependency properties and events, plus a default ControlTemplate in Themes/Generic.xaml

Yes: the look is a template, the logic is the class

A library control that must be themeable, such as StarRating

Derive from FrameworkElement

A class that overrides OnRender (direct rendering) or composes Visual objects

Not by a template; you draw everything

Special rendering such as Image, Border and Track

UserControl derives from ContentControl, so it still has a Template property, but the class is designed so that its content is fixed by your XAML. The documented guidance for choosing it is that all of the following hold: you want to build the control the way you build an application, it consists only of existing components, and you do not need complex customization. The documented reasons to derive from Control are that the appearance must be customizable through the ControlTemplate and that the control must support different themes.

UserControl or Custom Control?

flowchart TD A["Need a reusable piece of UI"] --> B{"Can a Style, DataTemplate or ControlTemplate on an existing control do it?"} B -- "Yes" --> Z["Do not write a control"] B -- "No" --> C{"Does it need drawing that composition cannot express?"} C -- "Yes" --> D["Derive from FrameworkElement and override OnRender"] C -- "No" --> E{"Must other developers restyle or retemplate it?"} E -- "Yes" --> F["Derive from Control with a default template in Themes/Generic.xaml"] E -- "No" --> G{"Is it a fragment used only inside this app or library?"} G -- "Yes" --> H["UserControl"] G -- "No" --> I{"Does it need its own states, keyboard handling or automation?"} I -- "Yes" --> F I -- "No" --> H

The right-hand branches are a judgement, not a rule. A UserControl is quick to build and to review because its structure is visible in one XAML file. A custom control costs more up front (a class, a generic dictionary, template parts, an automation peer) and pays that back in every screen that restyles it. A practical test: if you expect to write <Style TargetType="local:YourControl"> with a different Template in a consuming project, derive from Control.

Side by Side

Aspect UserControl Templated Control

Look defined in

The control’s own XAML file, compiled with its code-behind

A ControlTemplate inside a default Style in Themes/Generic.xaml

Logic can reference visuals

Freely, by x:Name (the generated InitializeComponent connects them)

Only through template parts found in OnApplyTemplate, and ideally not at all

Consumers change the look

By styling exposed properties only

By replacing Template, with TemplateBinding for your properties

Theming

Inherits whatever its parts inherit

Own default style per theme or in the generic dictionary

Design-time experience

Preview in the XAML designer

Preview needs the generic dictionary to load

Test and automate

Add an automation peer if it is more than a container

Always add an automation peer when it accepts input

Cost

Low

Moderate to high

A UserControl: BookCard

BookCard shows a book as a small card: a title, an author and a favourite toggle. It is a fragment of the main window that appears in a list item, a search result and a recently opened strip. It is a good UserControl candidate: it is made of existing controls, it is used inside this app, and nobody needs to retemplate it.

<UserControl x:Class="Bookshelf.Desktop.Controls.BookCard"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Name="Root"
             MinWidth="220">
    <Border Padding="12" CornerRadius="6" BorderThickness="1"
            BorderBrush="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}"
            Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}">
        <Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
            <TextBlock Grid.Row="0" Grid.Column="0" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
                       Text="{Binding Title, ElementName=Root}" />
            <TextBlock Grid.Row="1" Grid.Column="0" Opacity="0.75" TextTrimming="CharacterEllipsis"
                       Text="{Binding Author, ElementName=Root}" />
            <ToggleButton Grid.RowSpan="2" Grid.Column="1" VerticalAlignment="Center"
                          AutomationProperties.Name="Favourite"
                          IsChecked="{Binding IsFavorite, ElementName=Root, Mode=TwoWay}"
                          Content="&#x2606;" />
        </Grid>
    </Border>
</UserControl>

The two inline-form ColumnDefinitions and RowDefinitions attributes are the short Grid syntax added in .NET 10; see Layout & Panels for the older element form. On earlier runtimes write <Grid.ColumnDefinitions> explicitly.

The code-behind declares the public surface as dependency properties, so a consumer can bind, style and animate them:

using System.Windows;
using System.Windows.Controls;

namespace Bookshelf.Desktop.Controls;

public partial class BookCard : UserControl
{
    public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
        nameof(Title), typeof(string), typeof(BookCard), new PropertyMetadata(string.Empty));

    public static readonly DependencyProperty AuthorProperty = DependencyProperty.Register(
        nameof(Author), typeof(string), typeof(BookCard), new PropertyMetadata(string.Empty));

    public static readonly DependencyProperty IsFavoriteProperty = DependencyProperty.Register(
        nameof(IsFavorite), typeof(bool), typeof(BookCard),
        new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public BookCard()
    {
        InitializeComponent();
    }

    public string Title
    {
        get => (string)GetValue(TitleProperty);
        set => SetValue(TitleProperty, value);
    }

    public string Author
    {
        get => (string)GetValue(AuthorProperty);
        set => SetValue(AuthorProperty, value);
    }

    public bool IsFavorite
    {
        get => (bool)GetValue(IsFavoriteProperty);
        set => SetValue(IsFavoriteProperty, value);
    }
}

The CLR property wrappers only call GetValue and SetValue. The overview is explicit that clients, the binding engine and the designer can bypass the accessors, so logic placed in them runs sometimes and not others. Put logic in the PropertyChangedCallback, CoerceValueCallback or ValidateValueCallback instead.

Used in the main window, the card is bound exactly like any control:

<ItemsControl ItemsSource="{Binding Books}">
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <controls:BookCard Margin="0,0,0,8"
                               Title="{Binding Title}"
                               Author="{Binding Author}"
                               IsFavorite="{Binding IsFavorite, Mode=TwoWay}" />
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

Here Books is the ObservableCollection<BookViewModel> of BooksViewModel, and IsFavorite is the property of BookViewModel from the Commands page. The controls prefix maps to the control’s namespace and assembly with xmlns:controls="clr-namespace:Bookshelf.Desktop.Controls;assembly=Bookshelf.Controls" when the control lives in a separate library, or without the assembly part when it lives in the app.

Why the Card Binds with ElementName

The card’s XAML binds to Root, its own root element, rather than setting DataContext = this in the constructor. That choice is the single most common UserControl mistake to avoid. A control’s DataContext is inherited by its children and is set by its consumer: <controls:BookCard Title="{Binding Title}" /> evaluates Title against the DataContext of the BookCard, which the parent supplies (the BookViewModel). If the card’s constructor replaced that DataContext with the card itself, the consumer’s {Binding Title} would then look for Title on the card and fail silently. Binding inside the control with ElementName (or RelativeSource={RelativeSource AncestorType=UserControl}) leaves DataContext to whoever uses the control. See Data Binding for DataContext inheritance.

Limits of a UserControl

  • No template. Consumers cannot restyle the card’s inner layout. They can only set properties the card exposes (colours, fonts, Padding, and so on), which is why a card meant for reuse across themes should take brushes from system or application resources with DynamicResource, as above.

  • Names are private. x:Name elements inside the card belong to its namescope, so a consumer cannot reach them with ElementName. This is deliberate encapsulation; see Markup Extensions & Namescopes.

  • Content is the card. Setting Content on a UserControl in the consumer replaces the card’s XAML content. It is not a slot, so do not put content in an instance of BookCard unless you intend to replace it.

  • Code-behind couples the layout. Event handlers in the card’s code-behind refer to named elements, which is fine for a fragment, but becomes a liability once a team wants to change the layout.

A Custom Control: StarRating

StarRating lets a reader set a book’s rating from 0 to a configurable maximum with the mouse or the arrow keys. It is a library control: other apps will reuse it and want to replace the stars with, for example, circles. That makes it a templated Control. The pieces are:

Piece Role

StarRating class

Dependency properties (Value, Maximum), the ValueChanged routed event, input handling, template part wiring

Themes/Generic.xaml

The default Style whose Template setter holds the default ControlTemplate

assembly-level ThemeInfo attribute

Tells WPF where the generic dictionary lives

StarRatingAutomationPeer

Exposes the control to UI Automation (Narrator, test tools)

The Class

using System.Windows;
using System.Windows.Automation.Peers;
using System.Windows.Controls;
using System.Windows.Input;

namespace Bookshelf.Controls;

[TemplatePart(Name = PartSurface, Type = typeof(Border))]
[TemplatePart(Name = PartGlyphs, Type = typeof(TextBlock))]
public class StarRating : Control
{
    public const string PartSurface = "PART_Surface";
    public const string PartGlyphs = "PART_Glyphs";

    static StarRating()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(StarRating), new FrameworkPropertyMetadata(typeof(StarRating)));
    }

    public static readonly DependencyProperty MaximumProperty = DependencyProperty.Register(
        nameof(Maximum), typeof(int), typeof(StarRating),
        new FrameworkPropertyMetadata(5, OnMaximumChanged), IsValidMaximum);

    public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
        nameof(Value), typeof(int), typeof(StarRating),
        new FrameworkPropertyMetadata(
            0,
            FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
            OnValueChanged,
            CoerceValue));

    public static readonly RoutedEvent ValueChangedEvent = EventManager.RegisterRoutedEvent(
        nameof(ValueChanged), RoutingStrategy.Bubble,
        typeof(RoutedPropertyChangedEventHandler<int>), typeof(StarRating));

    private Border? _surface;
    private TextBlock? _glyphs;

    public int Maximum
    {
        get => (int)GetValue(MaximumProperty);
        set => SetValue(MaximumProperty, value);
    }

    public int Value
    {
        get => (int)GetValue(ValueProperty);
        set => SetValue(ValueProperty, value);
    }

    public event RoutedPropertyChangedEventHandler<int> ValueChanged
    {
        add => AddHandler(ValueChangedEvent, value);
        remove => RemoveHandler(ValueChangedEvent, value);
    }

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        if (_surface is not null)
        {
            _surface.MouseLeftButtonDown -= OnSurfaceMouseDown;
        }

        _surface = GetTemplateChild(PartSurface) as Border;
        _glyphs = GetTemplateChild(PartGlyphs) as TextBlock;

        if (_surface is not null)
        {
            _surface.MouseLeftButtonDown += OnSurfaceMouseDown;
        }

        UpdateGlyphs();
    }

    protected override void OnKeyDown(KeyEventArgs e)
    {
        switch (e.Key)
        {
            case Key.Right or Key.Up:
                Value++;
                e.Handled = true;
                break;
            case Key.Left or Key.Down:
                Value--;
                e.Handled = true;
                break;
            case Key.Home:
                Value = 0;
                e.Handled = true;
                break;
            case Key.End:
                Value = Maximum;
                e.Handled = true;
                break;
            default:
                base.OnKeyDown(e);
                break;
        }
    }

    protected virtual void OnValueChanged(RoutedPropertyChangedEventArgs<int> args)
    {
        RaiseEvent(args);
    }

    protected override AutomationPeer OnCreateAutomationPeer() => new StarRatingAutomationPeer(this);

    private static bool IsValidMaximum(object value) => (int)value > 0;

    private static object CoerceValue(DependencyObject d, object baseValue)
    {
        var control = (StarRating)d;
        return Math.Clamp((int)baseValue, 0, control.Maximum);
    }

    private static void OnMaximumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (StarRating)d;
        control.CoerceValue(ValueProperty);
        control.UpdateGlyphs();
    }

    private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (StarRating)d;
        control.UpdateGlyphs();

        var args = new RoutedPropertyChangedEventArgs<int>((int)e.OldValue, (int)e.NewValue, ValueChangedEvent);
        control.OnValueChanged(args);

        if (UIElementAutomationPeer.FromElement(control) is StarRatingAutomationPeer peer)
        {
            peer.RaiseValueChanged((int)e.OldValue, (int)e.NewValue);
        }
    }

    private void OnSurfaceMouseDown(object sender, MouseButtonEventArgs e)
    {
        if (_surface is null || _surface.ActualWidth <= 0)
        {
            return;
        }

        Focus();
        var fraction = e.GetPosition(_surface).X / _surface.ActualWidth;
        Value = (int)Math.Ceiling(fraction * Maximum);
        e.Handled = true;
    }

    private void UpdateGlyphs()
    {
        if (_glyphs is null)
        {
            return;
        }

        _glyphs.Text = new string('★', Value) + new string('☆', Maximum - Value);
    }
}

The parts that make this a well-behaved WPF control, not just a class that works:

  • Static constructor and default style key. DefaultStyleKeyProperty.OverrideMetadata with the control’s own type tells WPF to look up the implicit style whose TargetType is StarRating. The overview requires this whenever the template is in a theme dictionary. Without it the control inherits its base class’s key and renders as a plain Control, which has no visual.

  • Dependency properties with callbacks, not logic in accessors. Maximum has a validation callback (IsValidMaximum rejects zero and negatives; WPF throws ArgumentException for an invalid value) and a changed callback that re-coerces Value. Value has a coerce callback that clamps to 0..Maximum. See Dependency Properties & Attached Properties for the callback order.

  • BindsTwoWayByDefault for the editable property. Consumers can write Value="{Binding Rating}" without a Mode, the way they do for TextBox.Text.

  • Routed event, not a CLR event. ValueChanged is registered with EventManager.RegisterRoutedEvent and a Bubble strategy. The overview lists the benefits: a parent such as a ListBox can handle it for every item, it works in an EventSetter and in an EventTrigger. The add and remove accessors call AddHandler and RemoveHandler; a protected virtual OnValueChanged raises it with RaiseEvent.

  • Template parts declared with TemplatePart. The attribute is applied to the class and tells template authors which named elements the code expects and of what type. It cannot be used in XAML. A template that omits a part must still work, which is why the code checks for null.

  • Detach before re-attach. OnApplyTemplate can run more than once (when the template changes). The code removes its handler from the old surface before attaching to the new one.

Naming GetTemplateChild and OnApplyTemplate here is intentional: GetTemplateChild is a protected method of FrameworkElement meant for a control to look up named template elements, as described on the Markup Extensions & Namescopes page.

The Default Template

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:Bookshelf.Controls">

    <Style TargetType="{x:Type local:StarRating}">
        <Setter Property="Focusable" Value="True" />
        <Setter Property="IsTabStop" Value="True" />
        <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
        <Setter Property="FontSize" Value="20" />
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:StarRating}">
                    <Border x:Name="PART_Surface"
                            Background="Transparent"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            Padding="{TemplateBinding Padding}">
                        <TextBlock x:Name="PART_Glyphs"
                                   Foreground="{TemplateBinding Foreground}"
                                   FontSize="{TemplateBinding FontSize}" />
                    </Border>
                    <ControlTemplate.Triggers>
                        <Trigger Property="IsKeyboardFocused" Value="True">
                            <Setter TargetName="PART_Surface" Property="BorderBrush"
                                    Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
                            <Setter TargetName="PART_Surface" Property="BorderThickness" Value="1" />
                        </Trigger>
                        <Trigger Property="IsEnabled" Value="False">
                            <Setter Property="Opacity" Value="0.5" />
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

Points about this dictionary:

  • The file is named Generic.xaml and sits in a Themes folder at the root of the project that contains the control. The folder and file names are fixed by convention, and the overview describes generic.xaml as the theme-level fallback used when no theme-specific file supplies the resource.

  • The Style has no x:Key. A Style with a TargetType and no key is implicit for that type; combined with the DefaultStyleKey, this is how WPF finds the control’s default style.

  • TemplateBinding links template properties to the control’s own properties (Foreground, FontSize, Padding). Every TemplateBinding is a property a consumer can set on StarRating without touching the template.

  • Trigger-driven looks (focus border, disabled opacity) live in the template, so a consumer who retemplates replaces them as well. That is the contract: the class owns behaviour, the template owns every pixel.

Declaring Where the Dictionary Lives

WPF needs to be told that the assembly carries control resources. This is the job of the ThemeInfoAttribute, an assembly-level attribute with two properties:

Property Meaning

ThemeDictionaryLocation

Where the theme-specific dictionaries (Themes/Aero2.NormalColor.xaml, and so on) are

GenericDictionaryLocation

Where the generic, not theme-specific, dictionary (Themes/Generic.xaml) is

The constructor takes the theme location first and the generic location second, and each is a ResourceDictionaryLocation: None, SourceAssembly (the same assembly as the control) or ExternalAssembly (a separate assembly, whose name must follow a naming scheme and be built with the same key information). A library that has only a generic dictionary in the same assembly declares:

using System.Windows;

[assembly: ThemeInfo(
    ResourceDictionaryLocation.None,
    ResourceDictionaryLocation.SourceAssembly)]

The "WPF Custom Control Library" project template generates this attribute, normally in AssemblyInfo.cs. In an SDK-style project, which generates its own assembly attributes, a file such as AssemblyInfo.cs can still be added to carry it. If the attribute is missing, the control builds and the default style is silently not found at run time.

How WPF Finds a Control’s Default Style

When a StarRating is created and no explicit Style is set, WPF resolves its default style in this order:

  1. The DefaultStyleKey is read (the control’s type, from the static constructor).

  2. The theme dictionary for the current Windows theme is searched, according to ThemeDictionaryLocation.

  3. If it has no entry, the generic dictionary is searched, according to GenericDictionaryLocation.

  4. The style found is applied beneath any Style the consumer sets and any local property values.

An application’s Application.Resources implicit style for StarRating overrides the default style because it is the control’s Style; a Style with BasedOn="{StaticResource {x:Type local:StarRating}}" extends the default one. Value precedence is explained on the dependency properties page. The theme files for Windows XP, Vista and later are still the documented names, but a current app normally ships only Generic.xaml; the Fluent theme that ships with .NET 9 and later is covered on the Themes & Fluent Design page of this section.

Template Parts and Visual States

The control above reads two parts by name. The wider convention has three elements:

Element Convention

Template part

A named element in the template, prefixed PART_, declared on the class with TemplatePart(Name, Type). Code finds it with GetTemplateChild in OnApplyTemplate.

Template binding

TemplateBinding for properties the template reads. Prefer it over code for anything that is a property.

Visual state

A named state in a named group, declared with TemplateVisualState, entered with VisualStateManager.GoToState. Authors define the storyboards for each state in the template.

The part contract is soft: a template author may omit a part, so the code must tolerate null. Visual states keep animation out of code. To add them to StarRating, apply [TemplateVisualState(Name = "Normal", GroupName = "CommonStates")] and MouseOver the same way, then call VisualStateManager.GoToState(this, "MouseOver", true) from OnMouseEnter and GoToState(this, "Normal", true) from OnMouseLeave. The template then supplies a VisualStateManager.VisualStateGroups block. Styles, Templates & Triggers goes through that machinery in detail.

Using the Control

<StackPanel Margin="16" xmlns:ctl="clr-namespace:Bookshelf.Controls;assembly=Bookshelf.Controls">
    <TextBlock Text="Your rating" />
    <ctl:StarRating Maximum="5"
                    Value="{Binding SelectedBook.Rating, Mode=TwoWay}"
                    ValueChanged="OnRatingChanged"
                    AutomationProperties.Name="Book rating" />
</StackPanel>

Because ValueChanged is a routed event, a parent can handle it for every StarRating inside it without wiring each one:

<ListBox ItemsSource="{Binding Books}"
         ctl:StarRating.ValueChanged="OnAnyRatingChanged" />

The type-prefixed attribute form works because routed events can be attached in XAML to any element in the route; see Routed Events & Input. The handler’s signature is void OnAnyRatingChanged(object sender, RoutedPropertyChangedEventArgs<int> e).

Retemplating It

A consuming app that wants circles instead of stars supplies its own template; the class keeps working because the two parts are present with the declared types:

<Style x:Key="BookRatingCircles" TargetType="ctl:StarRating">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="ctl:StarRating">
                <Border x:Name="PART_Surface" Background="Transparent" Padding="2">
                    <TextBlock x:Name="PART_Glyphs" FontFamily="Segoe UI Symbol"
                               Foreground="{TemplateBinding Foreground}" />
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

The glyphs the code writes (★ and ☆) are still stars, because the glyph choice lives in the class. Making the symbols themselves replaceable is the next design step: expose FilledGlyph and EmptyGlyph dependency properties, or replace the single TextBlock with an ItemsControl of items, one per star, driven by a template. The more decisions are in the template, the less code the class owns.

Automation Peers

A control that accepts input must be usable from UI Automation, which is how Narrator, Windows Voice Access and test tools drive WPF. WPF supports it through a tree of peer objects that parallels the element tree. The peer classes derive from AutomationPeer; by convention their names are the control name followed by AutomationPeer.

The built-in peers for Button, TextBox and Label exist; classes derived from Decorator (such as Border) and Panel (such as Grid and Canvas) have none. The base Control class has no corresponding peer, so a custom control that derives from Control should derive its peer from FrameworkElementAutomationPeer. Without an override, the control appears to automation clients only as a generic element.

Member Responsibility

UIElement.OnCreateAutomationPeer

Override on the control; return your peer. WPF calls it to obtain the peer.

GetClassNameCore

Returns the class name. A minimum requirement.

GetAutomationControlTypeCore

Returns an AutomationControlType. Another minimum requirement. Choose the most accurate type; Custom makes clients unable to anticipate structure and patterns.

GetPattern(PatternInterface)

Returns the object that implements a pattern, such as the RangeValue pattern. Falls back to the base implementation.

IsContentElementCore, IsControlElementCore

Say whether the element carries content or is interactive; both return true by default and help screen readers filter the tree.

RaiseAutomationEvent, RaisePropertyChangedEvent

Report state changes to subscribed clients.

A rating with a minimum, a maximum and a current value is a natural fit for the range value pattern. The peer implements IRangeValueProvider itself and answers GetPattern with itself:

using System.Windows.Automation;
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;

namespace Bookshelf.Controls;

public sealed class StarRatingAutomationPeer(StarRating owner)
    : FrameworkElementAutomationPeer(owner), IRangeValueProvider
{
    private StarRating Rating => (StarRating)Owner;

    public double Value => Rating.Value;

    public double Minimum => 0;

    public double Maximum => Rating.Maximum;

    public double SmallChange => 1;

    public double LargeChange => 1;

    public bool IsReadOnly => !Rating.IsEnabled;

    public void SetValue(double value)
    {
        if (!Rating.IsEnabled)
        {
            throw new ElementNotEnabledException();
        }

        Rating.Value = (int)Math.Round(value);
    }

    public override object? GetPattern(PatternInterface patternInterface) =>
        patternInterface == PatternInterface.RangeValue ? this : base.GetPattern(patternInterface);

    protected override string GetClassNameCore() => nameof(StarRating);

    protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Slider;

    public void RaiseValueChanged(int oldValue, int newValue)
    {
        if (ListenerExists(AutomationEvents.PropertyChanged))
        {
            RaisePropertyChangedEvent(
                RangeValuePatternIdentifiers.ValueProperty, (double)oldValue, (double)newValue);
        }
    }
}

Notes on the peer:

  • The listener check before RaisePropertyChangedEvent is the optimisation the documentation shows: raise the event only when a client has subscribed to it, so an unobserved control pays no cost. The control’s OnValueChanged callback above gets the peer with UIElementAutomationPeer.FromElement, which returns null when no client has created one, so no peer is created just to be notified.

  • A consuming page can override the peer’s defaults in XAML with AutomationProperties.Name and HelpText, which is why the usage example above sets a name. Provide good defaults and let the consumer refine them.

  • ElementNotEnabledException is the exception automation clients expect when a disabled control is operated; the provider interfaces are in System.Windows.Automation.Provider. The details of Narrator testing and Accessibility Insights belong to the Accessibility & UI Automation page of this section.

  • Peers are for controls, not for every element: a pure container needs none.

WPF, WinUI 3 and WinForms

Aspect WPF WinUI 3 Windows Forms

Composed fragment

UserControl (XAML + code-behind)

UserControl (XAML + code-behind)

UserControl (designer surface + code-behind)

Library control

Derive from Control, default template in Themes/Generic.xaml, DefaultStyleKey in the static constructor

Templated Control item template; Control base, DefaultStyleKey set in the instance constructor, Themes/Generic.xaml

Derive from Control and override OnPaint, or extend an existing control; there is no template

Properties

DependencyProperty.Register with FrameworkPropertyMetadata

DependencyProperty.Register with PropertyMetadata

Plain CLR properties with [Browsable], [Category], [DefaultValue] attributes

Events

Routed events (RegisterRoutedEvent)

Routed events for input; ordinary CLR events for your own

Ordinary CLR events

Template parts

TemplatePart, GetTemplateChild in OnApplyTemplate

OnApplyTemplate, GetTemplateChild

Not applicable

Automation

OnCreateAutomationPeer returning an AutomationPeer

OnCreateAutomationPeer returning an AutomationPeer

AccessibleObject via CreateAccessibilityInstance

The WinUI 3 documentation builds its walkthrough around a BgLabelControl: the constructor sets DefaultStyleKey = typeof(BgLabelControl), the dependency property is registered with a PropertyMetadata, and the default Style in Themes\Generic.xaml uses TemplateBinding. Two details differ from WPF: the default style key is assigned in the constructor, not in a static constructor, and the namespace in the XAML uses the using: form. The WinUI version of this page’s control would start like this:

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Bookshelf.Controls;

public sealed class StarRating : Control
{
    public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
        nameof(Value), typeof(int), typeof(StarRating),
        new PropertyMetadata(0, OnValueChanged));

    public StarRating()
    {
        DefaultStyleKey = typeof(StarRating);
    }

    public int Value
    {
        get => (int)GetValue(ValueProperty);
        set => SetValue(ValueProperty, value);
    }

    private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        // Update the template parts here.
    }
}

In WinUI the default template XAML is <ResourceDictionary xmlns:local="using:Bookshelf.Controls" ...> with <Style TargetType="local:StarRating">. WPF requires clr-namespace: and {x:Type}, and a coerce callback exists only in WPF’s FrameworkPropertyMetadata; WinUI’s PropertyMetadata has a changed callback only, so clamping has to be done in the changed callback or the setter logic.

Packaging a Control Library

Controls that two or more apps share belong in a class library, so they version and ship independently.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>Bookshelf.Controls</RootNamespace>
    <AssemblyName>Bookshelf.Controls</AssemblyName>

    <!-- NuGet metadata -->
    <PackageId>Bookshelf.Controls</PackageId>
    <Version>1.0.0</Version>
    <Description>Reusable WPF controls for Bookshelf Desktop.</Description>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>

</Project>

The library project holds BookCard.xaml(.cs), StarRating.cs, StarRatingAutomationPeer.cs, Themes/Generic.xaml and the ThemeInfo attribute. Packaging notes:

  • UseWPF and the -windows target. The Microsoft.NET.Sdk with UseWPF=true and a -windows target framework moniker is the SDK-style form of a WPF library; see Project Structure & Build for the project file anatomy. XAML files in the project compile as Page items by default, including Themes/Generic.xaml.

  • No App.xaml in a library. The overview points out that an assembly containing only controls has no Application object, so there is no App.xaml for global resources. Put shared resources at the element level (a dictionary merged in the control) or at the theme level (Generic.xaml).

  • Theme-level resources use ComponentResourceKey. A resource defined in the generic dictionary and meant to be reused (a brush shared by several controls) is keyed with ComponentResourceKey, naming the type in the target assembly and a resource id. The consumer then writes {StaticResource {ComponentResourceKey TypeInTargetAssembly={x:Type local:StarRating}, ResourceId=StarBrush}}. Element-level resources may use simple string keys.

  • Avoid merging the same dictionary repeatedly. Merging a shared dictionary from XAML in every control creates one ResourceDictionary instance per use. The overview shows a small static class that loads it once with Application.LoadComponent and returns the cached instance, and merging it in the constructor before InitializeComponent.

  • Namespaces and XmlnsDefinition. Consumers can map a library with clr-namespace and assembly. To give a library a single stable XML namespace, add XmlnsDefinition attributes in the library. This page does not use them.

  • Version and sign. Use semantic versioning for the package, and keep the public surface (dependency property names, part names and types, event names) stable: they are the contract with every consumer’s custom template.

The consuming app refers to the library with a project or package reference and uses the control with the clr-namespace:...;assembly=... prefix shown above.

Choosing Where Logic Lives

Logic Put it in Reason

What the control is (state, constraints)

Dependency properties with coerce and validate callbacks

Works with binding, styling and animation

What the control does

Overridden On* methods and routed events

Consumers handle events without replacing the template

What the control looks like

ControlTemplate with TemplateBinding and triggers

Replaceable by consumers

What must be found by name

TemplatePart and GetTemplateChild

Explicit, optional contract

What assistive technology sees

Automation peer

Same logic serves Narrator and test tools

Pitfalls

  • DataContext = this in a UserControl. It replaces the inherited DataContext, so the consumer’s bindings on the control break without an error you can see. Bind inside the control with ElementName or RelativeSource.

  • Missing DefaultStyleKey override. A custom control with a template in Generic.xaml but no static-constructor override shows nothing. Check this first when a new control renders empty.

  • Missing or wrong ThemeInfo. The generic dictionary is not found when the attribute is absent or points to the wrong location. In a control library, verify the file path (Themes/Generic.xaml, exact folder name) and the attribute.

  • Logic in property wrappers. Callbacks run for every path that sets the value; wrapper code runs only when somebody uses the CLR property.

  • Hard-coding template details in code. Looking up parts and restyling them in code makes retemplating hard. Treat the part contract as optional and check for null.

  • Forgetting to detach handlers on a re-applied template. OnApplyTemplate can run again; the old elements must stop raising into your control.

  • Forgetting automation. A mouse-only custom control is invisible to screen readers and unreachable from keyboards. Support keys, focus, and an automation peer.

  • Calling GetValue-based properties from the constructor with expensive work. Property metadata defaults and inherited values are not final at that point; defer work to the changed callbacks or OnApplyTemplate.

  • Colours baked into the template. Hard-coded colours ignore high contrast and dark themes; use SystemColors keys or application brush resources with DynamicResource.

  • A UserControl used as a templated control. Setting Template on a UserControl instance to restyle it replaces the ContentPresenter that shows its content, and the card disappears. If you need that, the control should have been a Control.

  • Binding a Value coerced in a TwoWay binding. Coercion can change the value after the source set it. The binding does not push the coerced value back by itself unless the source is updated; test with out-of-range inputs.