DEV Community

Robodobdob
Robodobdob

Posted on

Solving Problems with HTMX - Theme Switcher

Dark mode has been all the rage in dev circles for years now but not everyone is on board with it. I fall into the mostly-light-mode-but-for-that-one-app camp. No shade (pun intended) on those who chose the dark side, but it's just not for my old eyes.

However, I accept that what works for me doesn't for others and vice versa, so how do you accommodate both users in your application?

In my personal project I took it as a fun exercise to add a dark mode to my app using HTMX to do all the work. The result was surprisingly simple and, I would argue, easier than a SPA.

The Objective

This is the application in its light-mode glory.

Note To Self in default theme

What we want to achieve is a switch between a light and dark mode theme with a simple toggle.

Consolidate the colours

The first step is to create two root CSS files - colors-light.css and colors-dark.css and put them in the wwwroot folder as static assets.

Screenshot showing CSS file locations

Next, all the colours that you want to change when a theme is switched, need to be consolidated into these two CSS files as variables.

/* Dark theme colour variables */
:root {
    --app-bg: #1e1e2e;
    --app-card-bg: #2a2a3e;
    --app-surface-bg: #2a2a3e;
    --app-footer-bg: #1a3a4a;
    --app-text: #e0e0e0;
    --app-text-muted: #a0a0b0;
    --app-header-bg: #111111;
    --app-hover: 50%;
}
Enter fullscreen mode Exit fullscreen mode

I found the best way to identify the colours you want is to use browser dev tools to pick the elements and then get their colour data.

Do the same for the colors-light.css file (with different colours, obviously).

Now, the existing CSS files where these colours will be used need to be updated to use the CSS variables instead of explicit colours.

.note-list-item {
    display: flex;
    gap: 0.5rem;
    align-items: center;
    justify-content: space-between;
    background-color: var(--app-card-bg);
    padding: 0.5rem 0.8rem;

    &:hover {
        background-color: var(--app-hover);
    }

    button {
        color: var(--app-text);
        padding: 0;
        text-align: start;
        flex: 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is quite elegant because it means the CSS for you application doesn't need to change, it just needs to reference a CSS variable.

The trick is to change the value of the variable.

Repeat this same process for any CSS that needs to respond to a colour change.

The final piece is to wire up the theme CSS to your wherever CSS <link..../> references are added to your application.

<link id="theme-stylesheet" rel="stylesheet" href="css/colors-@(_theme).css" />
Enter fullscreen mode Exit fullscreen mode

We also need to set the default value when the page loads. I use a session variable but you could also use a cookie or some other persistence.

protected override void OnInitialized()
    {
        var themeSession = HttpContextAccessor.HttpContext?.Session.GetString("theme");
        _theme = themeSession is "dark" ? "dark" : "light";
    }
Enter fullscreen mode Exit fullscreen mode

NOTE: Your server-side syntax may vary from mine (Razor), but you should be able to work out what you need

Build the switcher

Switching the theme requires a control to make the HTMX request and a server endpoint to persist the change and update the UI.

Server endpoint

We first need an endpoint that will update our session and return our updated <ThemeSwitcher/>:

// Toggle the theme session value and return the new stylesheet link for HTMX to swap in-place.
        app.MapGet("/toggle", (HttpContext httpContext) =>
        {
            var currentTheme = httpContext.Session.GetString("theme") ?? "light";
            var newTheme = currentTheme == "light" ? "dark" : "light";
            httpContext.Session.SetString("theme", newTheme);

            return new RazorComponentResult<ThemeSwitcher>(new { 
                Theme = newTheme 
            });
        });
Enter fullscreen mode Exit fullscreen mode

Theme switch control

Again, use what works for you, but this is my Blazor <ThemeSwitcher/> component:

@inject IHttpContextAccessor HttpContextAccessor

<button type="button"
        class="btn btn-link"
        hx-get="/theme/toggle"
        hx-swap="outerHTML">
    @if (_isDark)
    {
        <Icon Name="sun" />
    }
    else
    {
        <Icon Name="moon" />
    }
</button>

<hx-partial hx-target="#theme-stylesheet" hx-swap="outerHTML">
    <link id="theme-stylesheet" rel="stylesheet" href=@($"css/colors-{Theme}.css") />
</hx-partial>

@code {
    [Parameter]
    public string? Theme { get; set; }

    private bool _isDark => Theme == "dark";

    protected override void OnInitialized()
    {
        // If the Theme parameter is not set (i.e., we're rendering this component for the first time), load the theme from the session
        if (Theme == null)
        {
            // Get the current theme from the session, defaulting to "light" if not set
            var themeSession = HttpContextAccessor.HttpContext?.Session.GetString("theme");
            Theme = themeSession ?? "light";
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It's pretty simple in how it works. It renders a button with HTMX attributes which displays either a sun or moon icon depending on the current theme.

The current theme will default to light unless there is a theme stored in session.

The key piece that makes it work is the <hx-partial>. Because we gave our <link... /> an id attribute, we can target it with an HTMX response. In this case, the HTML fragment will be a fresh <link... /> with our updated CSS reference.

The really cool bit is the browser will detect this change and the styles are applied immediately.

Putting it all together

Now, we have our CSS themes, endpoint, and switcher component ready, we can put them together.

In my case, I just dropped it in the <footer> element:

        <footer>
            <ThemeSwitcher />
            <form method="post" action="/auth/logout" id="logout-form">
                <button type="submit" class="btn btn-link">Logout</button>
            </form>            
        </footer>
Enter fullscreen mode Exit fullscreen mode

Which renders like this:

Footer in light mode

Now, let's click the switcher:

Footer in dark mode

This looks cool, but you really have to see it in action:

Animated GIF showing light and dark mode toggling

Next steps

Of course, this just shows two colour modes, but you could have as many colour themes as you want e.g. one for colour blindness. You just need to consolidate your colours and then make them an option in your switcher.

Top comments (0)