Implementing dark mode in Blazor and Tailwind CSS Part Two


In the first part of this series we discussed the fundamental components for implementing dark mode in Blazor using Tailwind CSS. If you haven’t checked this out it is available here. Today we will focus on building a theme picker component, storing the the theme choice using local storage and using javascript to interact with the DOM.

Building a theme picker

As previously mentioned the theme picker needs to have three options. A light mode, dark mode and to allow the system to pick the theme.

We will begin by creating a ThemePicker blazor component.

@using ThemePickerProject.Models
@using System.ComponentModel

<EditForm Model="_theme">
   <InputRadioGroup @bind-Value="_theme.Choice">
       <label>
           <InputRadio Value="ThemeChoice.Dark" />
           @ThemeChoice.Dark
       </label>
       <label>
           <InputRadio Value="ThemeChoice.Light" />
           @ThemeChoice.Light
       </label>
       <label>
           <InputRadio Value="ThemeChoice.System" />
           @ThemeChoice.System
       </label>
   </InputRadioGroup>
</EditForm>

@code {
   private readonly Theme _theme = new();

   ...
}

Our ThemePicker component is using an EditForm Blazor component. The EditForm uses the Theme model that we created in the previous post. This is held as a private field and will hold the state within the component. More information on forms in Blazor can be found on the Microsoft Docs website.

Lets add the form to our project. I am adding this to the index page but you can place this where ever makes sense for you.

@page "/"

<PageTitle>Theme Picker</PageTitle>

<h1>Theme Picker</h1>

<ThemePicker />
Theme Picker form shown on page

As you can see adding the ThemePicker to your project will show a form but picking a choice won’t do a lot. Next we will wire up the OnPropertyChangedevent that we setup in out previous post.


@using ThemePickerProject.Models
@using System.ComponentModel

@implements IDisposable

...

@code {
    private readonly Theme _theme = new();
    
    protected override void OnInitialized()
    {
        _theme.PropertyChanged += ThemeChanged;
    }
    
    void ThemeChanged(object? sender, PropertyChangedEventArgs p)
    {
        switch (_theme.Choice)
        {
            case ThemeChoice.System:
                Console.WriteLine("System Choice Picked");
                break;
            case ThemeChoice.Light:
                Console.WriteLine("Light Choice Picked");
                break;
            case ThemeChoice.Dark:
                Console.WriteLine("Dark Choice Picked");
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    public void Dispose()
    {
        _theme.PropertyChanged -= ThemeChanged;
    }

}

We begin by using Blazor’s lifecycle OnInitialized method to register for the Theme model’s OnPropertyChanged event.

With this when ever the Theme’s Choice property changes the ThemeChangedmethod will be fired.

We have a switch statement that will be match which choice has been picked. At this stage we are writing out to the browsers console.

form change shown in the console

As the Theme model only has the Choice property we aren’t checking which property is being changed.

We are also implementing IDisposable here to de-register the event in the Dispose method.

Storing the theme choice

Now we need to be able to store the users choice somewhere when the user visits the page again or when the page is refreshed. For this we can user Local Storage.

Blazor has a library available for this Blazored.LocalStorage

Add this NuGet package to your project.

Next you will register the BlazoredLocalStorage service to your project. If you are building a WebAssembly project you will register this in the Program.cs file

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddBlazoredLocalStorage();

await builder.Build().RunAsync();

If you are building a Blazor Server project you will add it to the startup.cs file’s ConfigureServices method

public void ConfigureServices(IServiceCollection services)
{
    services.AddBlazoredLocalStorage();
}

Now that we have registered the service we need to inject it into our ThemePicker. Back in the the ThemePicker.cs file inject the ILocalStorageService.

@using ThemePickerProject.Models
@using System.ComponentModel

@inject Blazored.LocalStorage.ILocalStorageService _localStorage

@implements IDisposable

Next we will store the theme’s choice in local storage if the manual dark or light themes are picked. If the system choice is picked then we will make sure to remove the theme’s choice from local storage. To do this we will make a change to the ThemeChanged method.


... 

async void ThemeChanged(object? sender, PropertyChangedEventArgs p)
{
    switch (_theme.Choice)
    {
        case ThemeChoice.System:
            await _localStorage.RemoveItemAsync("theme");
            break;
        case ThemeChoice.Light:
            await _localStorage.SetItemAsync("theme", _theme.Choice);
            break;
        case ThemeChoice.Dark:
            await _localStorage.SetItemAsync("theme", _theme.Choice);
            break;
        default:
            throw new ArgumentOutOfRangeException();
    }
}

...

In the Light and Dark cases we are using the SetItemAsync() method. We are storing a the choice under a ‘theme’ key. In the system case we are making sure to remove the ‘theme’ key from local storage.

We are using the async versions of these methods, you will need to update the method signature with the async keyword to match this change.

Local Storage showing theme key

As you can see from the screenshot this stores a number on the theme key in local storage, this is because we are using an enum for the choice over a string value. This will be used in the next step when we build our javascript modules. Take note of the value that the dark theme stores as this will be needed shortly.

Setting up the Javascript components.

Now Blazor does not have a way to interact with the DOM (Document Object Model) from C#, because of this we need to create a javascript module to do the theme change for us.

In the Blazor projects wwwroot folder we create a ThemePicker.js file. This module exports one method called SetTheme()


export function setTheme() {
    if (localStorage.theme === '2' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.classList.add('dark')
    } else {
        document.documentElement.classList.remove('dark')
    }
}

Now the setTheme() method’s if statement checks whether local storage theme key is set to the dark choice, in my case this is a value of ‘2’ or if there is no theme key in local storage and the browsers media query ‘prefers-color-scheme’ is set to dark.

If either of these match then we add to the html tag a class of dark. This will then be used by Tailwind to set the dark theme.

With this module ready to go we need a way to call it from out ThemePicker component. This can be done with Blazor’s JSRuntime.

In the ThemePicker Component we will inject in the IJSRuntime service.

@using ThemePickerProject.Models
@using System.ComponentModel

@inject Blazored.LocalStorage.ILocalStorageService _localStorage
@inject IJSRuntime _js

....

We will use another lifecycle method OnAfterRenderAsync(bool firstRender) to store access to the ThemePicker javascript module.

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        _module = await _js.InvokeAsync<IJSObjectReference>("import", "./ThemePicker.js");
        ThemeChoice localStorageTheme = await _localStorage.GetItemAsync<ThemeChoice>("theme");
        _theme.Choice = localStorageTheme;
        StateHasChanged();
    }
}

