Switching screen theme with pure CSS (Dark/Light Mode)
A simple light and dark theme implementation with CSS, keeping the interface predictable.
- frontend
- css
- accessibility
We know the JS way
But what if we didn't use scripts to switch our app themes?
The path is a relationship between cascade and well-targeted selectors.
Let's start from the top:
HTML
The first element in the tree is a checkbox input.
Its sibling below is our app container. That's what gets restyled when the theme changes.
Inside it, we have a label tied to the input above, inside a div that acts as the transition area and serves as our theme toggle button.
<body>
<input type="checkbox" id="theme-switcher">
<div id="app-container">
<div class="theme-switcher-area">
<label for="theme-switcher" class="theme-switcher-button">
</label>
</div>
<h1>Mudando tema com CSS Puro</h1>
<p>O texto fica em contraste
com o fundo</p>
</div>
</body>
CSS
In the styles, we apply resets and declare the color variables for the theme:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--light: #cccccc;
--dark: #151515;
}
We hide the input since we'll use its label as the trigger.
#theme-switcher {
display: none;
}
We declare the app container properties. It fills the screen, has a light background and dark text, and is a flex container. The last part is optional, just to center the demo content on screen.
#app-container {
height: 100vh;
background: var(--light);
color: var(--dark);
font-family: monospace;
font-size: 1.5rem;
transition: 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
We declare the area the button slides through, absolutely positioned at the top:
.theme-switcher-area {
border: 1px solid var(--light);
background: var(--dark);
border-radius: 2rem;
width: 4.5rem;
height: 2.5rem;
padding: 0.2rem;
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
The button itself uses a dashed border, creating a sun-ray effect for the light theme.
.theme-switcher-button {
position: relative;
display: block;
background: #f1aa02;
border-radius: 50%;
width: 2rem;
height: 2rem;
border: 2px dashed var(--dark);
transition: 0.3s;
}
Finally, a ::after pseudo-element on the button. It's a smaller circle than the original element, becoming a shadow that turns the trigger into a moon in dark mode. So its initial opacity is 0.
.theme-switcher-button::after {
position: absolute;
width: 80%;
height: 80%;
content: "";
background: var(--dark);
border-radius: 50%;
opacity: 0;
transition: 0.3s;
}
Here's the trick
Since our input is the first element in the tree, we can use the :checked pseudo-class with the right selectors to restyle anything below it.
When it's checked, these rules apply.
Starting with the trigger itself, turning the sun into a moon. We remove the border that created the ray effect and slide the button to the right.
#theme-switcher:checked + #app-container .theme-switcher-button {
transform: translateX(100%);
border: none;
}
Next, we change the shadow opacity on ::after to form a crescent moon as the button switches.
#theme-switcher:checked + #app-container .theme-switcher-button::after {
opacity: 1;
}
Last, and for the main effect, we flip the background and text color on the app container:
#theme-switcher:checked + #app-container {
background: var(--dark);
color: var(--light);
}
And there it is.

This tutorial is just the start. You can extend it with prefers-color-scheme, localStorage persistence, and more elaborate sun/moon button animations.