Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active April 9, 2024 20:01
Show Gist options
  • Star 29 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save loilo/dd5639089d837e95c22a706260b26706 to your computer and use it in GitHub Desktop.
Save loilo/dd5639089d837e95c22a706260b26706 to your computer and use it in GitHub Desktop.
Sass Dark/Light Theme Mixin

Sass Dark/Light Theme Mixin

This is a Sass mixin to handle a 3-way dark mode. It relies on a data-theme attribute on your <html> element with a value of light or dark. If data-theme is absent (i.e. it's neither light nor dark), the system's preferred mode is used.

body {
  // matches data-theme="light" or data-theme="auto" with system instructing light mode
  @include light {
    background: white;
    color: black;
  }
  
  // matches data-theme="dark" or data-theme="auto" with system instructing dark mode
  @include dark {
    background: black;
    color: white;
  }
}

Caveat 1: If a browser you're targeting does not support the prefers-color-theme media rule (for example Internet Explorer), auto-detection of the color mode does not work. You need to provide fallback styles in that case instead:

// Use light theme by default
body {
  color: black;
  background: white;

  // Override in explicit or implicit dark mode
  @include dark {
    color: white;
    background: black;
  }
}

Caveat 2: The mixin prepends your selector with a check for the data-theme attribute on the :root element. That means you cannot use the mixin applied to a selector that already includes the root element:

// This results in unmatchable selectors containing :root twice
:root .foo {
  @include dark {
    background: black;
  }
}

// Do this instead:
@include dark {
  .foo {
    background: black;
  }
}
@mixin light() {
@media (prefers-color-scheme: light) {
@at-root #{selector-nest(':root:not([data-theme=light]):not([data-theme=dark])', &)} {
@content;
}
}
@at-root #{selector-nest(':root[data-theme=light]', &)} {
@content;
}
}
@mixin dark() {
@media (prefers-color-scheme: dark) {
@at-root #{selector-nest(':root:not([data-theme=light]):not([data-theme=dark])', &)} {
@content;
}
}
@at-root #{selector-nest(':root[data-theme=dark]', &)} {
@content;
}
}
@geoffreycrofte
Copy link

Hey.
Great idea, but doesn't work in that case:

.button,
[type=button] {
    @include dark {
        // do something
    }
}

will generate something like:

:root[data-theme=dark] .button, [type=button] {
    // do something
}

is suppose the output should be

:root[data-theme=dark] .button,
:root[data-theme=dark] [type=button] {
    // do something
}

Searching for a solution right now but Sass isn't my native language :D
Thanks :)

@loilo
Copy link
Author

loilo commented Apr 4, 2021

Heh, good catch @geoffreycrofte. I have adjusted my code to consider this use case by using selector-nest instead of directly interpolating the &. 👍

@geoffreycrofte
Copy link

Thanks for this :)

@studiolxv
Copy link

Thank you Florian, I am grateful you knocked this out and shared.

@tandy-1000
Copy link

I seem to be having an issue getting this to work, dark mode works, but I can't seem to use light mode.
My site is deployed here: https://listen2gether.github.io/

Here's the mixin you defined (converted to Sass, I only include the dark mixin as I want to default to light theme):
https://github.com/Listen2gether/Listen2gether.github.io/blob/develop/src/templates/sass/include/_themes.sass

Here is an example of where I use the dark mixin:
https://github.com/Listen2gether/Listen2gether.github.io/blob/61506d4feae3d4a525df4e8698d2c5c223a28e42/src/templates/sass/general.sass#L11

In my Javascript, the dark theme toggle correctly sets the data-theme attribute.

Not really sure where I could be going wrong..

@studiolxv
Copy link

studiolxv commented Mar 25, 2022

@tandy-1000 Pretty sure you need to @include light mixin additionally to each selector location the dark mixin appears, thats how I do it. If you check with your system preferences. Your site is working properly for system prefers color scheme, but the toggle only controls the styles if you include the light mixin. Meaning you dont have the light styles and your computer is probably currently set to dark.

Hope this helps.

theme-toggle-listen2gether github io

Also you seem to be setting the data-theme attr on the body, the sass is for root: -> <html> tag

"The :root pseudo-class represents an element that is the root of the document. In HTML, this is always the HTML element. "

Defaulting to light can be done by hard coding in the <html data-theme="light"> and fixing your js to target html instead of body

@tandy-1000
Copy link

tandy-1000 commented Mar 25, 2022

@tandy-1000 Pretty sure you need to @include light mixin additionally to each selector location the dark mixin appears, thats how I do it. If you check with your system preferences. Your site is working properly for system prefers color scheme, but the toggle only controls the styles if you include the light mixin. Meaning you dont have the light styles and your computer is probably currently set to dark.

Thanks for the demo. Good to see that it's working at least with system preferences, I added the light mixin, and enabled it in a couple selectors, but this doesn't seem to have changed anything..

