Dark mode on Tauri + Angular + Tailwind app

Jose I Santa Cruz G
ITNEXT
Published in
11 min readApr 19, 2023

--

or how to get dark mode without the dark: prefix

"Come to the Dark side, we have cookies…" Photo by R.D. Smith on Unsplash

The history behind

(You may skip this part as I'm not writing about Tauri, or Angular or Tailwind, neither placing code examples)

A good friend of mine is working in a great hardware MIDI controller pedal, for all (or most) home musicians that rely on software amps, or that desire to control another piece of hardware that lacks the extra buttons and has MIDI compatibility.

So far, what he has shown to me, is a great Wow!
But a good piece of hardware may also require a good piece of software. So I proposed a GUI for configuring this pedal. I own a Behringer FCB1010, and even though it's a great pedal, besides having lots of options it's not as simple to configure as one would expect, and available software is the same, too hard to understand, to hard to use for noob users. I ended installing a MIDI monitor press a button (trigger an action) on the controller, and use the default values. That works for me.

The GUI is built using Angular, styled using Tailwind CSS, and as it is supposed to be an installable application, I decided to use Tauri for this. The other choice was using Electron, but considering the app is going to send MIDI messages through a Java app, adding extra bloat to this doesn't seem like a good idea.

Tailwind

https://tailwindcss.com/

Won't talk much about Tailwind CSS. At first it was a really hard buy, as all HTML tag classes became extremely verbose:

<div class="flex flex-col text-right w-full">
<label class="text-xs font-light mr-1.5">Event value</label>
<span class="-mt-0.5">
<input type="number"
class="inline-block ml-auto w-12 rounded-md border border-slate-500 bg-neutral-50 text-slate-700 text-sm px-1 py-0"/>
<button class="font-bold ml-2"><span class="inline-block rotate-45 text-danger">&plus;</span></button>
</span>
</div>

But this has a reason, will get to that. Tailwind provide a way of "cleaning up" your HTML:

.number-input {
@apply inline-block ml-auto w-12 rounded-md border border-slate-500 bg-neutral-50 text-slate-700 text-sm px-1 py-0;
}
<div class="flex flex-col text-right w-full">
<label class="text-xs font-light mr-1.5">Event value</label>
<span class="-mt-0.5">
<input type="number"
class="number-input"/>
<button class="font-bold ml-2"><span class="inline-block rotate-45 text-danger">&plus;</span></button>
</span>
</div>

This would make certain sense if the .number-input style was used in many places, in this particular case it's used on one single component. Also, and I'm not saying it will always be the case, many times you'll be using some very specific styles for some HTML block, and reusability for CSS won't really apply.

So Tailwind provides a way on reusing classes. By specifying that verbose combination of CSS classes, we'll be allowing Tailwind to compile a really small CSS file that will include only the styles being used.
In Angular, most of the times SCSS compilation leads to a huge styles.<ugly_hash>.css file, and for webpages, more than 100Kb for a CSS file is really huge (remember those "all websites look the same" Bootstrap days? if you weren't using a CDN for Bootstrap your users would have to download 190 extra Kb just in CSS, not considering all custom CSS).

I still prefer clean HTML, and if possible, clean CSS styles, semantically build according to the HTML tags used, but Tailwind just works (and looks acceptably nice), and verbose classes in my HTML is a pain and cost I can take.

Angular + Tailwind

I'm using NX as a replacement for the Angular CLI, and adding Tailwind is really simple (https://nx.dev/recipes/other/using-tailwind-css-with-angular-projects):

npx nx g @nrwl/angular:setup-tailwind my-project

There are many other options, such as creating an Angular app with Tailwind, but in my case I already had the app, so I had to add Tailwind to my existing project.

Why NX? Not saying it's the best option for every project, but already got used to it. I've been working on several company projects arranged as a Monorepo and NX is pretty good at this.

A way of keeping your Angular component's SCSS files as empty as possible (making Tailwind take care of all the styling) is adding a class directly to the component selectors (when required), eg:

<section class="block h-auto pl-1">
<div class="flex flex-row w-full">
<div class="flex flex-col grow items-stretch">
<kowka-c6-folder />
<kowka-c6-save-button />
<dl class="border-t border-slate-300 grid grid-cols-3 mt-3 pt-3 px-3">
<dt class="font-semibold antialiased">Scene</dt>
<dd class="font-normal col-span-2 ml-4">Selected scene</dd>

<dt class="font-semibold antialiased">Button / Expr.</dt>
<dd class="font-normal col-span-2 ml-4">Selected control</dd>
</dl>
<p class="my-3 text-sm text-center italic">Remember to save your settings!</p>
</div>
<kowka-c6-expression-panel class="w-auto flex flex-wrap justify-end items-center content-center bg-grey"/>
</div>
<kowka-c6-button-panel class="flex flex-wrap justify-center items-stretch content-center bg-grey h-auto" />
</section>

As you can see both the expression-panel component and the button-panel component have their own class. To avoid this one would probably add something like:

// kowka-c6-expression-panel.scss
host: {
@apply w-auto flex flex-wrap justify-end items-center content-center bg-grey;
}

and

// kowka-c6-button-panel.scss
host: {
@apply flex flex-wrap justify-center items-stretch content-center bg-grey h-auto;
}

but Tailwind will compile a better (and smaller) CSS file when styles are placed directly on the HTML tags. @apply leads to clean HTML but the resulting CSS is not so small.

Tauri

https://tauri.app/

Finally Tauri. Tauri is a "not so new kid on the block", that comes to make some noise in the non-webapps development ecosystem. As I see it, it's aimed to reuse your web development team's expertises for installable apps. Using each OS native Webview, the resulting application is really lightweight, vs for eg an Electron app, that drags many Chromium and NodeJS libraries.

Not this rust — Photo by Zdeněk Macháček on Unsplash

One of the most important considerations for developing apps using Tauri is Rust. Rust, as a programming language, is growing in popularity, but older languages still have bigger ecosystems and communities, so there are many things expected to require extra efforts, coding workarounds and everything that drives us crazy when programming.
This WILL be the case when we reach the integration phase between the GUI and the app that writes de settings to the hardware controller. For that I expect making API calls to a simple Java backend, but as always, pain is to be expected. And by the way, Tauri does provide a way of bundling an external app that should interact with this web frontend, or as referred by Tauri, sidecars.

Instructions for installing Tauri are pretty clear, so I won't repeat what's already been written in the nice documentation.

What you DO have to know is a couple of settings to do after installing Tauri in your project.

  1. Run npm run tauri init (in the command line, inside your project's main folder)
  2. Edit/Check the created src-tauri/tauri.conf.json file. The build section should be something like:
"build": {
"beforeBuildCommand": "npx nx build --project your_project",
"beforeDevCommand": "npm start",
"devPath": "http://localhost:4200",
"distDir": "../dist/your_project"
},

where your_project is the project name you assigned when creating the project. If you have doubts on this, and you're using NX, you can check the name on the project's project.json file.

NX may have some problems when compiling, especially if you have configured it to use the NX distributed cache. In that case change the "beforeBuildCommand" to npx nx build --project your_project --skip-nx-cache and in that way you'll be always having a fresh compilation (in larger projects the NX distributed cache actually reduces compilation times).

Dark mode

Some facts on CSS dark mode:

  1. Browsers have their own internal CSS for dark and light modes.
  2. Browsers can detect the user's preference on light or dark modes.
  3. color-scheme detection can be done with pure CSS (no JavaScript involved).
  4. Tailwind has a CSS class prefix for dark mode. And here's where I'll develop the idea.
  <body class="bg-neutral-50 text-slate-700 dark:bg-slate-700 dark:text-neutral-50  h-screen select-none overflow-hidden mb-2 pb-2">
<app-root />
</body>

In light mode:

  • Background color: neutral-50
  • Text color: slate-700

In dark mode, Tailwind uses the values for CSS classes prefixed with dark: , in this case (a really simple example of dark mode), text and background colors are reversed (it's not always as simple as that):

  • Background color: slate-700
  • Text color: neutral-50

While this works if Tailwind's dark mode is configured to detect changes not using an extra dark class on the <html> tag, but using media as the value, the problem is that users cannot toggle switch between light and dark modes. media works for both browser AND Tauri (no theme toggle switch).

const { createGlobPatternsForDependencies } = require('@nrwl/angular/tailwind');
const { join } = require('path');

// tailwind.config.js snippet
module.exports = {
darkMode: 'media', // media not class
content: [
join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
...createGlobPatternsForDependencies(__dirname),
],
}

By replacing media with class, the way of toggling between light and dark modes requires some JavaScript in order to add a dark class to the <html> tag.

// Theme switcher component code snippet
@HostListener('change', ['$event']) onChange(event: Event) {
if((event?.target as HTMLInputElement)?.id === 'themeSwitcher') {
this.switchTheme((event?.target as HTMLInputElement)?.checked ? 'dark' : 'light');
}
}

// ... some other code

switchTheme(theme?: string) {
if (theme) {
this.themeSwitcher = theme === 'dark' ? true : false;
} else {
this.themeSwitcher = !this.themeSwitcher;
}

if (this.themeSwitcher) {
this.renderer.addClass(this.document.body.parentElement, 'dark');
} else {
this.renderer.removeClass(this.document.body.parentElement, 'dark');
}
}

and the theme switcher checkbox (styled with Tailwind):

<div class="flex flex-row justify-end items-center space-x-2 mx-auto float-right">
<span class="text-xs font-extralight">Light </span>
<div>
<input type="checkbox" name="themeSwitcher" id="themeSwitcher" class="hidden" [(ngModel)]="themeSwitcher" />
<label for="themeSwitcher" class="cursor-pointer">
<div class="w-9 h-5 flex items-center bg-gray-300 rounded-full p2">
<div class="switchball w-4 h-4 bg-white rounded-full shadow"></div>
</div>
</label>
</div>
<span class="text-xs font-semibold">Dark</span>
</div>

and some extra styles for the on/off switch animation:

#themeSwitcher:checked + label .switchball{
background-color: white;
transform: translateX(24px);
transition: transform 0.3s linear;
}

Works flawlessly on a browser, but the Tauri app doesn't seem to like nor detect classes with the dark: prefix.

If Tauri wasn't an issue this JavaScript less solution is a jewel: https://codepen.io/bramus/details/QWOYZqw
pure CSS using the :has selector. To make it work with Tailwind it requires some light changes, but it does work. But… the Tauri app was meant to have a light/dark mode switch so I needed to solve dark mode for Tauri.

CSS variables to the rescue

The proposed GUI in both light and dark modes (WIP)

As you can see in the images, the GUI is not really complex, neither is the color palette. So after reading nearly half of StackOverflow posts ended up here: https://stackoverflow.com/questions/69150928/how-to-create-multiple-themes-using-tailwind-css

Multiple themes is more than one theme, so the proposed solution applies correctly.
We'll be using both styles.scss and tailwind.config.js files.

In styles.scss file:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
:root {
--color-primary : theme(colors.slate.700);
--color-secondary: theme(colors.neutral.200);
--color-grey : theme(colors.stone.300);
--color-lightgrey: theme(colors.gray.200);
--color-danger : theme(colors.red.700);
--color-warning : theme(colors.orange.500);
--color-success : theme(colors.green.700);
--color-info : theme(colors.sky.700);
}

[data-theme="dark"] {
--color-primary : theme(colors.neutral.200);
--color-secondary: theme(colors.slate.700);
--color-grey : theme(colors.gray.600);
--color-lightgrey: theme(colors.gray.200);
--color-danger : theme(colors.red.400);
--color-warning : theme(colors.orange.400);
--color-success : theme(colors.green.400);
--color-info : theme(colors.sky.400);
}
}

// Ref. https://codepen.io/bramus/details/QWOYZqw
// html {
// color-scheme: light;
// }

// html:has(body #themeSwitcher:checked) {
// color-scheme: dark;
// }

This is a classic Tailwind SCSS setup.
:root and [data-the="dark"] color palettes are defined using CSS variables, that use Tailwind's color names (like theme(colors.neutral.50) and others).

When the <html> tag has a data-theme attribute with the value dark, the variable values will be overwritten.
Why use an attribute instead of a class? No special reason at all, except that using a data-theme attribute is more specific for the purpose of what's happening, we are changing the app's theme.

By defining the colors inside the @layer basesection, we make sure that Tailwind is going to generate the required styles for all our CSS variables.

In tailwind.config.js file:

const { createGlobPatternsForDependencies } = require('@nrwl/angular/tailwind');
const { join } = require('path');

/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'media',
content: [
join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
...createGlobPatternsForDependencies(__dirname),
],
theme: {
extend: {
colors: {
primary : 'var(--color-primary)',
secondary: 'var(--color-secondary)',
grey : 'var(--color-grey)',
lightgrey: 'var(--color-lightgrey)',
danger : 'var(--color-danger)',
warning : 'var(--color-warning)',
success : 'var(--color-success)',
info : 'var(--color-info)'
}
},
},
plugins: [
// require('@tailwindcss/typography'),
require('@tailwindcss/forms'),
// require('@tailwindcss/line-clamp'),
// require('@tailwindcss/aspect-ratio'),
],
};

The colors property is defined ONLY on theme.extend.colors otherwise you'll have to import all other Tailwind colors you use in your templates. I tried to resemble Bootstrap color classes, just for simplicity.

OK, we've got the styles, now we need the ThemeSwitcher.component to do the magic. We need JavaScript, no big deal as the whole app is Angular 😬

import { Component, HostListener, OnInit, Renderer2, inject } from '@angular/core';
import { CommonModule, DOCUMENT, NgClass } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

@Component({
selector: 'kowka-c6-theme-switcher',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
NgClass
],
templateUrl: './theme-switcher.component.html',
styleUrls: ['./theme-switcher.component.scss'],
})
export class ThemeSwitcherComponent implements OnInit {
themeSwitcher = false;
renderer = inject(Renderer2);
document = inject(DOCUMENT);

/**
* Checks user preferences for light or dark mode, from the OS setup.
* Sets the variable themeSwitcher to true or false, true is dark mode.
* Calls the switchTheme function to set the theme.
*/
ngOnInit(): void {
this.themeSwitcher = window?.matchMedia('(prefers-color-scheme: dark)').matches;
this.switchTheme(this.themeSwitcher ? 'dark' : 'light');
}

/**
* Hostlistener watch for themeSwitcher checkbox value changes
*/
@HostListener('change', ['$event']) onChange(event: Event) {
if((event?.target as HTMLInputElement)?.id === 'themeSwitcher') {
this.switchTheme((event?.target as HTMLInputElement)?.checked ? 'dark' : 'light');
}
}

/**
* If required, adds a data-theme attribute to the html element, with the value dark.
* @param theme {string} optional, dark sets dark mode. If not set, it will toggle the themeSwitcher variable
*/
async switchTheme(theme?: string) {
if (theme) {
this.themeSwitcher = theme === 'dark' ? true : false;
} else {
this.themeSwitcher = !this.themeSwitcher;
}

if (this.themeSwitcher) {
this.renderer.setAttribute(this.document.body.parentElement, 'data-theme', 'dark');
} else {
this.renderer.removeAttribute(this.document.body.parentElement, 'data-theme');
}
}
}

Template and SCSS code still the same as a few examples above.

And best of all, it works, for web apps and Tauri apps. It may require some extra thinking when defining both light/default and dark color palettes, perhaps your apps are not as simple as this one, but you got the idea. Tailwind's dark: prefix for CSS classes on dark mode is not bad, but it just doesn't cover all cases, and Tauri is one of those nasty exceptions.

I hope you enjoyed this article and if you please, share with me any thoughts or dark mode strategies for web or "web flavoured" desktop apps 😉

Stay safe.

--

--

Polyglot senior software engineer, amateur guitar & bass player, geek, husband and dog father. Not precisely in that order.