photo of mountains with snow at night
Photo by Nathan Anderson on Unsplash

Building a Dark Mode Theme Toggle

Learn how to build an interesting dark mode animation.

7 min read

Today we will be building a toggle component that animates between a light and dark theme. I was inspired by this Dribbble shot, and it was a fun experiment to try and recreate.

This article is split into four sections, each exploring a concept that builds up to our end result.

  1. Building a basic toggle component.
  2. Modifying that component for use with dark mode.
  3. Using CSS variables to apply dynamic theming.
  4. Adding the dark mode "wave" animation.

The code we'll be writing will be in React, using a few React hooks, but the overall idea can be implemented in your library or framework of choice.

This article will assume you are at least familiar with the concept of CSS Variables (AKA CSS Custom Properties). I recommend checking out this article for a basic intro, or this one for a deeper dive.

Let's get started!

Basic Toggle Component

The first thing we'll do is build our toggle component. Start by making a new file named ThemeToggle.jsx.

Since the value we want to toggle is either enabled or disabled (true or false), we'll use a single checkbox element and build up from that.

If you wanted to use a radio or button element instead, those may work too.

Our component should have one state value, isEnabled, that tracks a value being enabled or disabled.


export default function ThemeToggle() {
  const [isEnabled, setIsEnabled] = useState(false);

  const toggleState = () => {
    setIsEnabled((prevState) => !prevState);
  };

  return (
    <label className="toggle-wrapper" htmlFor="toggle">
      <div className={`toggle ${isEnabled ? "enabled" : "disabled"}`}>
        <span className="hidden">
          {isEnabled ? "Enable" : "Disable"}
        </span>
        <input
          id="toggle"
          name="toggle"
          type="checkbox"
          checked={isEnabled}
          onClick={toggleState}
        />
      </div>
    </label>
  );
}

In our JSX, a label wraps all of the markup. This helps make the entire toggle clickable, whereas we'd otherwise need to position the label to take up the width and height of the element. This saves us a few lines of CSS.

We swap between an enabled or disabled CSS class depending on the state. These classes will control the position of the toggle handle, or "dot".

We also want to make sure there is some text within the label, which helps with accessibility. We'll hide this text visually via a hidden class when we apply our styles.

One last addition: we want to actually do something when the toggle state updates! For this, we'll add a useEffect hook. More on this later.


