Build a light/dark mode toggle switch component with CSS & JavaScript

In this new tutorial, we’ll use previous knowledge and learn how to build a light/dark mode toggle switch component. This is a handy feature available in many sites and apps nowadays because it can help reduce eye strain and therefore improves accessibility.

MDN's color modesMDN's color modesMDN's color modes
MDN’s color modes

Slack's color modesSlack's color modesSlack's color modes
Slack’s color modes

It’s also worth noting that more and more CSS frameworks have started providing this functionality by default.

Bootstrap's dark modeBootstrap's dark modeBootstrap's dark mode
Bootstrap’s color modes

Tailwind's dark modeTailwind's dark modeTailwind's dark mode
Tailwind’s dark mode

What we’re building

Here’s an introductory video to give you a taste of the functionality that we want to achieve:


And, of course, there’s the CodePen demo for you to fork and play with:

To speed up the development process, we’ll heavily use markup and styles from a previous tutorial. In that tutorial, we developed a toggle switch component using the old-school CSS Checkbox hack technique.

1. Begin with the page markup

Our page will support light and dark modes. By default, the light mode will be active. 

Regarding the markup, we’ll need three radio buttons in total. When the third radio button (Auto) is active, the page colors will depend on the color scheme set in our OS settings. 

Our light/dark mode toggle componentOur light/dark mode toggle componentOur light/dark mode toggle component

Setting up dark mode in Windows 11 Setting up dark mode in Windows 11 Setting up dark mode in Windows 11
Setting up dark mode in Windows 11

Here’s the required structure:

1
<ul class="switches">
2
  <li>
3
    <input type="radio" id="light" name="theme-mode" checked>
4
    <label for="light">
5
      <span>Light</span>
6
      <span></span>
7
    </label>
8
  </li>
9
  <li>
10
    <input type="radio" id="dark" name="theme-mode">
11
    <label for="dark">
12
      <span>Dark</span>
13
      <span></span>
14
    </label>
15
  </li>
16
  <li>
17
    <input type="radio" id="auto" name="theme-mode">
18
    <label for="auto">
19
      <span>System</span>
20
      <span></span>
21
    </label>
22
  </li>
23
</ul>

2. Add the CSS

We’ll define the initial page colors using CSS variables. 

1
:root {
2
  ...
3
  --white: #fff;
4
  --black: black;
5
  --text-color: var(--black);
6
  --bg-color: var(--white);
7
  ...
8
}

Then, as soon as the dark mode is enabled, we’ll override the values of these variables.

1
.theme-dark {
2
  color-scheme: dark;
3
  --text-color: #fff;
4
  --bg-color: black;
5
  ...
6
}

Notice the color-scheme: dark property value that allows browsers to render native elements in dark mode (e.g. form controls).

Of course, we can go even further from here. For example, we can have different image filters depending on the active mode, etc.

Below you can see the styles responsible for creating the look and feel of our toggle switches.

1
/*CUSTOM VARIABLES HERE*/
2

3
.switches {
4
  display: inline-block;
5
  padding: 0;
6
  border: 1px solid var(--gray);
7
  margin: 10px 0 0;
8
  border-radius: 6px;
9
}
10

11
.switches li {
12
  position: relative;
13
}
14

15
.switches li:not(:last-child) {
16
  border-bottom: 1px solid var(--gray);
17
}
18

19
.switches li [type="radio"] {
20
  position: absolute;
21
  left: -9999px;
22
}
23

24
.switches label {
25
  display: grid;
26
  grid-template-columns: 40px auto;
27
  align-items: center;
28
  gap: 10px;
29
  padding: 20px;
30
  cursor: pointer;
31
}
32

33
.switches span {
34
  flex-shrink: 0;
35
}
36

37
.switches span:empty {
38
  position: relative;
39
  width: 50px;
40
  height: 26px;
41
  border-radius: 15px;
42
  background: var(--gray);
43
  transition: all 0.3s;
44
}
45

46
.switches span:empty::before,
47
.switches span:empty::after {
48
  content: "";
49
  position: absolute;
50
}
51

52
.switches span:empty::before {
53
  top: 1px;
54
  left: 1px;
55
  width: 24px;
56
  height: 24px;
57
  background: var(--slate-gray);
58
  border-radius: 50%;
59
  z-index: 1;
60
  transition: transform 0.3s;
61
}
62

63
.switches span:empty::after {
64
  top: 50%;
65
  transform: translateY(-50%);
66
  width: 14px;
67
  height: 14px;
68
  left: 6px;
69
  background-size: 14px 14px;
70
}
71

72
.switches [type="radio"]:checked + label span:empty {
73
  background: var(--white-pearl);
74
}
75

76
.switches [type="radio"]:checked + label span:empty::before {
77
  transform: translateX(24px);
78
}
79

