CSS Dark Mode Toggle

Nov 17, 2020

How I made the dark mode toggle for this site only using CSS to create the icons.

Dark mode is all the rage these days, so it's almost expected that every site has a dark mode option. Shopping around for inspiration and different implementations of a toggle, I decided to just make my own since the kind of icons I found weren't quite what I was looking for.

This dark mode toggle is pure CSS for all of the styling and JavaScript for the event listener to switch between the sun and moon icons. Since my website is built with React and Theme UI, I used the event listener to change the color mode for my theme to update the colors throughout the site!

HTML

HTML
<button
class="container"
aria-label="Toggle color mode"
title="Toggle color mode"
>
<div class="sun visible"></div>
<div class="moon">
<div class="star"></div>
<div class="star small"></div>
</div>
</button>

For accessibility purposes, I put the entire icon inside of a button tag. In my React site, I created a label variable whose value changes depending on what colorMode the site's theme is currently in. I then passed this label to the aria-label and title attributes on the button for increased accessibility. That way the aria-label and title are dynamic, saying "Activate light mode" or "Activate dark mode" depending on what the current colorMode is. To make the Pen more generic for JavaScript users, I have simplified them here.

Inside of the button tag, there are 2 children divs: the sun and moon. I chose to display the sun icon by default by adding the visible class, but you can have the moon show first if you'd like. And inside of the moon, there are 2 children divs for the little stars.

CSS

CSS
1.container {
2 width: 46px;
3 height: 46px;
4 box-sizing: border-box;
5 padding: 12px;
6 background: none;
7 border: none;
8 display: flex;
9 justify-content: center;
10 align-items: center;
11 position: relative;
12}
13
14.sun {
15 width: 50%;
16 height: 50%;
17 position: absolute;
18 pointer-events: none;
19 opacity: 0;
20 transform: scale(0.6) rotate(0deg);
21 transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
22
23 /* white transparent for Safari */
24 background: radial-gradient(
25 circle,
26 rgba(0, 0, 0, 0),
27 rgba(0, 0, 0, 0) 50%,
28 #f0f0f0 50%
29 );
30}
31
32.sun:before {
33 content: "";
34 position: absolute;
35 display: block;
36 width: 100%;
37 height: 100%;
38 background: radial-gradient(
39 circle,
40 #f0f0f0 30%,
41 rgba(0, 0, 0, 0) 31%,
42 rgba(0, 0, 0, 0) 50%,
43 #f0f0f0 50%
44 );
45 transform: rotate(45deg);
46}
47
48.sun.visible {
49 pointer-events: auto;
50 opacity: 1;
51 transform: scale(1) rotate(180deg);
52 transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
53}
54
55.moon {
56 width: 50%;
57 height: 50%;
58 pointer-events: none;
59 position: absolute;
60 left: 12.5%;
61 top: 18.75%;
62 background-color: rgba(0, 0, 0, 0);
63 border-radius: 50%;
64 box-shadow: 9px 3px 0px 0px #f0f0f0;
65 opacity: 0;
66 transform: scale(0.3) rotate(65deg);
67 transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
68}
69
70.moon.visible {
71 pointer-events: auto;
72 opacity: 1;
73 transform: scale(1) rotate(0deg);
74 transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
75}
76
77.star {
78 position: absolute;
79 top: 25%;
80 left: 5%;
81 display: block;
82 width: 0px;
83 height: 0px;
84 border-right: 7px solid rgba(0, 0, 0, 0);
85 border-bottom: 5px solid #f0f0f0;
86 border-left: 7px solid rgba(0, 0, 0, 0);
87 transform: scale(0.55) rotate(35deg);
88 opacity: 0;
89 transition: all 0.2s ease-in 0.4s;
90}
91
92.star:before {
93 border-bottom: 5px solid #f0f0f0;
94 border-left: 3px solid rgba(0, 0, 0, 0);
95 border-right: 3px solid rgba(0, 0, 0, 0);
96 position: absolute;
97 height: 0;
98 width: 0;
99 top: -3px;
100 left: -5px;
101 display: block;
102 content: "";
103 transform: rotate(-35deg);
104}
105
106.star:after {
107 position: absolute;
108 display: block;
109 color: red;
110 top: 0px;
111 left: -7px;
112 width: 0px;
113 height: 0px;
114 border-right: 7px solid rgba(0, 0, 0, 0);
115 border-bottom: 5px solid #f0f0f0;
116 border-left: 7px solid rgba(0, 0, 0, 0);
117 transform: rotate(-70deg);
118 content: "";
119}
120
121.moon.visible .star {
122 opacity: 0.8;
123}
124
125.star.small {
126 transform: scale(0.35) rotate(35deg);
127 position: relative;
128 top: 50%;
129 left: 37.5%;
130 opacity: 0;
131 transition: all 0.2s ease-in 0.45s;
132}
133
134.moon.visible .star.small {
135 opacity: 0.7;
136 transform: scale(0.45) rotate(35deg);
137}

Let's break this down into smaller chunks so it's easier to digest.

Button

