Introduction
This component is a sky that flips between day and night when you flick a switch — a sun crossfades into a moon, stars fade in, clouds disappear, and a horizon silhouette darkens, all in one motion. There is no image swap and no framework behind it: the whole effect comes from one HTML attribute, two overlapping circles, and a button that actually behaves like a switch.
The technique rests on four ideas: a single data-mode attribute on a shared parent lets every child element react to the same state instead of each element getting its own toggled class — stacking a sun face and a moon face in the exact same position and crossfading their opacity reads as one object transforming, not two objects trading places — generating repetitive decorative elements like stars in a short loop keeps the markup from bloating every time you want more of them — and building the control itself out of a real <button role="switch"> means keyboard and screen-reader support come for free instead of being bolted on afterward.
How one data-mode attribute can drive a gradient, an opacity, and a transform at the same time — how two absolutely-positioned faces crossfade into what looks like a single shape-shifting object — how to generate N decorative elements from a loop instead of typing them out — how role="switch" and aria-checked give you correct semantics for free — and how to tell apart motion that should always be running from motion that should only happen once, on command.
Step 1 — Let the Parent Hold the State
A tempting first draft is to add a .night class to the sky, a separate .active class to the stars, another to the sun, and so on — four things to keep in sync by hand. Instead, the sky element carries one data-mode="day" or data-mode="night" attribute, and every child selector reads from that single source of truth. Toggling the whole scene becomes one line of JavaScript, not a checklist of classes to flip.
<div class="sky" id="sky" data-mode="day">
<div class="stars" id="stars"></div>
<div class="horizon"></div>
<div class="celestial">
<div class="face sun"></div>
<div class="face moon"></div>
</div>
</div>function setMode(isNight) {
sky.setAttribute('data-mode', isNight ? 'night' : 'day');
}Step 2 — Transition the Gradient Itself
Browsers can transition a background property directly as long as both the day and night values are gradients of the same type. That means the sky does not need two stacked, opacity-faded layers — it needs one rule for the resting state, one attribute-selector override for night, and a transition on background. Everything else in the scene keys off the same [data-mode="night"] selector, which keeps every night-only rule grouped under one predictable pattern instead of scattered across unrelated class names.
.sky {
background: linear-gradient(160deg, #8ec9f0 0%, #cdeffc 55%, #eef8ff 100%);
transition: background 600ms cubic-bezier(.65,0,.35,1);
}
.sky[data-mode="night"] {
background: linear-gradient(160deg, #0b1026 0%, #1b2247 55%, #2a2f5c 100%);
}Every night-only rule keys off the same [data-mode="night"] selector — one pattern to search for, not four different class names to remember.
Step 3 — Stack Two Circles in the Same Spot
Swapping a background-image from a sun to a moon would snap instantly with no in-between frame. Instead, the sun and the moon are two separate absolutely-positioned circles occupying the exact same box, each with its own radial gradient, and only their opacity transitions. At the halfway point of the toggle, both are partially visible on top of each other, which is what actually sells the illusion of one object morphing rather than two objects trading places.
.celestial .face {
position: absolute;
inset: 0;
border-radius: 50%;
transition: opacity 500ms cubic-bezier(.65,0,.35,1);
}
.celestial .sun {
background: radial-gradient(circle at 35% 30%, #ffd166, #ff9f1c);
opacity: 1;
}
.celestial .moon {
background: radial-gradient(circle at 30% 30%, #f4f4f8, #cfd2e0);
opacity: 0;
}
.sky[data-mode="night"] .celestial .sun { opacity: 0; }
.sky[data-mode="night"] .celestial .moon { opacity: 1; }The moon's crater texture comes from three layered inset box-shadows rather than a background image — small negative-offset shadows read as shallow pits once they sit inside a circular clip, which is enough suggestion of texture without needing an actual bitmap asset.
Step 4 — A Loop Instead of Twenty-Four Divs
Writing out two dozen individually positioned star elements by hand in HTML would work, but it means every future tweak — more stars, a different spread, a new size — is a manual editing pass. Generating them in a short loop at load time keeps the markup empty until JavaScript runs, and turns "add more stars" into changing a single number.
const starCount = 24;
for (let i = 0; i < starCount; i++) {
const star = document.createElement('div');
star.className = 'star';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 55 + '%';
star.style.animationDelay = (Math.random() * 2.6) + 's';
starsContainer.appendChild(star);
}Each star still gets its own twinkle keyframe, but every star runs the same one — only the random delay staggers them so they do not all pulse in unison, the same staggering idea as a :nth-child delay, just computed instead of typed out.
Step 5 — Semantics You Do Not Have to Rebuild
A <div> with a click handler and a background color is not a control — it is invisible to a screen reader and unreachable by keyboard by default. Using an actual <button role="switch" aria-checked="false"> means the browser already exposes the correct role and state; the only job left for CSS and JavaScript is to move a thumb and flip that one boolean attribute.
<button class="switch" id="switchBtn" role="switch" aria-checked="false" aria-label="Toggle day and night">
<span class="switch-thumb"></span>
</button>.switch[aria-checked="true"] { background: var(--track-on); }
.switch[aria-checked="true"] .switch-thumb { transform: translateX(32px); }
.switch:focus-visible {
outline: 2px solid var(--track-on);
outline-offset: 3px;
}The thumb position and the track color both key off the same aria-checked attribute rather than a separate visual-only class. That is not a style shortcut — it guarantees the thing a sighted user sees and the state a screen reader announces can never drift out of sync with each other.
Step 6 — Two Different Reasons Something Moves
The scene has two categories of motion that are easy to accidentally blend into one: motion that is always quietly running (the clouds drifting, the stars twinkling) and motion that only happens because the user acted (the sun sliding across and crossfading into the moon). Ambient motion uses an infinite keyframe with no trigger. Triggered motion is a plain transition on a property that only changes value when the data-mode attribute changes — there is no keyframe running until the moment of the click.
/* ambient — always running, independent of state */
.cloud.c1 { animation: drift 22s linear infinite; }
/* triggered — only moves because data-mode changed */
.celestial {
transform: translateX(0);
transition: transform 700ms cubic-bezier(.65,0,.35,1);
}
.sky[data-mode="night"] .celestial { transform: translateX(230px); }Keeping these two categories on different CSS mechanisms — animation for ambient, transition for triggered — means disabling one for accessibility reasons later does not accidentally disable the other.
Step 7 — Turning Off Motion Without Turning Off the Component
Because the triggered motion lives on transition rather than animation, silencing it for prefers-reduced-motion is a single blanket rule — there is no opacity: 0 starting state left stranded the way there can be with keyframe-driven entrances, so nothing needs a manual visibility reset. Cutting every transition and animation duration to near-zero leaves the toggle fully functional, just without the sweep and crossfade.
@media (prefers-reduced-motion: reduce) {
*{ animation: none !important; transition-duration: 1ms !important; }
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| celestial translateX distance | 230px | How far the sun/moon travels across the sky; should roughly match track width minus body width |
| face opacity transition | 500ms | Crossfade speed between sun and moon; shorter feels like a snap, longer like a slow eclipse |
| star count | 24 | Density of the night sky; more stars costs more DOM nodes, not more code |
| twinkle animation-delay | Math.random() * 2.6s | How staggered the twinkling reads; a fixed delay makes every star pulse in lockstep |
| switch thumb translateX | 32px | Travel distance of the switch thumb; should match track width minus thumb width minus padding |
| prefers-reduced-motion | transition-duration: 1ms | Keeps the toggle fully functional while removing the sweep, for users who need it |
Full Source Code
The complete component is a single self-contained HTML document — one sky element, one switch, and a short script that generates the stars and flips the shared data-mode attribute. No build step, no external assets.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/day-night-toggle.html