Caveat 1: If a browser you're targeting does not support the prefers-color-theme media rule (for example Internet Explorer), auto-detection of the color mode does not work. You need to provide fallback styles in that case instead:

BTW, I'm trying to use this approach to default the site to the light theme when using old browsers, so I figured I wouldn't need the light mixin.

@loilo
Copy link
Author

loilo commented Mar 25, 2022

@tandy-1000 You're setting the data-theme attribute on the <body>, but it needs to be set on the <html>. Change that and it'll work. 🙂

@tandy-1000
Copy link

@tandy-1000 You're setting the data-theme attribute on the <body>, but it needs to be set on the <html>. Change that and it'll work. slightly_smiling_face

Thank you so much! This is a much cleaner method than what I previously had :)

@loilo
Copy link
Author

loilo commented Mar 25, 2022

Glad it's helping. 👍

@hotequil
Copy link

Very good!

@robbiemu
Copy link

robbiemu commented Sep 11, 2022

is it possible to write your styles with this such that in root you are defining surface, ink, and brand color variables and then within each style only referring to the variables, or do you have to repeat color conversions with these mixins in every style that has a color?

for a toy example,

$surface-color: whitesmoke !default;
$ink-color: #001 !default;
$primary-color: red !default;
$secondary-color: green !default;
$tertiary-color: blue !default;
$quartary-color: purple !default;
$accent-color: #999 !default;
  
:root {
  --surface-color: #{$surface-color};
  --ink-color: #{$ink-color};
  --primary-color: #{$primary-color};
  --secondary-color: #{$secondary-color};
  --tertiary-color: #{$tertiary-color};
  --quartary-color: #{$quartary-color};
  --accent-color: #{$accent-color};
}

:root, body {
  color: var(--ink-color);
  background-color: var(--surface-color);
}

.accent-background {
  background-color: var(--accent-color);
}
<html>
  <body>
    <span>text</span>
    <span class=accent-background>accent</span
  </body>
</html>

We can imagine other stylesheets might use these variables to set colors. It would be nice if we only needed to bring in the light/dark mixin once, rather than having to keep two definitions for colors in every style in addition to this root one. (although we might still want to bring it in sometimes, for special color definitions)

@loilo
Copy link
Author

loilo commented Sep 11, 2022

@robbiemu It is of course possible to use CSS variables once, however you need to use the mixin inside a selector. The body would probably be the way to go here:

body {
  --color: red;

  @include dark {
    --color: blue;
  }
}

@robbiemu
Copy link

robbiemu commented Sep 11, 2022

(edit: I was having a build error!) I do see that works, with the only sacrifice being that it is on body instead of root. Not bad at all! thank you

@thcrt
Copy link

thcrt commented Jun 22, 2023

Hey @loilo -- thanks for sharing this. What's it licensed under?

@loilo
Copy link
Author

loilo commented Jun 23, 2023

@wonsuc
Copy link

wonsuc commented Mar 26, 2024

I wonder why you changed selector-nest to selector-append. Was there any reason for this?

@loilo
Copy link
Author

loilo commented Mar 26, 2024

@wonsuc Because I changed from nesting to appending. 😁 See the latest revision.

Why do this?

Well, let's say I have a selector .foo where I want to use the mixin:

.foo {
  @include dark {
    background: black;
  }
}

This previously generated a selector like :root.is-dark-mode .foo, using selector-nest. (This selector is not what it actually generated, which was a little more complex, but it shows what nesting a selector does.)

However, this approach created issues when the .foo selector itself already included the :root, e.g. if you wanted to style something based on a modifier class on the root element, e.g.

:root.loading .foo {
  opacity: 0.5;

  @include dark {
    opacity: 0.8;
  }
}

That would generate :root.is-dark-mode :root.loading.foo, which would obviously never match as :root cannot be found inside :root.

Therefore, I switched the selector generation over to using the :where. pseudo class. That feature was not available in browsers when I originally published the gist, that's why I didn't use it in the first place.

Now using selector-append and :where, the first code sample would generate the selector .foo:where(:root.is-dark-mode *), and the second one would generate :root.loading .foo:where(:root.is-dark-mode *) – which do both work.

@wonsuc
Copy link

wonsuc commented Mar 26, 2024

Thank you for detail explanation.
I asked this because it didn't work with ::before, ::after pseudos.

For examples,

.foo::before {
  @include dark {
    opacity: 0.5%;
  }
}

I'm looking for an approach which can solve this problem.

@loilo
Copy link
Author

loilo commented Mar 26, 2024

@wonsuc You're right. I was aware of that caveat, but didn't add it to the readme. Your case inspired me to adjust the mixins. They're slightly more complex now, but they also support pseudo elements like ::before now.

@wonsuc
Copy link

wonsuc commented Mar 27, 2024

Thank you for your efforts. Works good :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment