Accessibility & UI Automation
|
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. |
An accessible application is one that people can use without a mouse, without seeing the screen, or with the display settings they need. On Windows, almost all of that is delivered through one framework, Microsoft UI Automation (UIA). UIA lets a program describe its user interface as a tree of elements, each with a name, a role, a set of properties and a set of behaviours called control patterns. Screen readers such as Narrator, voice-control software and automated test tools all read that tree instead of looking at pixels. In WPF the controls build the tree for you, so the work of an application developer is to give the elements meaningful names, keep keyboard navigation sensible, respect high contrast, and describe custom controls to UIA. This page covers those four jobs, then shows how to read the tree from test code and how to check an app with Narrator and Accessibility Insights.
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 UI Automation and
AutomationPropertiesAPIs used here are the same on .NET Framework 4.8 and on .NET 8, 9 and 10; the .NET 10 WPF release notes do not list a change in this area. -
CommunityToolkit.Mvvm 8.4.2 for the view-model code in the examples.
-
Visual Studio 2026 18.11.1 and Windows App SDK 2.5.1 (the latter only for the WinUI 3 comparison).
-
Windows 10 and Windows 11, with Narrator and Accessibility Insights for Windows as the checking tools.
The running scenario is Bookshelf Desktop. The page makes its edit-book dialog and its main window usable with a
keyboard and a screen reader, adds a star-rating control with its own automation peer, announces "Book saved" to
screen-reader users, and tests the result. The scenario types (Book, BookViewModel, BooksViewModel,
IBookRepository, IDialogService) are the ones defined on the Commands page of this section.
What Microsoft Learn Provides, and What It Does Not
Microsoft Learn documents the pieces of this topic in separate places:
-
The UI Automation Overview in the .NET Framework documentation explains providers, clients, the element tree, properties, patterns and events. It carries a notice that it is aimed at .NET Framework developers who use the managed
System.Windows.Automationclasses, and states that those classes work on versions of .NET Core starting with .NET Core 3.0. -
UI Automation of a Custom Control in the WPF documentation explains automation peers.
-
The
AutomationPropertiesandKeyboardNavigationclasses are documented as API reference. -
The Accessibility overview, Expose basic accessibility information and Accessibility testing articles are written for XAML apps on the Windows App SDK (WinUI), but the concepts (accessible name, role, value, Narrator commands, the testing tools) apply to any UIA-based app.
There is no WPF accessibility hub page on Microsoft Learn: the older /dotnet/desktop/wpf/accessibility address
returns 404. This page therefore combines the WPF API reference, the custom-control article and the shared Windows
guidance. Where it shows a pattern that no Learn page demonstrates for WPF (the live-region announcement, the
UIA client test, the star-rating control), it is built on the documented APIs and labelled as such.
How UI Automation Works
UI Automation has two sides. A provider is the application or control that exposes information about its UI. A
client is a program that reads that information and can send input: a screen reader, a test tool, a voice-control
program. The UIA core sits between them and hides the framework differences; the Learn overview gives the example that
the Content of a WPF button, the Caption of a Win32 button and the ALT text of an HTML image all appear to
clients as the same Name property.
WPF controls are UIA providers out of the box. Each control that needs to be visible to assistive technology creates
an automation peer (a class that derives from AutomationPeer, by convention named after the control, such as
ButtonAutomationPeer) when WPF calls its protected OnCreateAutomationPeer method. The peers form a tree that
parallels the element tree. Elements that carry no information of their own, such as Border, Grid, Canvas and
other Decorator and Panel types, have no peer and do not appear in the tree at all.
The three views of the tree
Clients can look at the tree in three filtered views:
| View | Contents |
|---|---|
Raw |
Every automation element, including structural ones. |
Control |
The elements a user understands as interactive or as part of the structure of the UI (buttons, edit boxes, list items, groups). This is the view most assistive technology and inspection tools use. |
Content |
The elements that carry the information the user reads or edits (text, values, list items), without most of the chrome. |
A peer decides where it appears through IsControlElementCore and IsContentElementCore; the Learn article says both
return true by default and that a peer which only forwards a pattern to a sub-element can return false from
IsControlElementCore to stay out of the tree. The built-in ScrollViewerAutomationPeer does exactly that, so the
ScrollViewer inside a ListBox is invisible to clients while its scrolling still works through the list box.
Source: UI Automation Overview
Properties, patterns and events
An element exposes three kinds of information:
| Kind | Meaning | Examples |
|---|---|---|
Properties |
Descriptive facts about the element, readable at any time. |
|
Control patterns |
Behaviours a client can use, each exposed through a provider interface in |
|
Events |
Notifications a client subscribes to. |
Property changed, structure changed, focus changed, invoked, selection changed, live region changed |
Control types and control patterns are not in one-to-one correspondence: a pattern can be supported by several control
types, and one control can support several patterns. The Learn overview gives the combo box as an example, with one
pattern for expanding and collapsing and another for selection. In a WPF app the pattern comes from the peer’s
GetPattern(PatternInterface) method, and a client that asks a button for the Invoke pattern receives an object that
implements IInvokeProvider.
For Bookshelf Desktop the built-in controls map as follows. The mapping is the usual one for these controls; check any specific control with a UIA inspector, because WPF may expose a pattern that this table omits.
| Bookshelf Desktop element | UIA control type | Typical patterns |
|---|---|---|
"Save" |
Button |
|
Title |
Edit |
|
"Favorite" |
CheckBox |
|
Books |
List |
|
One book row |
ListItem |
|
Books |
DataGrid |
|
Genre |
ComboBox |
|
Author |
TreeItem |
|
Giving Elements an Accessible Name
The most important piece of information a screen reader needs is the accessible name of each element. The Learn
guidance names three things every element should expose: a name, a role and, where it has one, a value. The role comes
from the control type and the value from the Value pattern, so for built-in controls the only thing left to supply is
usually the name.
Many controls derive a name from what they show. A Button takes its name from its Content; a TextBlock from its
text. The cases that need help are the ones with no inner text:
-
an
Image, an icon-only button or a chart: there is no text to derive a name from; -
a
TextBox,ComboBoxorDatePicker: the visible label is a separateTextBlockorLabel, and the control is anonymous unless something connects them; -
a data-bound item: the name comes from the data object, which may not be what the user sees.
Naming with AutomationProperties.Name
AutomationProperties.Name is an attached property that overrides the name a control would otherwise report. Use it
for icon-only buttons and images:
<Button Command="{Binding DeleteCommand}"
AutomationProperties.Name="Delete book"
AutomationProperties.HelpText="Removes the selected book from the shelf."
ToolTip="Delete book">
<Path Data="M3,3 L13,13 M13,3 L3,13" Stroke="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" StrokeThickness="2" />
</Button>
Source: AutomationProperties class
HelpText carries supplementary information. The API reference notes that it is generally the same text as the
tooltip. Narrator reads it on demand rather than as part of the default announcement, so keep the name short and
complete on its own. The Learn guidance also warns against repeating the role in the name (writing "Delete book button"
produces "Delete book button button", because assistive technology appends the control type to the name).
Naming with a label
A form field should be named by its visible label, so that what a user sees and what the screen reader says cannot
drift apart. WPF offers two ways. The first is a Label whose Target is the input control; the label then supplies
the name and also the access key:
<Label Grid.Row="0" Grid.Column="0" Content="_Title:" Target="{Binding ElementName=TitleBox}" />
<TextBox x:Name="TitleBox" Grid.Row="0" Grid.Column="1"
Text="{Binding Title, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
Source: Label.Target property
The underscore in _Title: makes Alt+T move the focus to the text box, which is also the keyboard shortcut that
sighted keyboard users see underlined. The second way is AutomationProperties.LabeledBy, for the case where the label
is a TextBlock (which is not a Label and has no Target):
<TextBlock x:Name="AuthorLabel" Grid.Row="1" Grid.Column="0" Text="Author" />
<TextBox Grid.Row="1" Grid.Column="1"
AutomationProperties.LabeledBy="{Binding ElementName=AuthorLabel}"
Text="{Binding Author, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
Source: Expose basic accessibility information (the same LabeledBy pattern, shown there for WinUI)
LabeledBy is typed UIElement in WPF, which is why the binding refers to the label by ElementName. The label text
must not itself be hard-coded in a second place: the name should come from the one string the user reads. Localisation
of the visible label text is covered by the Globalization & Localization page of this section; when you set
AutomationProperties.Name explicitly, take the value from the same resources.
Naming data items
When a ListBox, ListView or DataGrid shows view models, WPF wraps each data item in an ItemAutomationPeer (the
Learn reference describes it as exposing a data item in an Items collection to UI Automation). For an item
template that contains several elements the name is not obvious, and a template that shows a composite of title, author
and an icon can produce a name the user never sees on screen. Set the name explicitly on the container so it reads
the way the row looks:
<ListBox ItemsSource="{Binding Books}" SelectedItem="{Binding SelectedBook}"
AutomationProperties.Name="Books on the shelf">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="AutomationProperties.Name">
<Setter.Value>
<MultiBinding StringFormat="{}{0} by {1}">
<Binding Path="Title" />
<Binding Path="Author" />
</MultiBinding>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" Margin="0,0,8,0" />
<TextBlock Text="{Binding Author}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Source: ItemAutomationPeer class
The ItemContainerStyle and DataTemplate mechanics are explained on the Converters, Collection Views & Data Templates page (Converters, Collection Views & Data Templates),
and virtualised lists are covered in Lists, Grids & Trees. When the list is
virtualised, rows that are not on screen have an ItemAutomationPeer but no element; the peer implements
IVirtualizedItemProvider, whose Realize method makes the row real when a client needs it.
The AutomationProperties catalogue
AutomationProperties is a static class of attached properties, identified in the API reference as part of
System.Windows.Automation (assembly PresentationCore). The ones an application uses:
| Property | Purpose |
|---|---|
|
The accessible name. Overrides the name the control derives itself. |
|
Supplementary description, generally the same as the tooltip. |
|
The |
|
A string that identifies the element uniquely. Not spoken; it is for test code (see below). |
|
How a live region notifies the client of changes: |
|
The keyboard shortcut and the access key of the element, for clients that announce them. |
|
Whether the field must be filled in. Set it on mandatory fields such as the book title. |
|
A description of the status of an item (for example "Unsaved changes") and of what kind of item it is. |
|
Mark an element as a header of a table, so readers can announce row and column headings. |
|
The ordinal position of an element among its siblings and the number of siblings, for composite items not built from a standard list. |
|
Marks a text element as a heading (an |
|
Marks the element as a dialog. |
|
How |
All of these exist on .NET 10. AutomationProperties.AccessibilityView, which WinUI offers to move an element between
the raw, control and content views, is not a WPF property; in WPF the same decision is made in a peer through
IsControlElementCore and IsContentElementCore.
A form field with the properties that matter looks like this:
<TextBox x:Name="TitleBox"
AutomationProperties.AutomationId="BookTitleBox"
AutomationProperties.IsRequiredForForm="True"
AutomationProperties.HelpText="The title as printed on the cover."
Text="{Binding Title, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
Source: AutomationProperties class
The Bookshelf Desktop Edit Dialog, Made Accessible
The edit dialog combines the rules above. It uses Label.Target for the fields, gives the buttons stable automation
ids, and sets the initial focus so that a keyboard or screen-reader user starts on the first field rather than on a
caption or on nothing.
<Window x:Class="Bookshelf.Desktop.Views.EditBookWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Edit book" SizeToContent="WidthAndHeight" ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
FocusManager.FocusedElement="{Binding ElementName=TitleBox}"
AutomationProperties.IsDialog="True">
<Grid Margin="16" KeyboardNavigation.TabNavigation="Cycle">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="260" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Label Grid.Row="0" Content="_Title:" Target="{Binding ElementName=TitleBox}" />
<TextBox x:Name="TitleBox" Grid.Row="0" Grid.Column="1" Margin="8,0,0,8"
AutomationProperties.AutomationId="BookTitleBox"
AutomationProperties.IsRequiredForForm="True"
Text="{Binding Title, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
<Label Grid.Row="1" Content="_Author:" Target="{Binding ElementName=AuthorBox}" />
<TextBox x:Name="AuthorBox" Grid.Row="1" Grid.Column="1" Margin="8,0,0,8"
AutomationProperties.AutomationId="BookAuthorBox"
AutomationProperties.IsRequiredForForm="True"
Text="{Binding Author, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
<StackPanel Grid.Row="2" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="_Save" IsDefault="True" Margin="0,0,8,0" MinWidth="80"
AutomationProperties.AutomationId="SaveButton"
Command="{Binding SaveCommand}" />
<Button Content="Cancel" IsCancel="True" MinWidth="80"
AutomationProperties.AutomationId="CancelButton" />
</StackPanel>
</Grid>
</Window>
Source: Dialog boxes overview (WPF)
Three details are easy to miss. IsDefault and IsCancel make Enter and Esc work, which is both a keyboard shortcut
and a screen-reader convenience. FocusManager.FocusedElement sets the logical focus that the window gives to the
text box when it becomes active (see the focus discussion below). The binding errors reported by
ValidatesOnNotifyDataErrors are visual by default; the Validation page of this section
(Validation) shows the error template that draws them, and the next section shows how
to make a screen reader announce them too.
Announcing Changes: Live Regions and Notifications
When something changes without moving the keyboard focus (a status message appears, a save completes, a list reloads), a screen reader says nothing unless the app tells it. UIA has two mechanisms for this.
A live region is an element that tells clients its content may change and how urgently to announce it. Set
AutomationProperties.LiveSetting to Polite (announce when the user is idle) or Assertive (interrupt), and raise
a live-region-changed event when the text changes. An automation notification is a one-off announcement that is not
tied to an element’s content; the peer raises it with RaiseNotificationEvent.
<StatusBar DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText"
Text="{Binding StatusMessage}"
AutomationProperties.LiveSetting="Polite" />
</StatusBar>
using System.Windows;
using System.Windows.Automation;
using System.Windows.Automation.Peers;
using System.Windows.Controls;
namespace Bookshelf.Desktop.Accessibility;
/// <summary>Announces messages to screen readers without moving the keyboard focus.</summary>
public static class Announcer
{
/// <summary>Raises a live-region-changed event for an element marked with a LiveSetting.</summary>
public static void RaiseLiveRegionChanged(UIElement liveRegion)
{
if (UIElementAutomationPeer.FromElement(liveRegion) is { } peer)
{
peer.RaiseAutomationEvent(AutomationEvents.LiveRegionChanged);
}
}
/// <summary>Raises a notification event that a screen reader reads out as given.</summary>
public static void Notify(UIElement source, string message)
{
var peer = UIElementAutomationPeer.FromElement(source)
?? UIElementAutomationPeer.CreatePeerForElement(source);
peer?.RaiseNotificationEvent(
AutomationNotificationKind.ActionCompleted,
AutomationNotificationProcessing.ImportantMostRecent,
message,
activityId: "BookshelfStatus");
}
}
UIElementAutomationPeer.FromElement returns the existing peer, or null if none has been created because no client is
listening; CreatePeerForElement creates one on demand. The Learn custom-control article tells control authors to
call AutomationPeer.ListenerExists before raising events so the work is skipped when no assistive technology is
attached. The RaiseNotificationEvent method is listed in the API reference on AutomationPeer (it is the member that
takes a notification kind, a processing mode, the text and an activity id); the page does not state which screen
readers honour which processing modes, so test the announcement in Narrator rather than assuming it.
In BooksViewModel the message comes from a property, and the view calls the announcer when it changes:
using System.ComponentModel;
using Bookshelf.Desktop.Accessibility;
using Bookshelf.Desktop.ViewModels;
namespace Bookshelf.Desktop;
public partial class MainWindow
{
public MainWindow(BooksViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
viewModel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(BooksViewModel.StatusMessage)
&& !string.IsNullOrEmpty(viewModel.StatusMessage))
{
Announcer.Notify(StatusText, viewModel.StatusMessage);
}
};
}
}
A view model must not reference UI types, which is why the announcement lives in the view; see the MVVM pages of this section for the pattern.
Keyboard Navigation and Focus
Every command in a Windows app must be reachable without a pointer. For most controls WPF supplies this: Tab and
Shift+Tab move between tab stops, arrow keys move inside lists, menus and trees, and Space or Enter activates the focused
button. The KeyboardNavigation class implements the navigation for Tab, Shift+Tab, Ctrl+Tab, Ctrl+Shift+Tab and the
arrow keys. Its attached properties let you adjust it:
| Attached property | Effect |
|---|---|
|
The order of an element among its siblings. Lower numbers come first. Elements without one follow the document order. |
|
Whether Tab stops on the element. Set it to |
|
What Tab does at the edges of a container ( |
|
The same, for Ctrl+Tab. |
|
The same, for the arrow keys. |
|
Whether the control wants the Enter key. |
Source: KeyboardNavigation class
The dialog above sets TabNavigation="Cycle" on its grid, so Tab from the Cancel button returns to the title box; in
a modal dialog that keeps focus from escaping to the parent window.
The default tab order is the order of elements in the markup, which usually matches the visual order. Problems appear
when the layout is re-ordered with Grid.Row, DockPanel or a RenderTransform so that the visual order and the
markup order differ. Fix the markup order first; use TabIndex only for the exceptions, because it is easy to leave a
gap or a duplicate.
Logical focus and keyboard focus
WPF distinguishes keyboard focus (the one element that receives key events, Keyboard.FocusedElement) from logical
focus (the element a focus scope remembers, FocusManager.GetFocusedElement). A Window is a focus scope. Setting
FocusManager.FocusedElement in markup, as the dialog does, sets the logical focus; WPF gives the element keyboard focus
when the window is activated. The distinction and the event flow are covered on the
Routed Events & Input page. In code, move focus explicitly after an
action that removes the focused element:
private void OnBookDeleted()
{
// The focused row disappeared with the book; give the focus back to the list so a keyboard user is not lost.
BooksList.Focus();
}
Source: Focus overview (WPF)
Shortcuts and access keys
A mouse-only action is an accessibility bug. Bind every toolbar button to a command and give the command a keyboard
gesture: KeyBinding for application-wide shortcuts and an underscore (_) in the Header or Content for access
keys. The Commands page of this section (Commands) shows InputBinding, KeyBinding
and the built-in ApplicationCommands, which already carry the standard gestures (Ctrl+C, Ctrl+S, F1 and so on) and
text such as "Copy" that screen readers read. Expose the gesture to assistive technology with
AutomationProperties.AcceleratorKey and AutomationProperties.AccessKey when you define a custom one.
Focus visuals
A keyboard user must be able to see where the focus is. WPF controls draw a dashed rectangle through
FrameworkElement.FocusVisualStyle. A common mistake is to remove it in a style because it looks untidy:
<!-- Accessibility bug: keyboard users can no longer see the focused element -->
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
If the default look does not suit the application, replace it with a visible one rather than with nothing:
<Style x:Key="BookshelfFocusVisual">
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate>
<Rectangle Margin="-2" RadiusX="3" RadiusY="3" StrokeThickness="2"
Stroke="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Button">
<Setter Property="FocusVisualStyle" Value="{StaticResource BookshelfFocusVisual}" />
</Style>
Source: Styles and templates (WPF)
A custom ControlTemplate replaces the control’s whole visual appearance, so a focus indicator that existed in the
default template is lost when the template is replaced. Add a Trigger on IsKeyboardFocused (or
IsKeyboardFocusWithin for containers) to the new template. The styling and trigger rules are on the
Resources & Resource Dictionaries page and in the
Styles, Templates & Triggers material of this section.
High Contrast and Display Settings
Windows high-contrast themes replace the application’s colours with a small system palette chosen by the user. A WPF app respects them as long as it draws with system brushes and does not hard-code colours. The pieces:
-
SystemParameters.HighContrastis a staticboolthat istruewhen the high-contrast option is selected. The API reference states that it maps toSPI_GETHIGHCONTRASTandHCF_HIGHCONTRASTONin the Win32SystemParametersInfofunction, and that it is usable from XAML as{x:Static SystemParameters.HighContrast}. -
SystemColorsexposes the current palette as colours and brushes, with matching resource keys (for exampleSystemColors.WindowBrushKey,ControlTextBrushKey,HighlightBrushKey). Referring to them withDynamicResourcelets the brush change when the theme changes while the app is running.
<Border Background="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"
BorderBrush="{DynamicResource {x:Static SystemColors.ActiveBorderBrushKey}}"
BorderThickness="1">
<TextBlock Text="{Binding StatusMessage}"
Foreground="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" />
</Border>
When the application has its own palette (a light theme with a coloured selection), switch to system brushes only while high contrast is on. Do the switch in a resource dictionary swap in code, using the merged-dictionary mechanism on the Resources & Resource Dictionaries page:
using System.Windows;
namespace Bookshelf.Desktop.Theming;
public static class HighContrastSupport
{
private static readonly Uri HighContrastDictionary =
new("pack://application:,,,/Bookshelf.Desktop;component/Themes/HighContrast.xaml");
public static void Attach(Application app)
{
Apply(app);
SystemParameters.StaticPropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(SystemParameters.HighContrast))
{
app.Dispatcher.Invoke(() => Apply(app));
}
};
}
private static void Apply(Application app)
{
var merged = app.Resources.MergedDictionaries;
var existing = merged.FirstOrDefault(d => d.Source == HighContrastDictionary);
if (SystemParameters.HighContrast && existing is null)
{
merged.Add(new ResourceDictionary { Source = HighContrastDictionary });
}
else if (!SystemParameters.HighContrast && existing is not null)
{
merged.Remove(existing);
}
}
}
Source: Accessibility testing (the "Verify your app in high contrast" procedure)
HighContrast.xaml redefines the application’s brush keys (BookshelfAccentBrush, BookshelfSurfaceBrush) as
SystemColors brushes. Fluent theming for WPF, which in .NET 9 and 10 supplies its own colour handling, is covered on
the Themes & Fluent Design page of this section; whichever theme is active, test with a real high-contrast theme.
Two other display settings matter and cost nothing if the layout is right. Text scaling and DPI: use relative
layout (Auto, *, SizeToContent) instead of fixed sizes so larger system text fits; the Layout & Panels page
of this section explains the layout passes (Layout & Panels). Colour is
never the only cue: the validation error template should add an icon and text, not just a red border, because users
with colour-vision deficiencies cannot rely on red. The Learn testing article points to the WCAG 2.2 G18 technique for
checking the contrast ratio of visible text.
Custom Controls and Automation Peers
A control derived from Control (see the User Controls & Custom Controls page of this section) has no automation peer
of its own: the Learn article states that the base Control class has no corresponding peer and that a custom control
that needs one should derive its peer from FrameworkElementAutomationPeer. Without a peer, UIA sees an unnamed
element with no role and no patterns, and a screen reader cannot use the control.
The steps, from the Learn article, are:
-
Override
OnCreateAutomationPeerin the control and return a peer. -
Make the peer derive from
AutomationPeerdirectly or indirectly (usually from the peer of the control’s base class, or fromFrameworkElementAutomationPeer). -
Override
GetPattern(PatternInterface)to return the object that implements each pattern the control supports. -
Override the
Coremethods that differ from the base peer. At a minimum,GetClassNameCoreandGetAutomationControlTypeCore. -
Implement
IsContentElementCoreandIsControlElementCoreif the defaults (bothtrue) are wrong. -
Raise events when state changes, with
RaiseAutomationEventandRaisePropertyChangedEvent.
For GetAutomationControlTypeCore, return the most specific control type that accurately describes the control.
ControlType.Custom is allowed, but the article warns that it needs extra provider work and that clients cannot
anticipate the control structure, keyboard interaction or patterns.
A star-rating control
Bookshelf Desktop lets the user rate a book from 0 to 5 stars. The control is a Control with a Value dependency
property, keyboard support, and a RangeValue pattern so that a screen reader says "Rating, slider, 3" and can change
the value. A slider is the nearest UIA control type for a bounded numeric value, and the Learn article’s own example of
a numeric spinner uses the RangeValue pattern in the same way. The dependency-property mechanics (registration,
metadata, callbacks) are explained on the Dependency Properties & Attached Properties page.
using System.Windows;
using System.Windows.Automation;
using System.Windows.Automation.Peers;
using System.Windows.Controls;
using System.Windows.Input;
namespace Bookshelf.Desktop.Controls;
public class RatingControl : Control
{
public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
nameof(Value),
typeof(double),
typeof(RatingControl),
new FrameworkPropertyMetadata(
0d,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue));
public static readonly DependencyProperty MaximumProperty = DependencyProperty.Register(
nameof(Maximum), typeof(double), typeof(RatingControl), new PropertyMetadata(5d));
static RatingControl()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(RatingControl), new FrameworkPropertyMetadata(typeof(RatingControl)));
FocusableProperty.OverrideMetadata(typeof(RatingControl), new FrameworkPropertyMetadata(true));
}
public double Value
{
get => (double)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public double Maximum
{
get => (double)GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
protected override AutomationPeer OnCreateAutomationPeer() => new RatingControlAutomationPeer(this);
protected override void OnKeyDown(KeyEventArgs e)
{
switch (e.Key)
{
case Key.Right or Key.Up:
Value += 1;
e.Handled = true;
break;
case Key.Left or Key.Down:
Value -= 1;
e.Handled = true;
break;
case Key.Home:
Value = 0;
e.Handled = true;
break;
case Key.End:
Value = Maximum;
e.Handled = true;
break;
}
base.OnKeyDown(e);
}
private static object CoerceValue(DependencyObject d, object baseValue)
{
var control = (RatingControl)d;
return Math.Clamp(Math.Round((double)baseValue), 0, control.Maximum);
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
// Tell automation clients, but only if some client is listening.
if (AutomationPeer.ListenerExists(AutomationEvents.PropertyChanged)
&& UIElementAutomationPeer.FromElement((RatingControl)d) is RatingControlAutomationPeer peer)
{
peer.RaisePropertyChangedEvent(
RangeValuePatternIdentifiers.ValueProperty,
(double)e.OldValue,
(double)e.NewValue);
}
}
}
The peer is a FrameworkElementAutomationPeer that implements IRangeValueProvider, the provider interface for the
RangeValue pattern:
using System.Windows.Automation;
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;
namespace Bookshelf.Desktop.Controls;
public sealed class RatingControlAutomationPeer(RatingControl owner)
: FrameworkElementAutomationPeer(owner), IRangeValueProvider
{
private RatingControl Rating => (RatingControl)Owner;
protected override string GetClassNameCore() => nameof(RatingControl);
protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Slider;
protected override string GetNameCore()
{
// An explicit AutomationProperties.Name wins; fall back to a sensible default.
var name = base.GetNameCore();
return string.IsNullOrEmpty(name) ? "Rating" : name;
}
public override object? GetPattern(PatternInterface patternInterface) =>
patternInterface == PatternInterface.RangeValue ? this : base.GetPattern(patternInterface);
// IRangeValueProvider
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 (!IsEnabled())
{
throw new ElementNotEnabledException();
}
if (value < Minimum || value > Maximum)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
Rating.Value = value;
}
}
The Learn article shows GetPattern, the Core overrides and the event raising; it links to a NumericUpDown sample for
a full IRangeValueProvider implementation, and this peer follows the same structure with Bookshelf names. Using the
control looks like any other:
<controls:RatingControl Value="{Binding Rating, Mode=TwoWay}"
AutomationProperties.Name="Rating, 0 to 5 stars"
AutomationProperties.AutomationId="BookRating" />
Source: AutomationProperties class
The rating control also needs a template in Themes/Generic.xaml that draws the stars with brushes that follow the
high-contrast rules above; the template, the ThemeInfo attribute and the packaging of control libraries are the
subject of the User Controls & Custom Controls page.
Built-in peers to reuse
Before writing a peer from scratch, check whether the base control already has one. The built-in peers follow the
control names: ButtonAutomationPeer, TextBoxAutomationPeer, ListBoxAutomationPeer, ScrollViewerAutomationPeer,
RangeBaseAutomationPeer (which supplies the RangeValue implementation for controls derived from RangeBase, such as
Slider and ProgressBar), and ItemAutomationPeer for data items. A custom control that derives from RangeBase
or ButtonBase should return the matching peer type, as the Learn article says for ButtonBase and
ButtonBaseAutomationPeer, and gets most of the work for free.
Reading the Automation Tree from Code
Anything a screen reader sees, a program can see. That makes UI Automation also the basis of UI tests. There are two ways to use it.
Testing a peer in process
Because the peer is an ordinary class, a unit test can ask a control what it would tell UIA, without any UI tool. WPF
elements need a single-threaded apartment (STA) thread and a Dispatcher, so the test creates the control on an STA
thread:
using System.Windows.Automation;
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;
using Bookshelf.Desktop.Controls;
using Xunit;
namespace Bookshelf.Desktop.Tests;
public class RatingControlAutomationTests
{
[Fact]
public void Peer_exposes_range_value_and_slider_role() => RunOnSta(() =>
{
var control = new RatingControl { Value = 3 };
var peer = UIElementAutomationPeer.CreatePeerForElement(control);
var range = Assert.IsAssignableFrom<IRangeValueProvider>(peer.GetPattern(PatternInterface.RangeValue));
Assert.Equal(AutomationControlType.Slider, peer.GetAutomationControlType());
Assert.Equal(3, range.Value);
Assert.Equal(5, range.Maximum);
range.SetValue(4);
Assert.Equal(4, control.Value);
});
[Fact]
public void Explicit_name_overrides_the_default() => RunOnSta(() =>
{
var control = new RatingControl();
AutomationProperties.SetName(control, "Book rating");
var peer = UIElementAutomationPeer.CreatePeerForElement(control);
Assert.Equal("Book rating", peer.GetName());
});
private static void RunOnSta(Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (failure is not null)
{
throw new Xunit.Sdk.XunitException(failure.ToString());
}
}
}
Source: AutomationPeer class
The same technique checks that important elements have a name: create the window on an STA thread, walk it with
LogicalTreeHelper or VisualTreeHelper, and assert that every Button and TextBox produces a non-empty
UIElementAutomationPeer.CreatePeerForElement(element).GetName(). That is a cheap guard against an unnamed icon button
reaching a release. How to organise view-model and UI tests for the whole application is the subject of the Testing View Models & UI page of this section.
A UI Automation client
The out-of-process route uses the client API in System.Windows.Automation, which the Learn overview lists under the
UIAutomationClient assembly and describes as intended for client developers (finding AutomationElement objects,
registering for events, working with control patterns). A client finds an element by property and operates it through
a pattern. The example starts Bookshelf Desktop, opens the edit dialog and fills in a title, using the
AutomationId values set in XAML:
using System.Diagnostics;
using System.Windows.Automation;
namespace Bookshelf.Desktop.UiTests;
public sealed class EditDialogSmokeTest : IDisposable
{
private readonly Process _app = Process.Start(new ProcessStartInfo(
Environment.GetEnvironmentVariable("BOOKSHELF_EXE")
?? throw new InvalidOperationException("Set BOOKSHELF_EXE to the path of Bookshelf.Desktop.exe."))
{ UseShellExecute = false })!;
[Fact]
public void Save_button_is_reachable_by_id_and_invokable()
{
var window = WaitFor(() => AutomationElement.RootElement.FindFirst(
TreeScope.Children,
new PropertyCondition(AutomationElement.ProcessIdProperty, _app.Id)));
var title = window.FindFirst(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.AutomationIdProperty, "BookTitleBox"));
var value = (ValuePattern)title.GetCurrentPattern(ValuePattern.Pattern);
value.SetValue("Dune");
var save = window.FindFirst(
TreeScope.Descendants,
new PropertyCondition(AutomationElement.AutomationIdProperty, "SaveButton"));
((InvokePattern)save.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
}
private static AutomationElement WaitFor(Func<AutomationElement?> find)
{
var deadline = DateTime.UtcNow.AddSeconds(15);
while (DateTime.UtcNow < deadline)
{
if (find() is { } element)
{
return element;
}
Thread.Sleep(250);
}
throw new TimeoutException("The element did not appear in time.");
}
public void Dispose()
{
if (!_app.HasExited)
{
_app.Kill(entireProcessTree: true);
}
}
}
Source: UI Automation Overview
Notes on this kind of test:
-
It locates elements by
AutomationId, never by name or position. Names are localised and change with wording; automation ids are for tools and stay stable. This is the reason the XAML above sets them. -
The main window of this example is not on the screen until WPF has rendered it, so every search waits with a timeout.
-
The test drives the running exe, so it needs an interactive desktop session. On a GitHub-hosted Windows runner that works, but a locked or disconnected session does not; the CI/CD page of this section covers the runner setup.
-
The UIA calls above are synchronous and block the test thread; keep the test thread off the app’s UI thread, which is true here because the app is a separate process.
Microsoft’s current guidance for desktop UI test automation is thin. WinAppDriver has had no stable release since v1.2.1 (2020) and its newest release overall is the pre-release v1.2.99 (July 2021), so it is effectively unmaintained, though it is not formally deprecated; the Appium Windows Driver is the maintained Appium-based option, and both sit on top of UIA. FlaUI is a third-party .NET library over the UIA2 and UIA3 client APIs that many teams prefer to the raw client classes; it is mentioned here by name only. The Testing View Models & UI page of this section places these options next to unit testing.
Checking with Narrator and Accessibility Insights
Code review cannot confirm that the experience is right; a person using the tools can. Microsoft’s testing guidance recommends an automation-first workflow: define accessibility expectations for core flows, run automated checks in pull requests and CI, fail builds on critical issues, schedule manual screen-reader and keyboard validation where human judgement is needed, and retest whenever templates, control logic or navigation changes.
Narrator
Narrator is the screen reader built into Windows. The procedure from the Learn testing article:
-
Start Narrator with Windows logo key + Ctrl + Enter.
-
Move around the app with Tab, the arrow keys and Caps Lock + arrow keys. For each control, check that Narrator reads the visible content, the name, any state (checked, selected) and the control type.
-
For an interactive element, check that Caps Lock + Enter invokes its action.
-
Press Caps Lock + Shift + Enter to search the app and check that all controls appear in the list with localised, readable names.
-
Turn off the monitor and try to complete the main scenarios by keyboard and Narrator alone. Caps Lock + F1 lists all Narrator commands.
-
While Narrator runs, Ctrl + Caps Lock + F12 turns on a developer mode that masks the screen and highlights only the programmatically exposed objects and text.
For Bookshelf Desktop the scenarios are: open the app and hear the window title; reach the books list and hear "Dune
by Frank Herbert, list item, 2 of 12" (the last part is why PositionInSet and SizeOfSet matter for custom item
containers); press Ctrl+N and edit a book; trigger a validation error and hear it; save and hear "Book saved".
Accessibility Insights for Windows
Accessibility Insights for Windows is a desktop tool for testing the accessibility of Windows applications, and Learn recommends starting with it. Its three scenarios:
| Scenario | What it does |
|---|---|
Live Inspect |
Shows the UI Automation properties of the element under the mouse or with the keyboard focus, and shows the element in the UIA tree. Use it to verify the name, role, |
FastPass |
A two-step check. Automated checks test more than 60 accessibility requirements; a tab-stops test guides you through finding missing tab stops, keyboard traps and wrong tab order. |
Troubleshooting |
Shows the patterns a control supports and lets you invoke them, records the events the app raises, and includes a contrast checker. |
Run FastPass on every screen of Bookshelf Desktop before a release, and use Live Inspect while developing a custom
control to confirm that the peer reports the class name, control type and RangeValue pattern that you wrote.
Legacy Windows SDK tools
The Windows SDK also contains older tools, still available but described by Microsoft as superseded by Accessibility Insights: Inspect (select any element and view its UIA properties and patterns), AccScope (a visual evaluation of the tree for Narrator scenarios, usable on early prototypes), UI Accessibility Checker (AccChecker, run in UI or command-line mode, which can flag missing names and tree issues), UI Automation Verify (a framework for automated testing of the UIA implementation of a control) and Accessible Event Watcher (AccEvent, which checks that UI changes raise the right events). AccEvent and Inspect are particularly useful for custom controls, as they show what a peer actually raises and exposes.
A review checklist
| Check | How |
|---|---|
Every interactive element has a name |
Live Inspect, or a test that asserts |
Fields are tied to their labels |
|
Whole app works by keyboard |
Unplug the mouse; Tab, arrows, Enter, Space, Esc. FastPass tab-stops test. |
Focus is always visible and never lost |
Look at each control; check focus after dialogs close and rows are deleted. |
High contrast |
Turn on a high-contrast theme; text readable, images clear, no invisible controls. |
Contrast of text |
A contrast tool against the WCAG 2.2 G18 technique. |
Changes are announced |
Save, validation errors and loading states are heard without moving focus. |
Custom controls expose role and patterns |
Inspect or Live Inspect; unit test the peer. |
Scaling |
Larger system text and different DPI do not clip content. |
WPF, WinUI 3 and Windows Forms
All three stacks sit on UI Automation, so the concepts above carry over. The surface is different:
| Concept | WPF | WinUI 3 (Windows App SDK) | Windows Forms |
|---|---|---|---|
Provider mechanism |
|
|
|
Naming |
|
|
|
Tree view control |
Peer methods |
|
Not applicable |
Custom control |
Override |
Same idea, through "Custom automation peers" |
Override |
High contrast |
Use |
Theme resources resolve automatically to high-contrast values; avoid hard-coded colours |
Use |
Focus visuals |
|
Built-in focus visual states of the Fluent templates |
Controls draw their own focus cue |
Localisation of names |
Resource strings through the app’s mechanism |
|
|
For WinUI 3 the Learn accessibility articles apply directly, and they add the AccessibilityView control and the
x:Uid localisation syntax in the table. For Windows Forms, the Learn reference for Control.AccessibleName describes
it as the label that briefly describes and identifies the object within its container, with a default of null, and
its example sets AccessibleName and AccessibleDescription on a check box that shows an image and no text. The
Windows Forms overview does not have a dedicated custom-control accessibility walkthrough on current Learn (the older
walkthrough address returns 404), so a custom Windows Forms control is documented through the API reference of
AccessibleObject and Control.CreateAccessibilityInstance. The broader platform comparison is on the
Windows App Platforms page.
Pitfalls
-
An icon-only button with no name. The most common defect. Without
AutomationProperties.NameNarrator announces just "button"; do not count on the tooltip alone. Give every image or path button a name. -
Repeating the role in the name.
AutomationProperties.Name="Save button"is read as "Save button button". Name the action; the control type is appended by the tool. -
Hard-coded colours.
Background="White"andForeground="#333"ignore high contrast. Use system brushes (or dictionary keys that you redefine for high contrast) and check with a real high-contrast theme. -
Removing the focus rectangle. Setting
FocusVisualStyleto{x:Null}without replacement makes the keyboard user blind. Restyle, never remove. -
Replacing a
ControlTemplateand losing automation and focus behaviour. A new template changes the visuals only; the peer stays, but the focus cue, the access-key text (AccessText) and the content presenter that provides the name may be gone. Re-test with Live Inspect after retemplating. -
Tab order that follows the markup rather than the layout. Reordering rows with
Grid.Rowchanges the look but not the order; fix the markup, or useTabIndexsparingly. -
Using
ControlType.Custom. Clients cannot predict its structure, keyboard interaction or patterns. Pick the closest standard type. -
Raising automation events with no listeners. Creating peers and computing arguments for every property change wastes time. Guard with
AutomationPeer.ListenerExists, as the Learn article does. -
Mutating a
Namethat a client already cached. When the name changes at run time, raise a property-changed event (AutomationElementIdentifiers.NameProperty) through the peer so the tree refreshes. -
Announcing everything
Assertive. An assertive live region interrupts the user. UsePolitefor status andAssertiveonly for errors that block the task. -
Automation ids that change. They are the contract with the test code. Do not generate them from display text, and do not reuse one id for two elements in the same window.
-
Relying on one screen reader. Learn notes that not all screen readers support all automation properties. Test with at least Narrator and one other if your users use it.
-
Virtualised lists in tests. Rows outside the viewport have a data-item peer but no element, so a UIA search may not find them until they are realised (scrolled into view or realised by the
VirtualizedItempattern). -
Treating a clean FastPass as compliance. Automated checks find missing names and tab stops; they cannot judge whether the reading order, wording or workflow make sense. Pair them with manual Narrator and keyboard runs.
What Microsoft Learn Does Not Provide
-
There is no WPF accessibility hub page. The topic is spread across the UI Automation documentation, the custom-control article and API reference.
-
The UI Automation overview for managed code sits in the .NET Framework documentation; for the current native view of UIA it points to the Windows Automation API (
/windows/win32/winauto). -
There is no official recommendation for a desktop UI-test framework. WinAppDriver is unmaintained and the Appium Windows Driver and FlaUI are separate projects.
-
There is no official sample that makes WPF validation errors audible; the live-region and notification technique on this page is an assembly of documented APIs.
-
The Windows App SDK accessibility articles (accessibility overview, basic information, testing) describe WinUI, not WPF; the WPF-specific details come from the API reference.