Dark Mode and Dark Context

Most of the time, there is two way to implement dark mode support in an application.

  • Using media queries with prefers-color-scheme.
  • Using a .dark class and add/remove it with JS

The first one is, in my opinion, the best of the two. The second one allows users to change between dark and light mode in the application without switching the whole system.

Why do I think media queries are the best way? Because most JS-based implementations come with bugs, the most common is a flash of the incorrect theme, where you see light mode when you want dark mode or vice versa. Another common bug, which is my most hated one, is when the app forgets the selected theme, usually because they store it in the session. The theme switches back to the default one, usually light.

Some apps come with another option to have a theme switcher with three options.

  • Light
  • Dark
  • System Preference

So you can tell the app, I want it always in light mode, always in dark mode, or I want it to respect my OS preference. I usually use this, but sometimes if they forget the selected theme, they will switch back to the light one, and since I mostly use dark in my OS, it's annoying.

Instead, if you always use media queries, you will not have this problem. Suppose you want users to be able to change between light/dark mode in specific websites. In that case, we should convince the browser vendors to add the toggle. They can set the media query to be the one the user picked instead of the system one, which can be the default. They can store it in a better and more durable way and even share the preference between desktop and mobile.

Let's stop ranting and start with what I think is a better way to do light/dark mode support in an application.

Scheme Context

Instead of the app reacting globally to a light/dark mode, you should define the scheme context. What's this? Basically, the app should adapt to the container scheme mode.

Doing this allows you to say, "the user is in light mode, but this section should use dark mode", and then let the entire content inside that section switch the dark mode color scheme.

The easiest way to do this is by using CSS variables two classes. So you can create this classes:

.scheme-light {
  --background-color: white;
  --foreground-color: black;
}
.scheme-dark {
  --background-color: black;
  --foreground-color: white;
}

Now, you can define any element to use those colors:

.component {
  background-color: var(--background-color);
  color: var(--foreground-color);
}

And use it like this:

<section class="scheme-light">
  <div class="component"><!-- Content here --></div>
</section>
<section class="scheme-dark">
  <div class="component"><!-- Content here --></div>
</section>

And if you render .component inside .scheme-light, the colors will be white for the background and black for the text, but if you place it inside .scheme-dark, the colors will be black for the background and white for the text.

You can also use media queries to change the colors of those classes:

@media (prefers-color-scheme: dark) {
  .scheme-light {
    --background-color: black;
    --foreground-color: white;
  }
  .scheme-dark {
    --background-color: white;
    --foreground-color: black;
  }
}

Now, if the user system preference is to use a dark mode, most sections will use white text on a black background, and the "dark" sections will use the inverted colors.

So you are basically inverting the colors of each section. Thanks to CSS variables, it's easy to let the components automatically adapt to the scheme change based on the context used. You can even keep nesting them.

<section class="scheme-dark">
  <div class="component">
    <!-- Here the content uses a dark theme -->
    <div class="scheme-light">
      <!-- But here, it will use a light theme -->
      <div class="scheme-dark">
        <!-- And here it will go back to use a dark theme! -->
      </div>
    </div>
  </div>
</section>

Use it with Tailwind

If you use Tailwind, they have built-in support for using only media queries or only classes to support dark mode. You could use CSS Variables plus media queries if you want to keep Scheme Contexts. Still, if you're going to use the dark: variant with any class, it will only work if dark mode is enabled system-wide.

If you switch to classes, it will only respect the dark one, so if you nest a light Scheme Context inside a dark Scheme Context, any class with the dark: variant will remain dark.

So to be able to use this you will need to create a little plugin:

// tailwind.config.js
const plugin = require("tailwindcss/plugin");

module.exports = {
  plugins: [
    plugin(function lightDarkMode({ addVariant, e }) {
      // this will create the `dark` variant
      addVariant("dark", ({ modifySelectors, separator }) => {
        modifySelectors(({ className }) => {
          // and it will work if your class is nested inside a dark context
          return `.dark .${e(`dark${separator}${className}`)}`;
        });
      });

      // this will create the `light` variant
      addVariant("light", ({ modifySelectors, separator }) => {
        modifySelectors(({ className }) => {
          // and it will work if your class is nested inside a light context
          return `.light .${e(`light${separator}${className}`)}`;
        });
      });
    }),
  ],
};

Now you can use light: or dark: variants to define specific styles for an element based on their scheme context.

Usage with React

When you read Scheme Context, you thought on React Context, this idea is heavily inspired by React Context, but you don't need to use it. However, using a React component may still be helpful.

You could create a little component to let you change the Scheme Context:

import clsx from "clsx"; // a lib to concat classes (strings in general)
import type { ReactNode } from "react";

interface ColorSchemeProviderProps {
  scheme: "light" | "dark";
  children: ReactNode;
}

export function ColorSchemeProvider({
  scheme,
  children,
}: ColorSchemeProviderProps) {
  return (
    <div
      className={clsx("contents", {
        // add display: contents always
        "scheme-light": scheme === "light", // and the scheme-light
        "scheme-dark": scheme === "dark", // or scheme-dark based on the prop
      })}
    >
      {children}
    </div>
  );
}

Now you can render it in your application like this:

<ColorSchemeProvider scheme="light">
 // content here
</ColorSchemeProvider>

<ColorSchemeProvider scheme="dark">
 // content here
</ColorSchemeProvider>