A Understanding Theme Switching
Goal: Understand the three ingredients that make a theme switcher work.
The Big Picture
A theme switcher changes colors across your entire page in one move. Instead of rewriting every color when the user picks “dark mode,” you change a single attribute on the <html> element and let CSS do the rest.
The Three Ingredients
- CSS Custom Properties — Variables like
--color-surfacethat hold your colors. - A Dropdown UI — The menu that lets users pick Light, Dark, or System.
- JavaScript — The glue. It listens for clicks, swaps the
data-themeattribute, and saves the choice.
The Data Flow
When a user clicks “Dark”:
- JavaScript sets
document.documentElement.dataset.theme = "dark" - The
<html>tag now hasdata-theme="dark" - CSS rule
[data-theme="dark"]activates and overrides the variables - Every element using
var(--color-surface)instantly updates - JavaScript saves
"dark"to localStorage
Your Starting HTML
Your CodePen already has this structure:
<div class="theme-dropdown">
<button class="theme-trigger">
<span>Theme</span>
</button>
<div class="theme-menu">
<ul class="theme-list">
<li><button class="color-theme-option" data-theme="system">OS default</button></li>
<li><button class="color-theme-option" data-theme="light">Light</button></li>
<li><button class="color-theme-option" data-theme="dark">Dark</button></li>
</ul>
</div>
</div>
<main class="card">
<h1>Theme Toggle Demo</h1>
<p>This text will change color when the theme switches.</p>
</main>
Reflection:
Before writing any CSS: look at the HTML. What do the data-theme attributes do? How will CSS and JS use them?
Auto-saved ✓
B CSS Custom Properties & Theme Colors
Goal: Define color variables and create light/dark rule sets.
Step 1: Define your default variables Fill in the blank
CSS custom properties (variables) always start with --. They go inside :root so they’re available everywhere. Fill in the variable names:
:root {
__________________: #ffffff;
__________________: #111111;
}
Step 2: Apply variables to body Fill in the blank
Instead of hard-coding colors, use the var() function to reference your custom properties. Fill in the two values:
body {
margin: 0;
padding: 2rem;
font-family: system-ui, sans-serif;
background: __________________;
color: __________________;
}
var(--your-variable-name)
Step 3: Add the light theme rule Fill in the blank
CSS can target HTML attributes using bracket notation. Fill in the selector that matches when <html> has data-theme="light":
______________________ {
--color-surface: #ffffff;
--color-text: #111111;
}
[attribute="value"]
Step 4: Add the dark theme rule Write it yourself
Same pattern as Step 3, but for the dark theme. Use these colors:
- Surface:
#1a1a1a - Text:
#f0f0f0
Write the complete CSS rule for [data-theme="dark"] that overrides both variables.
Test it in DevTools
Open DevTools (F12). Find the <html> element and manually add data-theme="dark". Your colors should flip. Remove it to go back.
Reflection:
Why use CSS variables instead of writing separate stylesheets for light and dark?
Auto-saved ✓
C Styling the Dropdown Container
Goal: Style the trigger button and position the hidden menu below it.
Key properties to know:
position: relative— Creates a positioning context for child elementsposition: absolute— Positions relative to nearest positioned ancestordisplay: none— Completely hides an elementtop: 100%— Places element right below its parent
Step 1: Position the dropdown wrapper Fill in the blank
The wrapper needs a specific position value so the menu (which will be absolute) knows where to anchor itself. What goes here?
.theme-dropdown {
position: __________;
}
absolute, not fixed, not static. It creates a positioning context without moving the element itself.
Step 2: Style the trigger button Write it yourself
Style .theme-trigger to look like a clickable button. Include:
- Black background, white text
- Some padding (at least
0.5rem) - No default border
cursor: pointer
Write the .theme-trigger rule. The exact values are up to you — just make it look intentional, not default.
Step 3: Hide and position the menu Fill in the blank
The menu needs to: (1) start hidden, (2) sit below the trigger without pushing content around, (3) know where “below” is relative to the dropdown wrapper.
.theme-menu {
display: ________;
position: __________;
top: ________;
left: 0;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
z-index: 10;
}
Step 4: Reset the list styles
Remove default bullets and spacing from the <ul>:
.theme-list {
list-style: none;
margin: 0;
padding: 0;
}
Reflection:
What would happen if you forgot position: relative on .theme-dropdown? Where would the menu appear?
Auto-saved ✓
D Styling the Menu Buttons
Goal: Style the option buttons and create the rule that reveals the menu.
Step 1: Style the option buttons
Each option needs flexbox for icon+text alignment and full-width click targets:
.color-theme-option {
display: flex;
align-items: center;
column-gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
margin: 0;
font-size: 1rem;
color: white;
background-color: #000000;
border: none;
cursor: pointer;
}
Step 2: Add a hover state Write it yourself
Write a :hover rule for .color-theme-option that changes the background to #333.
.color-theme-option:hover
Step 3: The reveal rule Fill in the blank
When JavaScript adds a class to the dropdown wrapper, this CSS rule should show the menu. Fill in the missing class name and display value:
.theme-dropdown.______ .theme-menu {
display: ________;
}
none for block-level content.
Test it in DevTools
Find .theme-dropdown in the Elements panel. Manually add open to its class list. The menu should appear. Remove it and it disappears.
Reflection:
Why is toggling a CSS class better than directly setting style.display in JavaScript?
Auto-saved ✓
E JavaScript — Opening & Closing the Dropdown
Goal: Wire up JavaScript so clicking the trigger toggles the menu, and clicking elsewhere closes it.
Key concepts:
classList.toggle("open")— Adds the class if missing, removes it if presentevent.stopPropagation()— Prevents the click from bubbling up to parent elementselement.contains(event.target)— Checks if a clicked element is inside another element
Step 1: Select your elements Fill in the blank
Use querySelector with the CSS class selectors to grab the dropdown wrapper and trigger button:
const dropdown = document.querySelector("________________");
const trigger = document.querySelector("________________");
<div> and the <button> have?
Step 2: Toggle on trigger click Fill in the blank
When the trigger is clicked: (1) stop the event from bubbling, and (2) toggle the open class on the dropdown wrapper.
trigger.addEventListener("click", function (event) {
event.__________________();
dropdown.classList.__________("open");
});
Why stopPropagation()?
Without it, the click bubbles up to the document listener (Step 3), which would immediately close the menu. The open and close would happen in the same tick — the menu would never visibly appear.
Step 3: Close when clicking outside Write it yourself
Add a "click" listener on document. Inside the handler, check if the click target is outside the dropdown. If it is, remove the "open" class.
- Use
document.addEventListener("click", function (event) { ... }) - Use
dropdown.contains(event.target)to check if the click is inside - The
!operator flips the boolean — “if NOT inside” - Use
classList.remove("open")(nottoggle) to close
Test it
Click the button — menu opens. Click it again — menu closes. Click somewhere else — menu closes.
Common bug
If the menu opens and immediately closes, you probably forgot event.stopPropagation() in Step 2.
Reflection:
Could you use classList.toggle in the document listener instead of classList.remove? Why or why not?
Auto-saved ✓
F JavaScript — Loading & Applying Themes
Goal: Wire up each theme button, save the choice, and restore it on reload.
Step 1: Select the theme buttons Write it yourself
Select the three theme buttons using attribute selectors. You need three const declarations using querySelector.
- Attribute selector syntax:
button[data-theme='value'] - Name them
systemBtn,lightBtn,darkBtn - The values to match are
'system','light', and'dark'
Step 2: Wire up the Light button Fill in the blank
When clicked, this handler needs to: set the data-theme attribute on <html>, save to localStorage, and close the dropdown.
lightBtn.addEventListener("click", function () {
document.___________________.dataset.theme = "light";
localStorage.___________("theme", "light");
dropdown.classList.remove("open");
});
<html> element (it literally means “document element”). Blank 2 is the localStorage method that saves a key-value pair.
Breaking this down
document.documentElement— The<html>element.dataset.theme = "light"— Setsdata-theme="light"on itlocalStorage.setItem("theme", "light")— Saves the string under the key"theme"
Step 3: Wire up the Dark button Write it yourself
Same exact pattern as the light button. Just change the three instances of "light" to "dark".
Step 4: Wire up the System button Fill in the blank
This button saves "system" and then checks the OS preference to decide light or dark:
systemBtn.addEventListener("click", function () {
localStorage.setItem("theme", "system");
const osDark = window.____________________________________________;
if (osDark) {
document.documentElement.dataset.theme = "dark";
} else {
document.documentElement.dataset.theme = "light";
}
dropdown.classList.remove("open");
});
window.matchMedia() takes a media query string like CSS uses. You want to check (prefers-color-scheme: dark) and read the .matches property.
Step 5: Load the saved theme on page load Write it yourself
This is the boss fight. Write code that runs at the top of your JS file. It should:
- Read the saved theme from localStorage (key is
"theme") - If there’s no saved value, OR if it’s
"system": check the OS preference withmatchMediaand set the theme accordingly - Otherwise: set
data-themeto whatever was saved
localStorage.getItem("theme")returnsnullif nothing is saved!savedis truthy whensavedisnull- You can combine conditions:
if (!saved || saved === "system") - Reuse the
matchMediapattern from Step 4
Final test
- Click “Dark” — page goes dark
- Refresh the page — it stays dark
- Click “Light” — page goes light
- Click “OS default” — matches your system setting
- Refresh again — persists correctly
You built a theme switcher!
The full loop works: user clicks → JS sets the attribute → CSS swaps variables → localStorage remembers.
Reflection:
The “system” option saves "system" to localStorage, not the resolved "dark" or "light". Why does that matter? What breaks if you save the resolved value?
Auto-saved ✓
G Final Step: Document Your Thinking
Goal: Add your reflections to your code as comments.
Before you finish:
- Click the "Copy All My Responses" button below
- Open your CodePen’s JavaScript panel
- Paste your responses at the top as a comment block:
/*
MY REFLECTIONS
==============
[Paste your copied responses here]
*/
Why? Your reflections are part of your learning. Adding them as comments shows your thinking process and helps you remember what you learned when you revisit this code later.
Congratulations!
You built a fully functional theme switcher — and you wrote most of the code yourself.
- Define and override CSS custom properties with
:rootand attribute selectors - Build a dropdown pattern: trigger button + absolutely positioned menu
- Toggle visibility with a CSS class (
.open) controlled by JavaScript - Close dropdowns on outside click using event bubbling
- Persist user preferences with
localStorage - Read OS dark mode preference with
matchMedia
What you built:
- CSS variables that swap all colors in one attribute change
- A dropdown menu using the relative/absolute positioning pattern
- Three theme options including OS-aware “system” mode
- Persistence across page reloads with localStorage
Where to go from here:
- Add more variables: borders, shadows, accent colors, link colors
- Add transitions:
transition: background-color 0.3s, color 0.3sfor smooth changes - Keyboard accessibility: Arrow keys to navigate, Escape to close
- Active indicator: Highlight which theme is currently selected