First, we give our button container some styling. I made the button 46px tall since industry guidelines recommend that interactive elements, like buttons, should be between 44px-48px tall. However, I didn't want the actual icons to be quite so big, so I added some padding. That way the icons can be smaller, but the "touchable" interactive footprint is still within the accesibility guidelines. Then all that's left is some flexbox magic to center everything!

buttonCSS
.container {
width: 46px;
height: 46px;
box-sizing: border-box;
padding: 12px;
background: none;
border: none;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}

Sun

sunCSS
.sun {
width: 50%;
height: 50%;
position: absolute;
pointer-events: none;
opacity: 0;
transform: scale(0.6) rotate(0deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
background: radial-gradient(circle, rgba(0, 0, 0, 0) 50%, #f0f0f0 50%);
}
.sun:before {
content: "";
position: absolute;
display: block;
width: 100%;
height: 100%;
background: radial-gradient(
circle,
#f0f0f0 30%,
rgba(0, 0, 0, 0) 31%,
rgba(0, 0, 0, 0) 50%,
#f0f0f0 51%
);
transform: rotate(45deg);
}
.sun.visible {
pointer-events: auto;
opacity: 1;
transform: scale(1) rotate(180deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}

The sun is the easiest of the 3 shapes. All we have to do is create 2 squares, lay them on top of each other, and rotate one of them. Boom, we have a 6-point star shape. Let's break down what that looks like:

  • Since our button dimensions are square, we can just set the height and width to each be 50% of the containing button. To create 2 squares, we can utilize the before pseudo-element on our sun div. Don't forget to give it content: ""!
  • To lay the squares on top of each other, we can use position: absolute. Just make sure to give the button position: relative so that our squares position themselves within the button.
  • Pick which square you want to rotate. In this case, I chose to rotate the before pseudo-element. Add transform: rotate(45deg)

A note on the before pseudo-element: Its height and width are relative to its parent container, which in this case is our sun div. To make our squares the same size, we want the before element to have a height and width of 100%.

Ok, cool, we have a 6-point star. But how do we add the circle in the middle? We could just add more divs to create some circles, but then we would have to worry about making sure the color of the larger circle always matches whatever the background color behind the sun icon.

Enter radial gradients! As our background property value, we can create a circle that starts out as the color of our sun icon (in this case, #f0f0f0), has a hard color stop to change to a transparent color, and then a final color stop to change back to our sun icon color. Here's what our radial gradient looks like:

CSS
background: radial-gradient(
circle,
#f0f0f0 30%,
rgba(0, 0, 0, 0) 31%,
rgba(0, 0, 0, 0) 50%,
#f0f0f0 51%
);

Since we are using the default radial gradient positioning, the circle will start in the middle of our div. The colors will start at the center and move outwards. Here, we are starting with a color of #f0f0f0 for the first 30% of our div. Then at 31%, we change to a transparent color and stay at this color until we hit 50%. This way, we will be able to "see through" the sun icon for 19% of the size of the div. Finally, at 51%, we change back to our #f0f0f0 color for the remainder of the div. Play with the percentages to get the circles to be the sizes you want.

  • I went with a "hard coded" color with an alpha value of 0 because the transparency color of the transparent value appears as a black transparent in Safari, as opposed to a more white transparency in other browsers. To circumvent this, I opt for picking a color and reducing the alpha value.

  • I also chose to offset the color stops by 1% so that the change from one color to the next is not quite so harsh on the eyes. You can certainly have the stops start and stop at the same values, if you prefer that.

You can use this background value for both squares or just one. If you do it for just one, the second square just needs to have a transparent radial gradient that has a color stop to change back to the sun icon color at the same point as our first square (in this case, 51%). It doesn't matter either way. Doing it this way just means that only one of the squares has the colored inner circle.

CSS
background: radial-gradient(circle, rgba(0, 0, 0, 0) 50%, #f0f0f0 50%);

Ta-da! We have a sun icon!

Moon

moonCSS
.moon {
width: 50%;
height: 50%;
pointer-events: none;
position: absolute;
left: 12.5%;
top: 18.75%;
background-color: rgba(0, 0, 0, 0);
border-radius: 50%;
box-shadow: 9px 3px 0px 0px #f0f0f0;
opacity: 0;
transform: scale(0.3) rotate(65deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}
.moon.visible {
pointer-events: auto;
opacity: 1;
transform: scale(1) rotate(0deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}

For our moon, we're going to take advantage of the box-shadow property to get that crescent shape. You can read more about box shadows here

  • To start, we give our moon div a transparent background color because the part we actually want to see is that box-shadow.
  • We want a round moon, so we'll give our div a border-radius of 50%.
  • Then, we'll add a box-shadow. In this case, we used a value of 9px 3px 0px 0px #f0f0f0. You can play with these values to get the size and shape you want.

The values of the box-shadow property break down to this:

offset-x | offset-y | blur-radius | spread-radius | color

I also gave the moon an absolute position and changed the left and top values to position the moon icon where I wanted it inside of the button. Feel free to change them!

Stars

starCSS
.star {
position: absolute;
top: 25%;
left: 5%;
display: block;
width: 0px;
height: 0px;
border-right: 7px solid rgba(0, 0, 0, 0);
border-bottom: 5px solid #f0f0f0;
border-left: 7px solid rgba(0, 0, 0, 0);
transform: scale(0.55) rotate(35deg);
opacity: 0;
transition: all 0.2s ease-in 0.4s;
}
.star:before {
border-bottom: 5px solid #f0f0f0;
border-left: 3px solid rgba(0, 0, 0, 0);
border-right: 3px solid rgba(0, 0, 0, 0);
position: absolute;
height: 0;
width: 0;
top: -3px;
left: -5px;
display: block;
content: "";
transform: rotate(-35deg);
}
.star:after {
position: absolute;
display: block;
color: red;
top: 0px;
left: -7px;
width: 0px;
height: 0px;
border-right: 7px solid rgba(0, 0, 0, 0);
border-bottom: 5px solid #f0f0f0;
border-left: 7px solid rgba(0, 0, 0, 0);
transform: rotate(-70deg);
content: "";
}

The stars were the most challenging part for me. If you look closely, they're far from perfect. But since they're so small, I didn't stress trying to make them pixel perfect. The trick used here was creating 3 triangles and rotating and overlapping them to create a star-like shape. Pretty sneaky, huh?

To make a triangle with CSS, we're actually going to use the border property. By giving the div a width and height of 0, the borders come together to form a triangle shape. The size of the triangle is then determined by the width of the border. There's a great article on CSS Tricks if you want to dig deeper!

Since we need 3 triangle to make our star, we can use a single div and the before and after pseudo elements. This answer on StackOverflow beautifully breaks down how the overlapping triangles give us our star shape. Here's a gif from that answer demonstrating how it all comes together:

Gif demo of the triangles forming a star

The actual values of the border widthes and positioning are arbitrary and completely up to you for how big you want the star and where inside of the moon you want them to be. Whatever sizes you pick, the "bottom" two triangles (in this case, the parent and after elements) need to be the same size. Just make sure your moon div has a position value other than the default static so that the stars will be placed relative to the moon.

Toggle the icons

JS
const sun = document.querySelector(".sun")
const moon = document.querySelector(".moon")
const button = document.querySelector(".container")
button.addEventListener("click", () => {
sun.classList.toggle("visible")
moon.classList.toggle("visible")
})

Our JavaScript is very simple! We can grab our button element and add a click event to it. When the button is clicked, we want to toggle a visible class on our sun and moon divs. And that's it! In our styling for the visible classes for the sun and moon, we can simply toggle the opacity value between 1 to 0. Adding a transition helps the change be smoother instead of a sudden, jarring change.

Adding animations

Now that we have a working toggle, let's add some animations for some added flair. Here's what the final product looks like:

Demo of toggle animations switching back and forth from sun and moon icons

Let's start with the sun. Since we are starting with the sun icon being visible first, we're going to set an opacity of 0 on our .sun element. Then, when the sun is visible, we can change that opacity to 1. To make the sun shrink as it's transitioning to the moon icon, we can have the sun start with a transform: scale(0.6) so that it's a little bit smaller than the height and width we gave it. When the sun is visible, we can transform it back up to a scale(1) to that the sun is its normal size. Finally, we can add a little spin by rotating the sun. To make all of these changes smooth as the visible class gets toggled, we can add a transition value.

sunCSS
.sun {
/* other sun styles excluded for brevity */
opacity: 0;
transform: scale(0.6) rotate(0deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}
.sun.visible {
opacity: 1;
transform: scale(1) rotate(180deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}

For the moon, we're going to do the same thing: toggle the opacity and do some scaling and rotating!

moonCSS
.moon {
/* other moon styles excluded for brevity */
opacity: 0;
transform: scale(0.3) rotate(65deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}
.moon.visible {
pointer-events: auto;
opacity: 1;
transform: scale(1) rotate(0deg);
transition: transform 0.3s ease-in, opacity 0.2s ease-in 0.1s;
}

Finally, for the stars we'll toggle the opacity and rotating. Since they're so small, I didn't bother messing with the scaling. However, I DID want to make one of the stars smaller than the other, so our star div with the small class does have a scale to make it smaller than the other star element. I also opted to give the stars an opacity that's less than 1 to give a bit of an illusion of depth.

starsCSS
.star {
/* other star styles excluded for brevity */
transform: scale(0.55) rotate(35deg);
opacity: 0;
transition: all 0.2s ease-in 0.4s;
}
.star.small {
/* other small star styles excluded for brevity */
transform: scale(0.35) rotate(35deg);
opacity: 0;
transition: all 0.2s ease-in 0.45s;
}
/* we only want the stars visible when the moon is also visible */
.moon.visible .star {
opacity: 0.8;
}
.moon.visible .star.small {
opacity: 0.7;
transform: scale(0.45) rotate(35deg);
}

As always, feel free to change anything to fit your tastes and needs! I had a lot of fun making this toggle and I hope you will, too! 😊