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

See the example above on sassed

Caveat: This mixin targets modern browsers which support the :where() pseudo selector. There is an older revision of this Gist with more caveats, but also with support for older browsers, which you can find here.

@use 'sass:selector';
@use 'sass:string';
@use 'sass:list';
@mixin mode($mode) {
$opposite-mode: '';
@if $mode == 'light' {
$opposite-mode: 'dark';
} @else {
$opposite-mode: 'light';
}
$individual-selectors: selector.parse(&);
$new-selectors: ();
// Split into individual selectors
@each $individual-selector in $individual-selectors {
// Split by :: to find pseudo elements
$parts: string.split(#{$individual-selector}, '::', 2);
@if list.length($parts) > 1 {
// Selector has pseudo elements:
// Split up the selector, apply mode modifier and stick the pseudo element on again
$new-selectors: list.append(
$new-selectors,
"#{selector.append(list.nth($parts, 1), ':where(:root:not([data-theme=#{$mode}]):not([data-theme=#{$opposite-mode}]) *)')}::#{list.nth($parts, 2)}",
comma
);
} @else {
// Selector has no pseudo elements: Simply append the :where() clause
$new-selectors: list.append(
$new-selectors,
selector.append($individual-selector, ':where(:root:not([data-theme=#{$mode}]):not([data-theme=#{$opposite-mode}]) *)'),
comma
);
}
}
@media (prefers-color-scheme: $mode) {
@at-root #{$new-selectors} {
@content;
}
}
@at-root #{$new-selectors} {
@content;
}
}
@mixin light {
@include mode('light') {
@content;
}
}
@mixin dark {
@include mode('dark') {
@content;
}
}
@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