DEV Community

loading...
Cover image for Create your own Dark Mode Detection Observable using RxJS and Media Queries

Create your own Dark Mode Detection Observable using RxJS and Media Queries

tanepiper profile image Tane Piper Originally published at tane.dev ・5 min read

Demo Link

One of the more recent features available in browsers is the ability to do CSS Media Queries based on user theme & accessibility settings in the operating system - for example using @media (prefers-color-scheme: dark) (see prefers-color-scheme) you can check if the user's OS theme is currently in Dark Mode and use this to set a websites theme accordingly.

The query is also available in JavaScript using the window.matchMedia function - that returns a MediaListQuery that will allow us to do two things:

  • The current value of the users setting via the matches boolean property
  • Any future values by listening to its changes event and attaching an event lister function to it

Combining these, it's the perfect candidate to turn into a fully reactive dark mode switcher using RxJS and Observables that will give us the users current setting. If you're not familiar with Observables, they are a type of stream that emits values over time - consumers can subscribe to these Observables to get their values - this means we can use them to get values from long running functions or event emitters.

In the full demo you'll find an example page with light and dark mode set from your own OS settings. To see the full working example you need to change the setting (e.g. in OSX Dark Mode is under "General" settings) - also provided are a user toggle button, and a button to turn off and on the media query listener. The Observable in this example supports more than one prefers- type of query but in the tutorial below we'll build a much simpler isDarkMode Observable than the one provided in the demo, but the concept is the same.

Creating a Dark Mode Observable

For our code we first need to create our Observable factory - this is the function that allows us to pass any required parameters for the implementation and returns an Observable which can then be subscribed to.

The Observable constructor takes a function - a callback any time there is a new subscription - this is where the implementation will live.

As soon as the subscription opens, we first check to see if window.matchMedia is available - it should be available in all modern browsers but is not available in environments like node (yay unit testing!) - so here we can throw an error.

The factory also accepts an optional AbortSignal, an object that contains an onabort callback - the parent of the signal is
a AbortController and using this we can externally signal our Observable to close all subscriptions and remove all event listeners.

The return value of the constructor is another function - the teardown logic - this is called when an RxJS subscription is ended, such as using takeUntil or take(1) - here we also ensure that all subscriptions and event listeners are closed.

import { Observable } from 'rxjs';

export function isDarkMode(signal?: AbortSignal): Observable<boolean> {
  return new Observable<boolean>(subscriber => {

    if (!window.matchMedia) {
      subscriber.error(new Error('No windows Media Match available'));
    }

    if (signal) {
      signal.onabort = () => {
        !subscriber.closed && subscriber.complete()
      }
    }

    return () => {
      !subscriber.closed && subscriber.complete()
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Adding the Media Query

The main implementation of our Observable is to create our MediaListQuery and use it to emit values to any subscribers. On creation, contain a matches value of true or false which can be immediately be passed to subscriber.next.

We also need to bind a listener using to the change event of the query. As we also need to remove this later create an internal private function for the event handler - this will also call subscriber.next each time there is a detected change.

Also casting the event to a MediaQueryListEvent ensures TypeScript recognises it has the matches property which contains our value.

function emitValue(event: Event) {
  subscriber.next((event as MediaQueryListEvent).matches);
}

const mediaListQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaListQuery.addEventListener('change', emitValue);
subscriber.next(mediaListQuery.matches);
Enter fullscreen mode Exit fullscreen mode

Cleaning up handlers and subscriptions

Already we can start to use the new Observable, but we also need to make sure that we:

  • End any subscriptions to the Observable when either the AbortSignal fires or RxJS unsubscribes from it
  • Remove any event listeners in the DOM for the change event

With a slight bit of refactoring we have our final Observable factory below - in both the signal.onabort and the
Observable teardown logic we remove the event listener - the API for this requires you pass the function implementation
from our private function.

import { Observable } from 'rxjs';

export function isDarkMode(signal?: AbortSignal): Observable<boolean> {
  return new Observable<boolean>(subscriber => {

    if (!window.matchMedia) {
      subscriber.error(new Error('No windows Media Match available'));
    }

    function emitValue(event: Event) {
      subscriber.next((event as MediaQueryListEvent).matches);
    }

    const mediaListQuery = window.matchMedia('(prefers-color-scheme: dark)');

    if (signal) {
      signal.onabort = () => {
        mediaListQuery.removeEventListener('change', emitValue)
        !subscriber.closed && subscriber.complete()
      }
    }

    mediaListQuery.addEventListener('change', emitValue);
    subscriber.next(mediaListQuery.matches);

    return () => {
      mediaListQuery.removeEventListener('change', emitValue);
      !subscriber.closed && subscriber.complete()
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Finishing up

Now we have a fully working reactive Observable for a Dark Mode media query, this can be used in any application or website to check the users theme setting. The demo provides some more example of how to do this in full.

isDarkMode().pipe(
  tap(value => {
    body.classList.removeClass(value ? 'light' : 'dark');
    body.classList.addClass(value ? 'dark' : 'light');
  })
).subscribe()
Enter fullscreen mode Exit fullscreen mode

This tutorial is just one small example of the kind of things that can be done with RxJS - any API that can emit values over time can be turned into Observables.

A collection of pre-built operators and Observables for your projects

The RxJS Logo, a Ninja jumping over a moon

RxJS Ninja - is a collection of over 130 operators for working with various types of data such as arrays, numbers and streams allowing for modifying, filtering and querying the data.

Still in active development, you might find useful operators that provide clearer intent for your RxJS code.

You can check out the source code on GitHub.

Photo by Lubo Minar on Unsplash

Discussion (0)

pic
Editor guide