Add light and dark modes to CoinJar with swappable resource dictionaries and DynamicResource, follow the Windows setting, and use the built in Fluent theme with ThemeMode.

Bishal Biswas
Self Learner
Why themes matter
Many people run Windows in dark mode, and they expect apps to follow. Others pick light mode, or want to choose per app. A theme is a complete set of colours (and sometimes styles) that you can swap as a whole, so the same window can look light or dark.
In this lesson you will:
- build your own light and dark colour sets for CoinJar
- switch between them while the app runs
- follow the Windows light or dark setting
- use the Fluent theme that ships with WPF in .NET 9 and later, which gives standard controls a modern Windows 11 look
Everything here builds on two things you already know from Resources and Resource Dictionaries: resource dictionary files and DynamicResource.
The idea in one picture
App.xaml MergedDictionaries
Themes/Light.xaml <- swap this one file ...
Themes/Styles.xaml
|
v
{DynamicResource PageBrush} everywhere ... and everything repaints
Both theme files define the same keys with different colours. Every element asks for colours with DynamicResource. When you replace the theme dictionary, WPF notices that the resources changed and repaints every element that uses them.
Step 1: two dictionaries with the same keys
Create Themes/Light.xaml:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PageBrush" Color="#F3F5F7" />
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextBrush" Color="#111827" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#6B7280" />
<SolidColorBrush x:Key="BorderBrush" Color="#E5E7EB" />
<SolidColorBrush x:Key="AccentBrush" Color="#2E7D32" />
</ResourceDictionary>
And Themes/Dark.xaml with the same keys:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PageBrush" Color="#16181C" />
<SolidColorBrush x:Key="CardBrush" Color="#22252B" />
<SolidColorBrush x:Key="TextBrush" Color="#F3F4F6" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#9CA3AF" />
<SolidColorBrush x:Key="BorderBrush" Color="#33373F" />
<SolidColorBrush x:Key="AccentBrush" Color="#66BB6A" />
</ResourceDictionary>
Notice the accent is a lighter green in dark mode. A dark green on a dark background is hard to read. Good dark themes are not just “invert the colours”. They use softer backgrounds (dark grey, not pure black) and slightly brighter accents.
Name keys by purpose (CardBrush, MutedTextBrush), never by colour (WhiteBrush). In dark mode the “card” is not white.
Step 2: use DynamicResource for anything themed
Merge one theme in App.xaml to start with:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Light.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
Then every themed colour uses DynamicResource:
<Window Background="{DynamicResource PageBrush}"
Foreground="{DynamicResource TextBrush}">
<Border Background="{DynamicResource CardBrush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="16">
<TextBlock Text="Spent this month" Foreground="{DynamicResource MutedTextBrush}" />
</Border>
</Window>
The same goes for setters in your styles and colours inside control templates:
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource CardBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
</Style>
If even one place uses StaticResource or a hard coded colour like #FFFFFF, that spot will stay light when everything else turns dark. That is the most common theme bug.
Step 3: swap the dictionary at run time
Switching themes means: find the current theme dictionary in the merged list and replace it with the other one. A small helper class keeps this in one place:
public static class ThemeService
{
public static void ApplyColours(bool dark)
{
var merged = Application.Current.Resources.MergedDictionaries;
// Find the dictionary we loaded before, if any
var old = merged.FirstOrDefault(d =>
d.Source != null &&
(d.Source.OriginalString.EndsWith("Themes/Light.xaml") ||
d.Source.OriginalString.EndsWith("Themes/Dark.xaml")));
var fresh = new ResourceDictionary
{
Source = new Uri($"pack://application:,,,/Themes/{(dark ? "Dark" : "Light")}.xaml")
};
if (old != null) merged[merged.IndexOf(old)] = fresh;
else merged.Insert(0, fresh);
}
}
The pack://application:,,,/ address is the pack URI format from the resources lesson. It points at a XAML file compiled into the app.
The search matches the full ending Themes/Light.xaml on purpose. When the Fluent theme (later in this lesson) is switched on, WPF adds its own dictionary to the same list, and its name (Fluent.Light.xaml) also ends in Light.xaml. A looser match would replace Fluent’s dictionary by mistake.
Call it from anywhere, for example a menu item from Menus, ToolBars and StatusBars:
private void DarkMode_Click(object sender, RoutedEventArgs e) => ThemeService.ApplyColours(dark: true);
To remember the user’s choice next time the app starts, save it in a settings file. You will learn how in App Settings and Configuration.
Step 4: follow the Windows setting
Windows stores the “apps” light or dark choice in the registry, the system’s settings database. The value AppsUseLightTheme is 1 for light and 0 for dark:
using Microsoft.Win32;
public static bool IsWindowsInDarkMode()
{
object? value = Registry.GetValue(
@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
"AppsUseLightTheme", 1);
return value is int i && i == 0;
}
The last argument (1) is the default, used when the value does not exist, for example on older Windows versions. We only read this setting. Never change it from your app.
To react when the user flips the setting while CoinJar is open, listen to SystemEvents.UserPreferenceChanged, a static event from Microsoft.Win32:
SystemEvents.UserPreferenceChanged += (s, e) =>
{
if (e.Category == UserPreferenceCategory.General)
{
Application.Current.Dispatcher.Invoke(() => ThemeService.ApplyColours(IsWindowsInDarkMode()));
}
};
Dispatcher.Invoke makes sure the change runs on the UI thread, because the event may arrive on another thread. That idea is explained in The Dispatcher and Threading.
The Fluent theme
So far you changed your colours. But the standard controls (buttons, text boxes, combo boxes, scroll bars, the DataGrid) still use the classic look, which is always light. Making every control dark by hand would mean templating all of them.
Since .NET 9, WPF includes a Fluent theme: modern styles for the standard controls that match Windows 11, with rounded corners, the user’s accent colour, and both light and dark versions. On .NET 10 you switch it on with one attribute.
ThemeMode in XAML
Set ThemeMode on the Application in App.xaml:
<Application x:Class="CoinJar.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml"
ThemeMode="System">
</Application>
The values are:
ThemeMode |
Result |
|---|---|
None |
no Fluent theme, the classic look (the default) |
Light |
Fluent, always light |
Dark |
Fluent, always dark |
System |
Fluent, following the Windows light or dark setting automatically |
You can also set ThemeMode on a single Window to give just that window its own mode. Setting it in XAML compiles with no warnings.
ThemeMode from C#: the WPF0001 error
Changing the theme from code, for example from a settings menu, looks like this:
Application.Current.ThemeMode = ThemeMode.Dark;
But it fails to build with error WPF0001: the API is marked “for evaluation purposes only”. The feature is still marked experimental, which means its shape may change in a future version. To use it anyway, opt in around the code:
#pragma warning disable WPF0001
Application.Current.ThemeMode = dark ? ThemeMode.Dark : ThemeMode.Light;
#pragma warning restore WPF0001
Or turn the check off for the whole project in the .csproj:
<PropertyGroup>
<NoWarn>$(NoWarn);WPF0001</NoWarn>
</PropertyGroup>
The #pragma version is better practice: it marks exactly the lines that use an experimental API.
The older way: merging Fluent.xaml
Before ThemeMode existed (and still today), you can load the Fluent styles by merging their dictionary yourself, using a pack URI into the PresentationFramework.Fluent assembly:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/PresentationFramework.Fluent;component/Themes/Fluent.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
You will see this in some tutorials. For new .NET 10 apps, ThemeMode is simpler.
Combining Fluent with your own colours
The neat solution for CoinJar uses both:
- the Fluent theme draws the standard controls in light or dark
- your dictionaries colour your own surfaces: cards, charts, category chips
Switch both together so they always agree. The full ThemeService in the code panel does exactly this, with three choices: Light, Dark and System.
A few things to keep in mind:
- A local
Backgroundon a control replaces Fluent’s background for that control. That is what you want for your own cards, but avoid it on standard controls or they will not follow the theme. - Your own control templates replace Fluent’s look completely. Use
DynamicResourcebrushes in them. - Fluent styles change sizes and padding a little compared with the classic look. Check your layouts after switching it on.
Tips and common mistakes
- One element stays light. It uses
StaticResourceor a hard coded colour. Search your XAML for#colours andStaticResourcebrushes. - Icons disappear in dark mode. A black
Pathicon on a dark background. Give iconsFill="{DynamicResource TextBrush}", which is another good reason to use Path icons instead of images. - Keys missing from one theme. If
Dark.xamlforgets a key,DynamicResourcesilently shows nothing. Keep both files in the same order so they are easy to compare. - Do not forget high contrast. Some users need Windows high contrast mode.
SystemParameters.HighContrasttells you when it is on, and Accessibility and Localization covers it. - Build error WPF0001. You set
ThemeModefrom C#. Add the#pragmaor theNoWarnentry. - Test both themes. Click through every window in light and dark mode before shipping.
Quick recap
- A theme is a swappable set of resources. Make one dictionary per theme with the same keys.
- Use
DynamicResourcefor every themed colour, in elements, styles and templates. - Swap the theme by replacing the dictionary in
Application.Current.Resources.MergedDictionaries. - Read
AppsUseLightThemefrom the registry to follow Windows, and listen toSystemEvents.UserPreferenceChangedfor live changes. - The Fluent theme (
ThemeMode="Light","Dark"or"System") gives standard controls a modern look. It is still marked experimental: setting it from C# needs#pragma warning disable WPF0001. - Combine Fluent for controls with your own dictionaries for your surfaces, and switch both together.
// Services/ThemeService.cs
using System.Windows;
using Microsoft.Win32;
namespace CoinJar.Services;
public enum AppTheme { Light, Dark, System }
// Keeps the Fluent theme and CoinJar's own colours in step
public static class ThemeService
{
public static AppTheme Current { get; private set; } = AppTheme.System;
private static bool _listening;
public static void Apply(AppTheme theme)
{
Current = theme;
bool dark = theme == AppTheme.Dark || (theme == AppTheme.System && IsWindowsInDarkMode());
// 1. Standard controls: the Fluent theme (still experimental, so opt in)
#pragma warning disable WPF0001
Application.Current.ThemeMode = theme switch
{
AppTheme.Light => ThemeMode.Light,
AppTheme.Dark => ThemeMode.Dark,
_ => ThemeMode.System
};
#pragma warning restore WPF0001
// 2. Our own surfaces: swap Light.xaml and Dark.xaml
ApplyColours(dark);
// 3. Follow Windows while the app runs (subscribe once)
if (!_listening)
{
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
_listening = true;
}
}
public static void ApplyColours(bool dark)
{
var merged = Application.Current.Resources.MergedDictionaries;
var old = merged.FirstOrDefault(d =>
d.Source != null &&
(d.Source.OriginalString.EndsWith("Themes/Light.xaml") ||
d.Source.OriginalString.EndsWith("Themes/Dark.xaml")));
var fresh = new ResourceDictionary
{
Source = new Uri($"pack://application:,,,/Themes/{(dark ? "Dark" : "Light")}.xaml")
};
if (old != null) merged[merged.IndexOf(old)] = fresh;
else merged.Add(fresh);
}
public static bool IsWindowsInDarkMode()
{
// 1 = light, 0 = dark. We only read this value.
object? value = Registry.GetValue(
@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
"AppsUseLightTheme", 1);
return value is int i && i == 0;
}
private static void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e)
{
if (e.Category == UserPreferenceCategory.General && Current == AppTheme.System)
{
// The event may arrive on another thread
Application.Current.Dispatcher.Invoke(() => ApplyColours(IsWindowsInDarkMode()));
}
}
}
// App.xaml.cs
using System.Windows;
using CoinJar.Services;
namespace CoinJar;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Later the saved choice comes from a settings file
ThemeService.Apply(AppTheme.System);
}
}
<!-- App.xaml -->
<Application x:Class="CoinJar.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml"
ThemeMode="System">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- ThemeService swaps this for Dark.xaml when needed -->
<ResourceDictionary Source="Themes/Light.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
<!-- Themes/Light.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="PageBrush" Color="#F3F5F7" />
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="TextBrush" Color="#111827" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#6B7280" />
<SolidColorBrush x:Key="BorderBrush" Color="#E5E7EB" />
<SolidColorBrush x:Key="AccentBrush" Color="#2E7D32" />
</ResourceDictionary>
<!-- Themes/Dark.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Same keys, darker surfaces, brighter accent -->
<SolidColorBrush x:Key="PageBrush" Color="#16181C" />
<SolidColorBrush x:Key="CardBrush" Color="#22252B" />
<SolidColorBrush x:Key="TextBrush" Color="#F3F4F6" />
<SolidColorBrush x:Key="MutedTextBrush" Color="#9CA3AF" />
<SolidColorBrush x:Key="BorderBrush" Color="#33373F" />
<SolidColorBrush x:Key="AccentBrush" Color="#66BB6A" />
</ResourceDictionary>
<!-- MainWindow.xaml -->
<Window x:Class="CoinJar.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="CoinJar" Height="340" Width="460"
Background="{DynamicResource PageBrush}"
Foreground="{DynamicResource TextBrush}">
<StackPanel Margin="16">
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<TextBlock Text="Theme:" VerticalAlignment="Center" Margin="0,0,8,0" />
<!-- Standard controls are drawn by the Fluent theme -->
<ComboBox x:Name="ThemePicker" Width="120" SelectionChanged="ThemePicker_SelectionChanged">
<ComboBoxItem Content="System" IsSelected="True" />
<ComboBoxItem Content="Light" />
<ComboBoxItem Content="Dark" />
</ComboBox>
</StackPanel>
<!-- Our own surface uses DynamicResource so it follows the swap -->
<Border Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Padding="16,12">
<StackPanel>
<TextBlock Text="Spent this month" Foreground="{DynamicResource MutedTextBrush}" />
<TextBlock Text="$412.35" FontSize="28" FontWeight="Bold"
Foreground="{DynamicResource AccentBrush}" />
<TextBox Text="Search expenses" Margin="0,12,0,0" />
<CheckBox Content="Show only this month" IsChecked="True" Margin="0,8,0,0" />
</StackPanel>
</Border>
</StackPanel>
</Window>
// MainWindow.xaml.cs
using System.Windows;
using System.Windows.Controls;
using CoinJar.Services;
namespace CoinJar;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void ThemePicker_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Ignore the event raised while the window is still loading
if (!IsLoaded) return;
string choice = (string)((ComboBoxItem)ThemePicker.SelectedItem).Content;
ThemeService.Apply(Enum.Parse<AppTheme>(choice));
}
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready