My silly little dark mode toggle
8 min read
A dark-mode toggle, in the technically-correct sense, needs one checkbox and one event listener. Click, flip a class on the html element, done.
I shipped one with eight animated clouds, three twinkling stars, a sliding sun-and-moon thumb, four staggered cloud-drift keyframes, a mask gradient on the container so the clouds don't pop on the left edge, a hover-only scale on the sun, and a per-cloud animation-delay so they don't all line up at the start. The whole thing is about 200 lines of CSS for a control that does one binary thing.
I'm not going to argue this was necessary. I'm going to argue why I keep doing it anyway.
The first iteration
The first version was a tiny pill with a sun icon swapping to a moon icon on click. It worked. I shipped it. Reviewing the build the next morning I left a note in the codebase that just said "this is fine", which, for me, is the lowest possible enthusiasm rating.
The problem wasn't the swap. The problem was the site now had a section that read "this person cares deeply about typography, designs in OKLCH, animates the headline shine on a 0.05s linear stagger" and then a checkbox in the corner that looked like every cookie-banner toggle on every SaaS landing page made since 2019. The toggle didn't earn its spot.
So I rebuilt it.
The structural decision
The pill is 80px wide. That's enough room for two zones: a "day" half on the left, a "night" half on the right, plus a 28px circular thumb that slides between them. The thumb shows a sun in light mode and a moon in dark mode, same circle, same size, just different inner glyph and color.
The two halves of the pill have different scenery. Light side: clouds drifting across a sky-blue gradient. Dark side: stars twinkling against deep navy. Click the thumb, the scene under it changes and the thumb slides over.
Markup is dead simple:
<button class="mode-toggle" x-data="theme" x-on:click="toggle()">
<span class="mode-toggle__clouds">
<span class="mode-toggle__cloud mode-toggle__cloud--1"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--2"></span>
<!-- six more clouds, each with its own size + speed + delay -->
</span>
<span class="mode-toggle__stars">
<span class="mode-toggle__star"></span>
<span class="mode-toggle__star"></span>
<span class="mode-toggle__star"></span>
</span>
<span class="mode-toggle__thumb"></span>
</button>
Eight clouds, three stars, a thumb. The cloud and star containers are positioned absolutely; their visibility is driven by the data-theme attribute on the html element. In light mode the clouds animate; in dark mode the stars do. The thumb just slides via a transform with a transition.
Eight clouds, eight problems
Every cloud is just a span with white background, rounded corners, and a box-shadow cluster that builds the puff shape. Pure CSS. Per cloud:
.mode-toggle__cloud--3 {
width: 14px;
height: 6px;
border-radius: 999px;
box-shadow:
5px -3px 0 0 white,
-3px -2px 0 0 white,
8px 0 0 -1px white;
animation: cloud-drift 11s linear infinite;
animation-delay: -3s;
top: 30%;
}
The box-shadow clones are how you draw a puff with a single element. The negative animation-delay is how you keep all eight clouds from starting their drift at exactly the same x-position.
Where it got hard
Three problems took most of the build time.
1. Clouds popping on the left edge. Each cloud's keyframes drift it from right -30px to right 110% (off the right side, all the way across, off the left side). At the moment a cloud "leaves" the left edge, it doesn't gradually fade, it just blinks out. Looks awful at the corner of a 28px-tall pill.
The fix is a mask gradient on the cloud container:
.mode-toggle__clouds {
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 12%,
black 88%,
transparent 100%
);
mask-image: linear-gradient(...same...);
}
Now clouds fade out at both edges instead of disappearing. About 2px of soft fade hides the pop. You don't notice it doing anything; you only notice when you take it out.
2. The fastest cloud moves too fast on hover. The toggle has a hover state that bumps the sun's scale and speeds up the cloud animation by reducing animation-duration proportionally, supposed to feel like wind picking up. Cloud #3 was set to a 4-second cycle; hovering bumped it to 1.6s. At that speed the cloud-drift looked like a horizontal twitch. Fixed by giving each cloud a separate hover-speed multiplier, with the fastest baseline cloud only speeding up by 1.2x instead of the same 2.5x as the slow ones.
3. The toggle background fights the primary button background. The toggle pill sits next to the "Book a meeting" CTA. The CTA's background is an animated mesh gradient. The toggle's day-mode sky is a different gradient. Side by side, your eye doesn't know which one to track. Two competing focal points.
Fixed by making the toggle's day-mode sky much less saturated than the button's mesh, and pegging the night-mode sky to almost-pure --bg. The toggle reads as "I belong to the bg" instead of "I am also a colored pill, please look at me."
The dark side
Three stars instead of clouds. Each star is a 2px square rotated 45 degrees, with a box-shadow that adds a perpendicular cross, looks like a tiny twinkle. Each star has its own twinkle keyframe with a different period (3s, 4.5s, 7s) and offset, so they don't blink in sync. Sync would feel mechanical; offsets feel like a sky.
@keyframes mode-toggle-twinkle {
0%, 100% { opacity: 0.3; transform: rotate(45deg) scale(0.8); }
50% { opacity: 1; transform: rotate(45deg) scale(1.0); }
}
The 0.3 minimum opacity is the trick: stars never go fully invisible, they just dim. Going to 0 looked like they were toggling on/off, which broke the illusion. 0.3 looks like "stars are always there, some are just brighter right now."
The thumb
When you toggle, the thumb slides from one end to the other. The duration is 360ms with a custom cubic-bezier that's slightly overshooty: cubic-bezier(0.34, 1.4, 0.64, 1). Not enough overshoot to feel cartoony, just enough to register as "this thing has weight."
The sun (light mode thumb) has a soft outer glow on hover. The moon (dark mode thumb) has a slim crescent: just a smaller circle offset to one side, same color as the bg, masking part of the moon. Both are cut from the same 28px circle so the slide between them is visually identical.
Total cost
About 200 lines of CSS, 8 cloud spans, 3 star spans, 1 thumb, 1 keyframe per animation type. Around 1.4KB gzipped after build. Three iterations to get the cloud-edge fade working without it being visible. Two iterations on the hover speed multipliers. About an hour of work for what should be a checkbox.
Was it worth it
For a SaaS dashboard, no. For a portfolio site whose entire pitch is "I care about craft," yes, because the toggle is one of the first things a visitor touches, and the difference between "this person ships defaults" and "this person noticed the cloud-pop and built a mask gradient to hide it" is the only thing the toggle communicates.
Defaults are fine. Defaults are the right answer for 90% of the work I do for clients. But if you're building a thing where the work itself is the pitch, the toggle should pitch.
The drop-in version
Three blocks: markup, styles, and the click handler. Drop the markup wherever the toggle goes, paste the CSS into your stylesheet, and the JS into a script. Tune cloud counts and twinkle timings to taste.
<button class="mode-toggle" type="button" aria-label="Toggle color mode">
<span class="mode-toggle__clouds" aria-hidden="true">
<span class="mode-toggle__cloud mode-toggle__cloud--1"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--2"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--3"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--4"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--5"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--6"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--7"></span>
<span class="mode-toggle__cloud mode-toggle__cloud--8"></span>
</span>
<span class="mode-toggle__stars" aria-hidden="true">
<span class="mode-toggle__star"></span>
<span class="mode-toggle__star"></span>
<span class="mode-toggle__star"></span>
</span>
<span class="mode-toggle__thumb" aria-hidden="true"></span>
</button>
/* Pill */
.mode-toggle {
position: relative;
width: 80px; height: 32px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.08);
background: linear-gradient(180deg, #b6dcff, #e6f3ff);
cursor: pointer; overflow: hidden;
transition: background 360ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
[data-theme="dark"] .mode-toggle {
background: linear-gradient(180deg, #0c1530, #14213d);
border-color: rgba(255,255,255,0.08);
}
/* Cloud container — mask hides the pop on the edges */
.mode-toggle__clouds {
position: absolute; inset: 0; opacity: 1;
transition: opacity 280ms ease-out;
-webkit-mask-image: linear-gradient(to right,
transparent 0%, black 12%, black 88%, transparent 100%);
mask-image: linear-gradient(to right,
transparent 0%, black 12%, black 88%, transparent 100%);
}
[data-theme="dark"] .mode-toggle__clouds { opacity: 0; }
.mode-toggle__cloud {
position: absolute;
background: white; border-radius: 999px;
animation: cloud-drift linear infinite;
}
.mode-toggle__cloud--1 { width: 14px; height: 5px; top: 6px;
box-shadow: 5px -2px 0 0 white, -3px -1px 0 0 white;
animation-duration: 12s; animation-delay: 0s; }
.mode-toggle__cloud--2 { width: 10px; height: 4px; top: 18px;
box-shadow: 3px -2px 0 0 white;
animation-duration: 9s; animation-delay: -2s; }
.mode-toggle__cloud--3 { width: 14px; height: 6px; top: 30%;
box-shadow: 5px -3px 0 0 white, -3px -2px 0 0 white, 8px 0 0 -1px white;
animation-duration: 11s; animation-delay: -3s; }
/* repeat with varied size + speed + delay through --8 */
@keyframes cloud-drift {
from { right: -30px; }
to { right: 110%; }
}
/* Stars */
.mode-toggle__stars { position: absolute; inset: 0; opacity: 0;
transition: opacity 280ms ease-out; }
[data-theme="dark"] .mode-toggle__stars { opacity: 1; }
.mode-toggle__star {
position: absolute; width: 2px; height: 2px;
background: white; transform: rotate(45deg);
box-shadow: 0 0 4px white;
animation: mode-toggle-twinkle 3s ease-in-out infinite;
}
.mode-toggle__star:nth-child(1) { top: 10px; left: 14px; animation-delay: 0s; }
.mode-toggle__star:nth-child(2) { top: 27px; left: 24px; animation-delay: 0.8s; }
.mode-toggle__star:nth-child(3) { top: 13px; left: 38px; animation-delay: 1.6s; }
@keyframes mode-toggle-twinkle {
0%, 100% { opacity: 0.3; transform: rotate(45deg) scale(0.8); }
50% { opacity: 1; transform: rotate(45deg) scale(1.0); }
}
/* Thumb (sun/moon) */
.mode-toggle__thumb {
position: absolute; top: 2px; left: 2px;
width: 28px; height: 28px; border-radius: 999px;
background: radial-gradient(circle at 35% 35%, #fff7ad, #ffce4a 70%);
box-shadow: 0 0 10px rgba(255, 200, 60, 0.5);
transition: transform 360ms cubic-bezier(0.34, 1.4, 0.64, 1),
background 360ms ease;
}
[data-theme="dark"] .mode-toggle__thumb {
transform: translateX(48px);
background: radial-gradient(circle at 70% 35%,
transparent 0, transparent 8px, #f5f5f7 8px);
box-shadow: 0 0 6px rgba(245, 245, 247, 0.4);
}
// Flip the data-theme attribute on <html> and persist to localStorage
// so the choice survives reloads.
const html = document.documentElement;
const stored = localStorage.getItem('theme');
if (stored) html.dataset.theme = stored;
document.querySelector('.mode-toggle').addEventListener('click', () => {
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', html.dataset.theme);
});