80
.switches li:nth-child(1) [type="radio"]:checked + label span:empty::after {
81
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNy4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTM2MS41IDEuMmM1IDIuMSA4LjYgNi42IDkuNiAxMS45TDM5MSAxMjFsMTA3LjkgMTkuOGM1LjMgMSA5LjggNC42IDExLjkgOS42czEuNSAxMC43LTEuNiAxNS4yTDQ0Ni45IDI1Nmw2Mi4zIDkwLjNjMy4xIDQuNSAzLjcgMTAuMiAxLjYgMTUuMnMtNi42IDguNi0xMS45IDkuNkwzOTEgMzkxIDM3MS4xIDQ5OC45Yy0xIDUuMy00LjYgOS44LTkuNiAxMS45cy0xMC43IDEuNS0xNS4yLTEuNkwyNTYgNDQ2LjlsLTkwLjMgNjIuM2MtNC41IDMuMS0xMC4yIDMuNy0xNS4yIDEuNnMtOC42LTYuNi05LjYtMTEuOUwxMjEgMzkxIDEzLjEgMzcxLjFjLTUuMy0xLTkuOC00LjYtMTEuOS05LjZzLTEuNS0xMC43IDEuNi0xNS4yTDY1LjEgMjU2IDIuOCAxNjUuN2MtMy4xLTQuNS0zLjctMTAuMi0xLjYtMTUuMnM2LjYtOC42IDExLjktOS42TDEyMSAxMjEgMTQwLjkgMTMuMWMxLTUuMyA0LjYtOS44IDkuNi0xMS45czEwLjctMS41IDE1LjIgMS42TDI1NiA2NS4xIDM0Ni4zIDIuOGM0LjUtMy4xIDEwLjItMy43IDE1LjItMS42ek0xNjAgMjU2YTk2IDk2IDAgMSAxIDE5MiAwIDk2IDk2IDAgMSAxIC0xOTIgMHptMjI0IDBhMTI4IDEyOCAwIDEgMCAtMjU2IDAgMTI4IDEyOCAwIDEgMCAyNTYgMHoiLz48L3N2Zz4=");
82
}
83

84
.switches li:nth-child(2) [type="radio"]:checked + label span:empty::after {
85
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzODQgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNy4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTIyMy41IDMyQzEwMCAzMiAwIDEzMi4zIDAgMjU2UzEwMCA0ODAgMjIzLjUgNDgwYzYwLjYgMCAxMTUuNS0yNC4yIDE1NS44LTYzLjRjNS00LjkgNi4zLTEyLjUgMy4xLTE4LjdzLTEwLjEtOS43LTE3LTguNWMtOS44IDEuNy0xOS44IDIuNi0zMC4xIDIuNmMtOTYuOSAwLTE3NS41LTc4LjgtMTc1LjUtMTc2YzAtNjUuOCAzNi0xMjMuMSA4OS4zLTE1My4zYzYuMS0zLjUgOS4yLTEwLjUgNy43LTE3LjNzLTcuMy0xMS45LTE0LjMtMTIuNWMtNi4zLS41LTEyLjYtLjgtMTktLjh6Ii8+PC9zdmc+");
86
}
87

88
.switches li:nth-child(3) [type="radio"]:checked + label span:empty::after {
89
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNy4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTQ0OCAyNTZjMC0xMDYtODYtMTkyLTE5Mi0xOTJsMCAzODRjMTA2IDAgMTkyLTg2IDE5Mi0xOTJ6TTAgMjU2YTI1NiAyNTYgMCAxIDEgNTEyIDBBMjU2IDI1NiAwIDEgMSAwIDI1NnoiLz48L3N2Zz4=");
90
}

3. Add the JavaScript

To set up the functionality for our toggle switch component, we’ll take advantage of the local storage and the prefers-color-scheme CSS media feature. This useful media query takes as value the user’s preferred selection as defined in their OS settings. 

The presence of the theme-dark class in the html element will denote that we’ve requested the dark mode manually or through the system settings.

The dark mode enabledThe dark mode enabledThe dark mode enabled

At that point, we’ll store in local storage, the dark-mode="true" key value that will help us keep the user’s color scheme selection on page reload.

The value stored in local storageThe value stored in local storageThe value stored in local storage

We’ll also store a second key value for identifying the active toggle switch.

Store in local storage the active radio buttonStore in local storage the active radio buttonStore in local storage the active radio button

With all these in mind, here’s all the required JavaScript—use your browser to check how it works:

1
const html = document.documentElement;
2
const switches = document.querySelector(".switches");
3
const inputs = switches.querySelectorAll("input");
4

5
if (localStorage.getItem("dark-mode")) {
6
  html.classList.add("theme-dark");
7
}
8

9
if (localStorage.getItem("selected-radio")) {
10
  switches.querySelector(`#${localStorage.getItem("selected-radio")}`).checked =
11
    "true";
12
}
13

14
const setTheme = (theme) => {
15
  if (theme === "dark") {
16
    html.classList.add("theme-dark");
17
    localStorage.setItem("dark-mode", "true");
18
  } else {
19
    html.classList.remove("theme-dark");
20
    localStorage.removeItem("dark-mode");
21
  }
22
};
23

24
const handleMediaChange = (e) => {
25
  if (switches.querySelector('[type="radio"]:checked').id === "auto") {
26
    setTheme(e.matches ? "dark" : "light");
27
  }
28
};
29

30
const handleInputChange = (e) => {
31
  const themeMode = e.target.id;
32
  if (
33
    themeMode === "dark" ||
34
    (themeMode === "auto" &&
35
      window.matchMedia("(prefers-color-scheme: dark)").matches)
36
  ) {
37
    setTheme("dark");
38
  } else {
39
    setTheme("light");
40
  }
41
  localStorage.setItem("selected-radio", themeMode);
42
};
43

44
window
45
  .matchMedia("(prefers-color-scheme: dark)")
46
  .addEventListener("change", handleMediaChange);
47

48
inputs.forEach((input) => input.addEventListener("input", handleInputChange));

Conclusion

Done! I hope you had some fun with this exercise and will consider this component whenever you need a light/dark theme toggle functionality.

Let’s remind ourselves of our creation:

As always, thanks a lot for reading!

More color modes tutorials

Need more color scheme tutorials? Check out the tutorials below: