CSS Light/Dark Mode Implementation WITHOUT Duplicating Vars
Tags: howto, css, webdev, tutorial
Published: 2023-09-06 18:14:52

A common perspective on the challenges behind implementing light mode and dark mode toggles in CSS is that it seemingly requires duplicating your --var declarations in order to set up the classes.

It doesn't - and it's an easy pattern to implement!

---

## A brief history

As an example, [this article from Bramus](https://www.bram.us/2022/05/25/dark-mode-toggles-should-be-a-browser-feature/#code-duplication-as-a-red-flag) in May 2022 states the common perspective outright:

> ![Screenshot of an excerpt from the article linked above showing a warning from the author that duplication is necessary, plus the CSS code to demonstrate it](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k8ii2oprwdmzvr8dghn5.png)

Though it was broadly accepted as truth back when that article was written, there were ways around duplicating<sup>[1]</sup> the actual theme color value, which is the bulk of the concern. But... those approaches also left room for improvement.

That improvement was found, and worked in all browsers<sup>[2]</sup> - except Safari - [until Safari 16.4, around March 2023](https://twitter.com/Jane0ri/status/1640570291087720448) when it began working everywhere!

<sub>PS: [Bramus is great, follow him!](https://twitter.com/bramus)</sub>

---

## The Desired API

First, the biggest ask from the community is that we can do this all from an actual media preference, without a checkbox hack or making JS toggle a class on load to flip the state, and [that work is underway now](https://twitter.com/intenttoship/status/1699185089718984930)!

{% embed https://twitter.com/intenttoship/status/1699185089718984930 %}

In the meantime, and even after that's implemented, it's also really nice to have the flexibility of classes that manage it in addition to the preference.

Here is the api:

- dark by default
- `@media (prefers-color-scheme: light)` light on root and descendants by preference
- `.theme-preferred` render this node (& desc.) based on the default root preference
- `.theme-not-preferred` render this node (& desc.) as the opposite of the default root preference
- `.theme-dark` render this node (& desc.) in dark
- `.theme-light` render this node (& desc.) in light

Then, additionally, mix and match that api. Any section or element, no matter the parents or preference, you can toggle by flipping preference or by managing light/dark directly.

For example, this would render exactly as expected:

```html
<div class="theme-dark">
  <span>render dark</span>
  <div class="theme-light">render light</div>
</div>
```

as would this:

```html
<main class="theme-preferred">
  preferred theme here even if body or html changed it
  <div class="theme-light">light</div>
  <div class="theme-dark">dark</div>
  <section class="theme-not-preferred">
    render the opposite of the preference
    <div class="theme-preferred">render the preference</div>
    <div class="theme-light">light</div>
    <div class="theme-dark">dark</div>
  </section>
</main>
```

---

## The DRY Implementation

If you are unfamiliar with Space Toggles, this setup can be copy pasted without any technical knowledge; you don't have to use them after initial setup.

Just following the pattern will enable you to take full advantage of it!

> If you ARE interested, [you can read about space toggles here from when I first invented the idea](https://github.com/propjockey/css-sweeper#css-is-a-programming-language-thanks-to-the-space-toggle-trick) or google it for the many articles and talks that have come out about them since then!

> In short, you're adding a light switch to other values. If the switch is a space (on), the value next to it is used, if it was `initial` (off) then the value isn't used and the fallback takes its place when it's referenced.

> (And yep, it is spec!)

### Boilerplate

First, copy paste this exactly as it is. No matter how many variables you need to toggle between themes, this engine never changes and does all the work:

```css
.theme-not-preferred,
.theme-light {
  --media-prefers-light: ;
}
.theme-preferred,
.theme-dark {
  --media-prefers-light: initial;
}
@media (prefers-color-scheme: light) {
  :root:not(.theme-dark):not(.theme-not-preferred),
  .theme-preferred {
    --media-prefers-light: ;
  }
  .theme-not-preferred {
    --media-prefers-light: initial;
  }
}
```

This sets up a `--media-prefers-light` space toggle that flips in accordance with our desired API described previously. (Media settings and space toggles were made for each other; check out [css-media-vars](https://github.com/propjockey/css-media-vars#example-of-what-your-mobile-first-css-would-look-like) for a whole collection of them and the huge improvements they offer!)

### Your theme values

Next, we'll set up just 3 theme-dependent variables to establish the pattern:

```css
:root,
.theme-preferred,
.theme-not-preferred,
.theme-light,
.theme-dark {
  /* const maybe_light_val = lightTog && YourLightValue: */
  --theme_0_light: var(--media-prefers-light) 0 0% 100%;
  --theme_1_light: var(--media-prefers-light) 0 0% 0%;
  --theme_2_light: var(--media-prefers-light) hotpink;
  --theme_scheme: var(--media-prefers-light) light;

  /* const theme-var = maybe_light_val || YourDarkValue: */
  --theme-0: var(--theme_0_light, 0 0% 0%);
  --theme-1: var(--theme_1_light, 0 0% 100%);
  --theme-2: var(--theme_2_light, rebeccapurple);
  color-scheme: var(--theme_shceme, dark);
}
```

You won't use the light vars like `--theme_1_light` directly anywhere else, they're only meant as internal vars (hence underscore naming convention) for setting up the vars you actually want to use everywhere, `--theme-1` etc.

### Usage

Finally, all you have to do is use the theme variables wherever you want in your CSS:

```css
.my-info-box-component {
  background: hsl(var(--theme-0));
  color: hsl(var(--theme-1));
  border: 1px solid var(--theme-2);
  padding: 1rem;
}
```

Then it works in your HTML in all the api scenarios as expected:

```html
<aside class="my-info-box-component">
  My border is:<br>
  - `hotpink` with light mode preference<br>
  - `rebeccapurple` with dark mode preference.
</aside>
```

and:
```html
<aside class="my-info-box-component theme-not-preferred">
  My border is:<br>
  - `rebeccapurple` with light mode preference<br>
  - `hotpink` with dark mode preference.
</aside>
```

and this:
```html
<section class="theme-not-preferred">
  ...
  <aside class="my-info-box-component">
    My border is:<br>
    - `rebeccapurple` with light mode preference<br>
    - `hotpink` with dark mode preference.
  </aside>
  ...
  <aside class="my-info-box-component theme-preferred">
    My border is:<br>
    - `hotpink` with light mode preference<br>
    - `rebeccapurple` with dark mode preference.
  </aside>
</section>
```

and of course these too:
```html
<aside class="my-info-box-component theme-dark">
  My border is `rebeccapurple`
</aside>

<section class="theme-dark">
  <div>
    ...
    <div>
      ...
      <div>
        ...
        <aside class="my-info-box-component">
          My border is `rebeccapurple`
        </aside>
        ...
      </div>
      ...
    </div>
    ...
  </div>
</section>
```

---

## Scenario Tests / Demo CodePen

{% codepen https://codepen.io/propjockey/pen/RwVNybQ default-tab=result theme-id=40148 %}

---

## The End!

If you think this is useful, it's the kind of thing I do for fun all the time! So please do consider following me here [and on X](https://twitter.com/Jane0ri) as well!

👽💜
// Jane Ori

> PS: I've been laid off recently and am looking for a job!

> https://linkedin.com/in/JaneOri

> Over 13 years of full stack (mostly JS) engineering work and consulting, ready for the right opportunity!

---

<sub>[1] "ways around duplicating" - You did have to duplicate some variable work if you want a [Space Toggle](https://github.com/propjockey/css-sweeper#css-is-a-programming-language-thanks-to-the-space-toggle-trick) powered API like the one demonstrated in this article, but the primary concern could be avoided; no duplication of the actual theme color values is necessary. [DRY Theme color values, version that works in all browsers](https://codepen.io/propjockey/pen/MWmYQvr?editors=1100)</sub>

<sub>[2] improved version that "worked in all browsers except Safari" - The method described in this article worked in FireFox and Chrome for many years BUT because Safari technically had it right according to the CSS spec, FF & Chrome "fixed it" in coordination with each other (so all 3 major browsers were in harmony), then in response [I filed an issue](https://github.com/w3c/csswg-drafts/issues/5370) to change the spec, successfully got all parties to agree (and break harmony again), the CSS Spec changed to describe the previous behavior as the new spec, FF and Chrome reverted to the previous behavior in step, aaand Safari updated later, resulting in a new, improved, harmony!</sub>