Here we check whether this is the first render for the component. This is to stop an infinite loop.

We then import the ThemePicker javascript module and store in a private IJSObjectReference? field named _module.

We grab the theme choice from local storage and store it in the _theme.Choice property.

We then call StateHasChanged() to re-render the component.

Next we create a new method ChangeTheme() this will be used to call the setTheme() method we created in out javascript module.

private async ValueTask ChangeTheme()
{
    if(_module is not null)
    {
        await _module.InvokeVoidAsync("setTheme");
    }
}

We then update our switch statement to call the ChangeTheme() method.

switch (_theme.Choice)
{
    case ThemeChoice.System:
        await _localStorage.RemoveItemAsync("theme");
        await ChangeTheme();
        break;
    case ThemeChoice.Light:
        await _localStorage.SetItemAsync("theme", _theme.Choice);
        await ChangeTheme();
        break;
    case ThemeChoice.Dark:
        await _localStorage.SetItemAsync("theme", _theme.Choice);
        await ChangeTheme();
        break;
    default:
        throw new ArgumentOutOfRangeException();
}

Finally we will update our dispose method to use IAsyncDisposable. We need to update our implementation from IDisposable to IAsyncDisposable and then in turn update the Dispose method as below.

...

@implements IAsyncDisposable

...

async ValueTask IAsyncDisposable.DisposeAsync()
{
    _theme.PropertyChanged -= ThemeChanged;

    if (_module is not null)
    {
        await _module.DisposeAsync();
    }
}
    
...

Now you will be able to the dark prefix for Tailwind to create a dark theme. Here I have added basic dark theme which will be used when the picker is updated.

Here is the index.html that implements a basic dark theme on the body tag.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>ThemePickerProject</title>
    <base href="/" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="ThemePickerProject.styles.css" rel="stylesheet" />
</head>

<body class="bg-white dark:bg-gray-700 dark:text-white text-gray-700">
    <div id="app">Loading...</div>
    
    <script src="_framework/blazor.webassembly.js"></script>
</body>

</html>

We know are able to see dark theme being implemented.

Dark them implemented

loading the choice on page load

Now we could call it a day there. This has been a lengthy post however there is one extra thing we will implement that we will implement to finish up.

Even though we are checking the theme on page load we still have to wait until Blazor has loaded completely before we can check the theme. Blazor as of 6.0 allows for us to run Javascript before Blazor has loaded. To do this create a javascript file at the root of the wwwroot folder. This should have the name <AssemblyName>.lib.module.js in my case this is ‘ThemePickerProject.lib.module.js’

Blazor will look for either a ‘beforeStart(options, extensions)’ or ‘afterStarted(blazor)’ methods.

import { setTheme } from "./ThemePicker.js";


export function beforeStart(options, extensions) {
    setTheme();
    
    window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => setTheme() );
}

In this module we will import the javascript ThemePicker and then will call the setTheme() method.

We will also add an event listener so if the system choice is being used and the media query for this updates the page will update without a page reload..

Now the theme will be set before Blazor is loaded as you can see from the the standard loading page in dark mode

Dark mode on loading page

This series has ran through implementing a dark mode theme picker in blazor and Tailwind CSS.