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 AutomationProperties APIs 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.Automation classes, 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 AutomationProperties and KeyboardNavigation classes 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.

flowchart LR A["WPF control Button TextBox ListBox"] --> B["AutomationPeer one per element"] B --> C["UIA core UiAutomationCore.dll"] C --> D["Screen reader Narrator"] C --> E["Test tool UIA client"] C --> F["Voice control or magnifier"] D --> C E --> C C --> B

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.

Properties, patterns and events

An element exposes three kinds of information:

Kind Meaning Examples

Properties

Descriptive facts about the element, readable at any time.

Name, ControlType, AutomationId, HelpText, IsEnabled, HasKeyboardFocus, IsOffscreen, BoundingRectangle

Control patterns

Behaviours a client can use, each exposed through a provider interface in System.Windows.Automation.Provider.

Invoke (a button was pressed), Value (read or set text), Toggle (check box), Selection and SelectionItem (lists), ExpandCollapse, RangeValue (slider), Scroll

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

Button

Invoke

Title TextBox

Edit

Value

"Favorite" CheckBox

CheckBox

Toggle

Books ListBox

List

Selection, Scroll

One book row

ListItem

SelectionItem, ScrollItem

Books DataGrid

DataGrid

Grid, Table, Selection

Genre ComboBox

ComboBox

ExpandCollapse, Value

Author TreeViewItem

TreeItem

ExpandCollapse, SelectionItem

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, ComboBox or DatePicker: the visible label is a separate TextBlock or Label, 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>

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}" />

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>

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

Name

The accessible name. Overrides the name the control derives itself.

HelpText

Supplementary description, generally the same as the tooltip.

LabeledBy

The UIElement that holds the text label for this element.

AutomationId

A string that identifies the element uniquely. Not spoken; it is for test code (see below).

LiveSetting

How a live region notifies the client of changes: Off, Polite or Assertive (AutomationLiveSetting).

AcceleratorKey and AccessKey

The keyboard shortcut and the access key of the element, for clients that announce them.

IsRequiredForForm

Whether the field must be filled in. Set it on mandatory fields such as the book title.

ItemStatus and ItemType

A description of the status of an item (for example "Unsaved changes") and of what kind of item it is.

IsColumnHeader and IsRowHeader

Mark an element as a header of a table, so readers can announce row and column headings.

PositionInSet and SizeOfSet

The ordinal position of an element among its siblings and the number of siblings, for composite items not built from a standard list.

HeadingLevel

Marks a text element as a heading (an AutomationHeadingLevel value) so readers can jump between headings.

IsDialog

Marks the element as a dialog.

IsOffscreenBehavior

How IsOffscreen is determined for the element.

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}" />

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>

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

KeyboardNavigation.TabIndex

The order of an element among its siblings. Lower numbers come first. Elements without one follow the document order.

KeyboardNavigation.IsTabStop

Whether Tab stops on the element. Set it to False on decorative focusable elements.

KeyboardNavigation.TabNavigation

What Tab does at the edges of a container (Continue, Local, Cycle, Once, None, Contained).

KeyboardNavigation.ControlTabNavigation

The same, for Ctrl+Tab.

KeyboardNavigation.DirectionalNavigation

The same, for the arrow keys.

KeyboardNavigation.AcceptsReturn

Whether the control wants the Enter key.

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();
}

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>

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.HighContrast is a static bool that is true when the high-contrast option is selected. The API reference states that it maps to SPI_GETHIGHCONTRAST and HCF_HIGHCONTRASTON in the Win32 SystemParametersInfo function, and that it is usable from XAML as {x:Static SystemParameters.HighContrast}.

  • SystemColors exposes the current palette as colours and brushes, with matching resource keys (for example SystemColors.WindowBrushKey, ControlTextBrushKey, HighlightBrushKey). Referring to them with DynamicResource lets 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:

  1. Override OnCreateAutomationPeer in the control and return a peer.

  2. Make the peer derive from AutomationPeer directly or indirectly (usually from the peer of the control’s base class, or from FrameworkElementAutomationPeer).

  3. Override GetPattern(PatternInterface) to return the object that implements each pattern the control supports.

  4. Override the Core methods that differ from the base peer. At a minimum, GetClassNameCore and GetAutomationControlTypeCore.

  5. Implement IsContentElementCore and IsControlElementCore if the defaults (both true) are wrong.

  6. Raise events when state changes, with RaiseAutomationEvent and RaisePropertyChangedEvent.

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" />

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());
        }
    }
}

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);
        }
    }
}

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:

  1. Start Narrator with Windows logo key + Ctrl + Enter.

  2. 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.

  3. For an interactive element, check that Caps Lock + Enter invokes its action.

  4. Press Caps Lock + Shift + Enter to search the app and check that all controls appear in the list with localised, readable names.

  5. Turn off the monitor and try to complete the main scenarios by keyboard and Narrator alone. Caps Lock + F1 lists all Narrator commands.

  6. 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, AutomationId and patterns of a control.

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 GetName() is not empty.

Fields are tied to their labels

Label.Target or LabeledBy; read the form in Narrator.

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

AutomationPeer classes in System.Windows.Automation.Peers

AutomationPeer classes in Microsoft.UI.Xaml.Automation.Peers

AccessibleObject and UIA providers behind the AccessibilityObject property of a control

Naming

AutomationProperties.Name, LabeledBy, Label.Target

AutomationProperties.Name, LabeledBy (a TextBlock plus LabeledBy is the documented pattern)

AccessibleName, AccessibleDescription, AccessibleRole, AccessibleDefaultActionDescription

Tree view control

Peer methods IsControlElementCore, IsContentElementCore

AutomationProperties.AccessibilityView (Raw, Control, Content)

Not applicable

Custom control

Override OnCreateAutomationPeer, derive from FrameworkElementAutomationPeer

Same idea, through "Custom automation peers"

Override CreateAccessibilityInstance to return an AccessibleObject subclass

High contrast

Use SystemColors brushes with DynamicResource; check SystemParameters.HighContrast

Theme resources resolve automatically to high-contrast values; avoid hard-coded colours

Use SystemColors and re-read them when SystemEvents.UserPreferenceChanged fires

Focus visuals

FocusVisualStyle

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

x:Uid with the AutomationProperties qualified key, for example MediumButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name

.resx resources

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.Name Narrator 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" and Foreground="#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 FocusVisualStyle to {x:Null} without replacement makes the keyboard user blind. Restyle, never remove.

  • Replacing a ControlTemplate and 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.Row changes the look but not the order; fix the markup, or use TabIndex sparingly.

  • 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 Name that 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. Use Polite for status and Assertive only 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 VirtualizedItem pattern).

  • 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.