This article will cover the design process, inspiration, and implementation using modern CSS techniques.
Contents
Introduction
This article discusses from need to implementation to give an understanding of what and how the theme support is implemented and used. Snippets of code are used to help with the article, not a complete code dump. All code and sample projects are included in the download for further study and trying out.
The article assumes that you have a basic understanding of Blazor, and links to various external resources to where you may require further information and/or explanation.
Inspiration
I wanted a theme switching button that would be modern with animation. I liked the button on Google Fonts website but I am a back-end developer, not a UI web designer. Here is their button in action.
(click on the image above to see how the theme switching works)
Luckily, Kevin Powell accepted a challenge from one of his viewers and created an equivalent button. You can view how it made it on his YouTube channel.
Below is Kevin's button integrated into this Blazor solution.
(click on the image above to see how the theme switching works)
Design
The concept is:
- Minimal code - fully wrapped or open to custom implementation
- Open design to work with any bespoke code or CSS framework like Bootstrap, Tailwind, etc.
- Reusable across multiple projects
- Minimal JavaScript if unavoidable
- Latest Blazor and CSS3 coding techniques
Implementation
For this article, two different theme switch methods are used:
1. Swapping Stylesheets
Allowing distinct separation of themes into separate files. This allows for downloading themes from 3rd party websites like Bootswatch. I've used their Darkly (dark theme) and Flatly (light theme) for this article. This is an older theme switching technique.
The best place to implement theme support is close to the top of the DOM as possible. This is done in the MainLayout.razor
.
For stylesheet switching, we need to alter the page head
section. To do this, we use the HeadContent
component in the MainLayout.razor
:
<HeadContent>
/* elements go here */
</HeadContent>
When using the HeadContent
component, the order is important. The component add content to the bottom of the page head
.
The CSS stylesheet(s) are normally added to theindex.html
file. However, for switching stylesheets, we remove the color styling from the index.html
and place the files in the <HeadContent>
block in the MainLayout.razor
file:
<HeadContent>
<Themes>
<DarkMode>
<link href="css/bootstrap/darkly.min.css" rel="stylesheet" />
</DarkMode>
<LightMode>
<link href="css/bootstrap/flatly.min.css" rel="stylesheet" />
</LightMode>
</Themes>
<link href="css/app.css" rel="stylesheet" />
<link href="ThemeByStylesheetDemo.styles.css" rel="stylesheet" />
</HeadContent>
No changes are required to the app CSS as the same CSS rules are applied in each theme file. This method is the least invasive however when switching themes there may be a slight flicker to the page when the browser refreshes.
2. CSS Variables
CSS variables, also called Custom Properties, is the modern and recommended technique used in websites today. CSS frameworks like Open Props use CSS Variables extensively.
Theme switching is done using a CSS class. In our case, we default to light mode, and add a CSS class name like dark
to switch modes. The CSS markup would look something like:
:root {
--background: #fff;
--font-color: #000;
--font-color-2: #fff;
--highlight: #f7f7f7;
--highlight-2: #95a6a6;
--link: #0366d6;
}
.dark {
--background: #222;
--font-color: #fff;
--font-color-2: #fff;
--highlight: #393939;
--highlight-2: #444444;
--link: #3ca4ff;
}
To use CSS variables:
.page {
background-color: var(--background);
color: var(--font-color);
}
This method does require the use of CSS variables, so changes to existing stylesheet code and inline style rules is required however the benefit is that there is no flickering when the browser updates. The other benefit is that you are now using shared variables and your css is easier to maintain.
3. Theme Switching
The animated samples above, like for the Google Fonts website, have a button for manual switching. There is also a media query for detecting user changes via the OS or web browser.
So typically in CSS, we would use the prefers-color-scheme media query.
For this to work, we need to listen for the change event. Blazor currently cannot directly listen to media queries, so we need to use some JavaScript with a callback into Blazor.
Here is the JavaScript:
function createThemeListener(dotNetRef) {
window.matchMedia("(prefers-color-scheme: dark)").addListener(
e => dotNetRef.invokeMethodAsync("DarkModeStateChanged", e.matches)
);
The initializer in Blazor where we pass a reference to our class with the callback:
_jsRuntime = jsRuntime;
_moduleTask = new(() => jsRuntime.ModuleFactory(ScriptFile));
IJSObjectReference module = await _moduleTask!.Value;
DotNetInstance = DotNetObjectReference.Create(this);
And the callback for the JavaScript event to Blazor:
[JSInvokable]
public async Task DarkModeStateChanged(bool state)
=> await SetDarkModeAsync(state).ConfigureAwait(false);
The javascript code lives in the library. With the latest version of Blazor, it is possible to include the javascript file without the need to manually add it to the index.html
file. To do this, we export
the javascript functions. The compiler sees this and includes the javascript for us. So the updated javascript looks like this:
export function isDarkTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function createThemeListener(dotNetRef) {
window.matchMedia("(prefers-color-scheme: dark)").addListener(
e => dotNetRef.invokeMethodAsync("DarkModeStateChanged", e.matches)
);
}
export function getLocalStorage(key) {
return localStorage[key];
}
export function setLocalStorage(key, value) {
localStorage[key] = value;
}
You can read more about how this works in Microsoft's Documentation.
4. Linking the Button to the Switching
There are three parts to enabling theme switching:
- User Selection - In this case, a toggle button. You could also use a dropdown list or a more bespoke selection.
- Switching the theme in MainLayout.razor file.
- Linking the selection to the switching. We will use a service called
ThemeService
for this.
Dot Net Core uses IOC container to implement Dependency Injection to automagically wire up classes with their dependencies.
The ThemeService
class handles the shared theme state and notification of changes from the user either via the ThemeToggle
button component or via OS or browser changes. Any changes made are handled in the MainLayout.razor
component.
The Code
Sample projects are included to demonstrate how each theme switching mode works. Both sample projects use the included ThemeToggle
component, however you can switch it out with your own.
Theme Library
The library encapsulates all core functionality for easy reuse:
- auto inclusion of all library CSS & JavaScript in the main project
1. ThemeToggle Component
- Aria compliant
- BEM CSS class naming convention
- Animated with minimal animation used
- Light or Dark state
- Optionally
ShowTooltip
property - Custom
DarkTipMessage
& LightTipMessage
properties - Supports 16, 24, 43, 48 pixel
ButtonSize
- Custom
Style
, class
and attribute
OnDarkModeStateChanged
event
@inject IThemeService themeService
<button @attributes="@Attributes"
style="@Style"
class="@GetComponentCssClass()"
aria-label="@GetToolTip()"
@onclick="_ => ToggleTheme()">
<svg xmlns="http://www.w3.org/2000/svg"
max-width="24px" max-height="24px"
viewBox="0 0 472.39 472.39">
<g class="theme-toggle__sun">
<path d="M403.21,167V69.18H305.38L236.2,0,167,69.18H69.18V167L0,236.2l69.18,
69.18v97.83H167l69.18,69.18,69.18-69.18h97.83V305.38l69.18-69.18Zm-167,
198.17a129,129,0,1,1,129-129A129,129,0,0,1,236.2,365.19Z"/>
</g>
<g class="theme-toggle__circle">
<circle cx="236.2" cy="236.2" r="103.78"/>
</g>
</svg>
</button>
When the button is pressed, it notifies the ThemeService
:
private void ToggleTheme()
=> themeService.DarkMode = !themeService.DarkMode;
2. ThemeService (core)
-
DarkMode
property for the theme state - light or dark
-
Store state changes to the browser's localstorage
to remember user selection for page reloads, changes and later website revisits.
-
Listens for the prefers-color-scheme media query changed event
-
OnDarkModeStateChanged
event for notifying changes
When a change is made to the state, the following code is executed:
private async Task SetDarkModeAsync(bool value)
{
_darkMode = value;
await (await GetModuleInstance())
.SetLocalStorageThemeAsync(_darkMode);
OnDarkModeStateChanged?.Invoke(DarkMode);
}
SetLocalStorageThemeAsync
is an extension method that wraps the JavaScript call for storage:
internal static class IJSObjectReferenceExtensions
{
private static string JSSetLocalStorage = "setLocalStorage";
public static async Task SetLocalStorageAsync(
this IJSObjectReference? jsObjRef, string key, string value)
=> await jsObjRef!.InvokeVoidAsync(JSSetLocalStorage, key, value)
.ConfigureAwait(false);
public static async Task SetLocalStorageThemeAsync(
this IJSObjectReference? jsObjRef, bool IsDarkTheme)
=> await jsObjRef!.SetLocalStorageAsync(ThemeKey,IsDarkTheme
? DarkThemeValue : LightThemeValue).ConfigureAwait(false);
}
and the JavaScript:
function setLocalStorage(key, value) {
localStorage[key] = value;
}
3a. Themes Component Method
This component is used for Stylesheet Switching:
- Auto-selection of Light or Dark theme selection
- Initializes the
ThemeService
and listens to the OnDarkModeStateChanged
event for changes and raises a render update.
This is the theme switching markup:
@if (ThemeService is not null && ThemeService.DarkMode)
{
@if (DarkMode is not null)
{
@DarkMode
}
}
else
{
@if (LightMode is not null)
{
@LightMode
}
}
And the code that listens and raises a render update:
protected override async Task OnInitializedAsync()
{
if (ThemeService is not null)
{
await ThemeService.InitializeAsync()!;
ThemeService.OnDarkModeStateChanged+= OnDarkModeChanged;
}
await base.OnInitializedAsync();
}
private void OnDarkModeChanged(bool State) => StateHasChanged();
In your app, the component usage in MainLayout.razor
would be:
<HeadContent>
<Theming.Themes>
<DarkMode>
<link href="css/bootstrap/darkly.min.css" rel="stylesheet" />
</DarkMode>
<LightMode>
<link href="css/bootstrap/flatly.min.css" rel="stylesheet" />
</LightMode>
</Theming.Themes>
<link href="css/app.css" rel="stylesheet" />
<link href="ThemeTest.styles.css" rel="stylesheet" />
</HeadContent>
3b. CSS Class Change Method
Manual wiring up the MainLayout.razor
for CSS class selection:
@inject IThemeService themeService
<div class="@GetClassCss()">
and the code to manage the classes:
private bool IsDarkMode;
protected override async void OnInitialized()
{
themeService.OnDarkModeStateChanged += OnDarkModeChanged;
await base.OnInitializedAsync();
}
private void OnDarkModeChanged(bool state)
{
IsDarkMode = state;
StateHasChanged();
}
private string GetClassCss()
=> "page" + (IsDarkMode ? " dark" : "");
Testing Theme Switching
To test switching between light and dark themes, you can either set your preferences in Windows or Mac OS or use the settings / developer tools in web browsers. Below, I've listed where to find the options in common browsers.
Chrome/Edge
- Open the developer tools
- Click on the 3 dots for more options, then More tools, and select "Rendering".
- Scroll down until you reach the "prefers-color-scheme" dropdown selection.
FireFox
Open the developer tools, Page Inspector, and there are buttons to toggle between light (sun) and dark (moon) modes.
Opera
Select the "Easy Setup" button on the far right and you can choose between light, dark, and system OS modes.
Summary
The library encapsulates all functionality required to manage theme state and switching with automatic storage, supports multiple theming techniques, and a modern Toggle. Only a handful of lines of code are required to implement into your own projects.
Enjoy!
History
- v1.0 - 23rd January, 2022 - Initial release
- v1.01 - 31st January, 2022 - added more information to the Implementation section.