Aral Balkan

Mastodon icon RSS feed icon

Implementing dark mode in a handful of lines of CSS with CSS filters

I finally got round to implementing dark mode for this site (the cobbler’s children have no shoes and all that…)

Here’s all the CSS I had to add:

@media (prefers-color-scheme: dark) {
  /* Invert all elements on the body while attempting to not alter the hue substantially. */
  body {
    filter: invert(100%) hue-rotate(180deg);
  }

  /* Workarounds and optical adjustments. */

  /*
     Firefox workaround: Set the background colour for the html
     element separately because, unlike other browsers, Firefox
     doesn’t apply the filter to the root element’s background.
  */
  html {
    background-color: #111;
  }

  /* Do not invert media (revert the invert). */
  img, video, iframe, svg {
    filter: invert(100%) hue-rotate(180deg);
  }

  /*
    Videos running fullscreen are no longer affected by the
    filter on the body so we need to also unset the
    revert we applied earlier so we’re left with no filter again.
   */
  video:fullscreen {
    filter: none;
  }

  /* Improve contrast on icons. */
  .icon {
    filter: invert(15%) hue-rotate(180deg);
  }

  /* Re-enable code block backgrounds. */
  pre {
    filter: invert(6%);
  }

  /* Improve contrast on list item markers. */
  li::marker {
    color: #666;
  }
}

How it works

The real magic happens in the first rule:

body {
  filter: invert(100%) hue-rotate(180deg);
}

I run an invert filter alongside a hue rotate filter on the body element. That inverts the colours on the page while attempting to keep the hues as similar as possible.

You might be wondering why I didn’t apply this rule to the root html element. Thing is, originally, I did1. However, unlike other browsers, Firefox does not apply the invert filter to the background of the root element.

So, instead, I have to set the HTML element’s background manually, in the section on workarounds and optical adjustments:

html {
  background-color: #111;
}

(Since the background colour of the page is #eee, I set the dark mode background to its inverse, #111, which matches what the invert filter does for the body.)

Next, I re-inverting media like images, videos, and iframes so they appear as originally intended:

img, video, iframe, svg {
  filter: invert(100%) hue-rotate(180deg);
}

(As you can see, I’m simply running the same rule again to revert the invert.)

That all works until you take a video fullscreen, at which point, you’ll see that the video appears inverted again. This is because the video is taken out from its original location under the body and so the filter rule no longer applies to it, leaving just our revert rule which, without the original invert, ends up inverting it (confused yet?)

In any case, the fix is to remove the filter altogether for videos playing fullscreen, which is easilly done:

video:fullscreen {
  filter: none;
}

Finally, the rest of the rules are little opticals tweaks, to improve contrast on a few elements like the icons in the header and the backgrounds of code block that don’t fare well with the inversion filter:

/* Improve contrast on icons. */
.icon {
  filter: invert(15%) hue-rotate(180deg);
}

/* Re-enable code block backgrounds. */
pre {
  filter: invert(6%);
}

/* Improve contrast on list item markers. */
li::marker {
  color: #666;
}

If you’re looking to implement dark mode on your web site, you don’t use shadows/shades to denote hierarchy, and you don’t have a huge amount of time on your hands to craft a separate stylesheet, CSS filters are your friend.

Enjoy!

Like this? Fund us!

Small Technology Foundation is a tiny, independent not-for-profit.

We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.


  1. Thanks to Kieran Barker, varx, Silmathoron, and others on the fediverse for pointing out the issue with Firefox and suggesting potential fixes. ↩︎