DEV Community

Cover image for CSS Light/Dark Mode Implementation WITHOUT Duplicating Vars
Jane Ori
Jane Ori

Posted on • Updated on

CSS Light/Dark Mode Implementation WITHOUT Duplicating Vars

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 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

Though it was broadly accepted as truth back when that article was written, there were ways around duplicating[1] 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[2] - except Safari - until Safari 16.4, around March 2023 when it began working everywhere!

PS: Bramus is great, follow him!


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!

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:

<div class="theme-dark">
  <span>render dark</span>
  <div class="theme-light">render light</div>
</div>
Enter fullscreen mode Exit fullscreen mode

as would this:

<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>
Enter fullscreen mode Exit fullscreen mode

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 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:

.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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 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:

: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);
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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

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

and:

<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>
Enter fullscreen mode Exit fullscreen mode

and this:

<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>
Enter fullscreen mode Exit fullscreen mode

and of course these too:

<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>
Enter fullscreen mode Exit fullscreen mode

Scenario Tests / Demo CodePen


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 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!


[1] "ways around duplicating" - You did have to duplicate some variable work if you want a Space Toggle 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

[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 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!

Top comments (0)