Aduok Code

A Day/Night Toggle, Built from One data-mode Attribute, Two Crossfaded Faces, and a Real Switch

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.

What you will learn

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.

01
Part One
One Attribute Instead of Four Classes

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.

HTML
<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>
JavaScript
function setMode(isNight) {
  sky.setAttribute('data-mode', isNight ? 'night' : 'day');
}
02
Part Two
The Sky Is a Gradient Cross-Fade

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.

CSS
.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.

03
Part Three
Two Faces, Not a Swapped Background

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.

CSS
.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.

04
Part Four
Stars Are Generated, Not Hand-Written

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.

JavaScript
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.

05
Part Five
A Real Switch, Not a Clickable Div

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.

HTML
<button class="switch" id="switchBtn" role="switch" aria-checked="false" aria-label="Toggle day and night">
  <span class="switch-thumb"></span>
</button>
CSS
.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;
}
A gotcha worth knowing about

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.

06
Part Six
Ambient Motion vs. Triggered Motion

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.

CSS
/* 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.

07
Part Seven
Reduced Motion

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.

CSS
@media (prefers-reduced-motion: reduce) {
  *{ animation: none !important; transition-duration: 1ms !important; }
}

Tuning Reference

PropertyExampleEffect
celestial translateX distance230pxHow far the sun/moon travels across the sky; should roughly match track width minus body width
face opacity transition500msCrossfade speed between sun and moon; shorter feels like a snap, longer like a slow eclipse
star count24Density of the night sky; more stars costs more DOM nodes, not more code
twinkle animation-delayMath.random() * 2.6sHow staggered the twinkling reads; a fixed delay makes every star pulse in lockstep
switch thumb translateX32pxTravel distance of the switch thumb; should match track width minus thumb width minus padding
prefers-reduced-motiontransition-duration: 1msKeeps 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.

HTML — day-night-toggle.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/day-night-toggle.html
Want to see it in action?Watch the full build on YouTube