export default function ThemeToggle() {
  const [isEnabled, setIsEnabled] = useState(false);

	useEffect(() => {
    console.log('TODO more on this later');
	}, [isEnabled]);

  const toggleState = () => {
    setIsEnabled((prevState) => !prevState);
  };

Believe it or not, this is all the markup we need at the moment.

Now for some CSS. First adjust the box-sizing for all elements to border-box, and add a few CSS variables to store some colors and a transition value.


* {
  box-sizing: border-box;
}

:root {
  --black: #333333;
  --white: #f5f5f5;
	--transition: 0.5s ease;
}

.hidden {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}

.toggle-wrapper {
  width: 130px;
  display: block;
}

.toggle {
  height: 65px;
  width: 130px;
  background: var(--black);
  border-radius: 40px;
  padding: 12px;
  position: relative;
  margin: auto; // Optional to center the toggle
  cursor: pointer;
}

We'll add a hidden class to visually hide the label text, and then add in the styles for the .toggle div itself.

Now for the rest of the toggle styles. The toggle handle consists of a ::before pseudo element and it's transformed 62px to the right when the .right class is applied.


.toggle::before {
  content: "";
  display: block;
  height: 41px;
  width: 41px;
  border-radius: 30px;
  background: var(--white);
  position: absolute;
  z-index: 2;
  transform: translate(0);
  transition: transform var(--transition);
}

.toggle.enabled::before {
  transform: translateX(65px);
}

.toggle input {
	position: absolute;
	top: 0;
  opacity: 0;
}

The last thing is to hide the checkbox by setting it to an opacity of 0 and updating the positioning slightly.

With that, you should have a minimal toggle component!

completed basic toggle component
completed basic toggle component

Updating the Toggle Component

With the base toggle done, let's make a few updates so it's more suitable for our end goal: toggling a dark theme.

Adding some icons should make this a little more obvious, and while we're add it, updating the label text to be a bit more descriptive.


import { ReactComponent as MoonIcon } from "./assets/svg/moon.svg";
import { ReactComponent as SunIcon } from "./assets/svg/sun.svg";

return (
  <label className="toggle-wrapper" htmlFor="toggle">
    <div className={`toggle ${isEnabled ? "enabled" : "disabled"}`}>
      <span className="hidden">
        {isEnabled ? "Enable Light Mode" : "Enable Dark Mode"}
      </span>
      <div className="icons">
        <SunIcon />
        <MoonIcon />
      </div>
      <input
        id="toggle"
        name="toggle"
        type="checkbox"
        checked={isEnabled}
        onClick={toggleState}
      />
    </div>
  </label>
);

Depending on your development environment, the way you import and use SVGs might be different than the above.

Next the styles for the icons.


.toggle .icons {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 100%;
  margin: 0 5px;
}

.toggle .icons svg {
  fill: var(--white);
  height: 30px;
  width: 30px;
  z-index: 0;
}

The icon fill color will be updated in the next section to be dynamic. For now, we'll set it as white.

toggle component with icons
toggle component with icons

Using CSS Variables

Next let's see how we can use CSS variables in our dark mode implementation.
CSS variables can be set using Javascript with the setProperty function.

For example:


  document.documentElement.style.setProperty("--orange", 'blue');

With the above line of code, anywhere the --orange variable is used, it will now be blue.

This means that we can use two variables in our CSS: --foreground and --background and set them to a specific color depending on the state of our toggle component.

Let's add a function to do that.


const updateTheme = (isDarkEnabled) => {

  // Get all available styles
  const styles = getComputedStyle(document.body);

	// Get the --black and --white variable values
	const black = styles.getPropertyValue("--black");
  const white = styles.getPropertyValue("--white");
};

The first thing our function does is get the color values we've defined in our CSS. This prevents us from needing to store hex color values for black and white in both JS and CSS. They are defined once in our CSS file, and if they need updating, only the CSS needs to be changed.

We can do this with the getComputedStyle function and passing in document.body. This returns all the style properties on the body.

Next we use the getPropertyValue to get the values of --black and --white and set them as constants.

For our logic, we want to check the value of isDarkEnabled. If it's true, set the --background variable to black and the --foreground variable to white. We do the opposite if isDarkEnabled is false.


const updateTheme = (isDarkEnabled) => {
	// Get all available styles
	const styles = getComputedStyle(document.body);

	// Get the --black and --white variable values
	const black = styles.getPropertyValue("--black");
	const white = styles.getPropertyValue("--white");

	// Optional shorthand constant for accessing document.documentElement
	const docEl = document.documentElement;

  if (isDarkEnabled) {
    docEl.style.setProperty("--background", black);
    docEl.style.setProperty("--foreground", white);
  } else {
    docEl.style.setProperty("--background", white);
    docEl.style.setProperty("--foreground", black);
  }

Finally, let's use our function in the useEffect hook we added earlier.


useEffect(() => {
	// Pass in the isEnabled state
  updateTheme(isEnabled);
}, [isEnabled]);

If you try clicking on the toggle component, you won't see anything happen yet. This is because we haven't actually defined --background or --foreground in the CSS.

Let's make some adjustments to get this working!


:root {
  --black: #333333;
  --white: #f5f5f5;
  --background: var(--white);
  --foreground: var(--black);
  --transition: 0.5s ease;
}

html {
  background: var(--background);
  color: var(--foreground);
  transition: color var(--transition), background var(--transition);
}

.toggle {
	...
  background: var(--foreground);
	transition: background var(--transition);
}

.toggle::before {
  ...
  background: var(--background);
	transition: transform var(--transition), background var(--transition);
}

.toggle .icons svg {
  ...
  fill: var(--background)
}

You can see that we aren't directly using the color variables anymore and are relying on the --background and --foreground variables to do the work for us.

dark mode toggle with a fade variation
dark mode toggle with a fade variation

I added a bit of text to better show that the text you have in the document should be changing color as well, based on the color: var(--foreground); in the CSS we wrote above.

Now we have a nice fade in! But, we don't quite have the effect we were going for.

"Wave" Animation Effect

For our last step, let's make a few changes so that the background transitions in from the left of the screen, rather than fade in.

In order to accomplish this, we'll need to use an element that can be transitioned on the X axis for our background. A ::before pseudo element is what we'll use.

We'll also toggle a CSS class on the root of the document. This will serve two purposes:

  1. Let us know when to transition the pseudo element.
  2. Allow some flexibility for other elements to change their appearance when the dark mode is active.

Applying the class is straightforward. Just add two lines to our updateTheme function.


const updateTheme = (isDarkEnabled) => {
  ...

  if (isDarkEnabled) {
    ...
    document.querySelector("html").classList.add("darkmode");
  } else {
    ...
    document.querySelector("html").classList.remove("darkmode");
  }
};

In the CSS, set the background property of the html element back to --white. We can also remove the background transition property we added in the previous example.


html {
  background: var(--white);
  color: var(--foreground);
  transition: color var(--transition);
}

html::before {
  content: "";
  position: fixed;
  height: 100%;
  width: 100%;
  background: var(--black);
  transform: translateX(-100%);
  transition: transform var(--transition);
  z-index: 0;
}

/* When the darkmode class is applied, set the pseudo element position to 0 */
.darkmode::before {
  transform: translateX(0);
}

Finally, because we're using a pseudo element as the background, we want to make sure it doesn't display on top of other things on the page. Add a z-index and positioning to all elements to prevent this.


* {
  box-sizing: border-box;

  /* Make sure all elements are above the background */
  z-index: 1;
  position: relative;
}

completed dark mode toggle with animation
completed dark mode toggle with animation

Summary

The popularity of dark mode on the web has increased over the past few years, and in this article we learned just one of many different ways to implement it. Because CSS variables can be dynamically set in Javascript, it's one easy way to swap the color theme of many elements at once using very little code.