Skip to content
UI ComponentDark Mode ToggleUI ElementNew

Day & Night Dark Mode Toggle with Circular Reveal

An illustrated theme switch where the sun sets into a cratered moon, clouds drift away and stars twinkle in — and the whole page switches theme with a circular reveal from the toggle (View Transitions API). Includes a Light / Dark / System preference control, small, medium and large sizes, saved choice, no flash on load and live system-theme following.

  • Published 15 Oct 2026
  • 10 views
Day & Night Dark Mode Toggle with Circular Reveal — preview

Overview

Day & Night Dark Mode Toggle with Circular Reveal

A dark mode toggle worth clicking twice — sun to moon, clouds to stars, and the page follows in a circle.


What's Included

  • Sun → moon toggle with clouds, craters and twinkling stars
  • Circular page reveal from the toggle (View Transitions API, with fallback)
  • Light / Dark / System preference with live OS theme following
  • Choice saved in localStorage, applied before first paint (no flash)
  • One --s variable scales the toggle to any size
  • role="switch" with aria-checked for screen readers
  • Theme colors as CSS variables — easy to drop into any site

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Day / Night Theme Toggle | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8    <!-- Set the theme before first paint so there's no flash -->
9    <script>
10      (() => {
11        const saved = localStorage.getItem("theme-preference") || "system";
12        const dark = saved === "dark" || (saved === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
13        document.documentElement.dataset.theme = dark ? "dark" : "light";
14      })();
15    </script>
16  </head>
17  <body>
18    <!-- created by metaskillgroup-team -->
19    <main class="page">
20      <header class="intro">
21        <span class="eyebrow">Dark mode toggle</span>
22        <h1>Good <span class="day-word">morning</span><span class="night-word">evening</span> ✦</h1>
23        <p>Flip the switch — the sun sets, the stars come out and the whole page follows with a circular reveal. Your choice is remembered.</p>
24      </header>
25
26      <!-- Big toggle -->
27      <div class="hero-toggle">
28        <button type="button" class="dn-toggle" role="switch" aria-checked="false" aria-label="Dark mode" style="--s: 2">
29          <span class="sky" aria-hidden="true"></span>
30          <span class="stars" aria-hidden="true">
31            <i style="--x: 18%; --y: 22%; --d: 0s"></i>
32            <i style="--x: 30%; --y: 62%; --d: 0.6s"></i>
33            <i style="--x: 44%; --y: 30%; --d: 1.1s"></i>
34            <i style="--x: 12%; --y: 70%; --d: 0.3s"></i>
35            <i style="--x: 52%; --y: 72%; --d: 1.5s"></i>
36            <i style="--x: 38%; --y: 14%; --d: 0.9s"></i>
37          </span>
38          <span class="clouds" aria-hidden="true">
39            <i></i><i></i><i></i>
40          </span>
41          <span class="knob" aria-hidden="true">
42            <span class="crater c1"></span>
43            <span class="crater c2"></span>
44            <span class="crater c3"></span>
45          </span>
46        </button>
47      </div>
48
49      <!-- Settings card showing other sizes + a 3-way preference -->
50      <section class="card">
51        <div class="card-row">
52          <div>
53            <h2>Appearance</h2>
54            <p>Choose how the interface looks to you.</p>
55          </div>
56        </div>
57
58        <div class="segmented" role="radiogroup" aria-label="Theme preference">
59          <button type="button" role="radio" data-pref="light">☀️ Light</button>
60          <button type="button" role="radio" data-pref="dark">🌙 Dark</button>
61          <button type="button" role="radio" data-pref="system">💻 System</button>
62        </div>
63
64        <div class="sizes">
65          <div class="size">
66            <button type="button" class="dn-toggle" role="switch" aria-checked="false" aria-label="Dark mode (small)" style="--s: 0.8"><span class="sky"></span><span class="stars"><i style="--x: 22%; --y: 30%; --d: 0s"></i><i style="--x: 40%; --y: 65%; --d: 0.7s"></i></span><span class="clouds"><i></i><i></i><i></i></span><span class="knob"><span class="crater c1"></span><span class="crater c2"></span><span class="crater c3"></span></span></button>
67            <small>Small</small>
68          </div>
69          <div class="size">
70            <button type="button" class="dn-toggle" role="switch" aria-checked="false" aria-label="Dark mode (medium)" style="--s: 1.1"><span class="sky"></span><span class="stars"><i style="--x: 22%; --y: 30%; --d: 0s"></i><i style="--x: 40%; --y: 65%; --d: 0.7s"></i><i style="--x: 14%; --y: 66%; --d: 1.2s"></i></span><span class="clouds"><i></i><i></i><i></i></span><span class="knob"><span class="crater c1"></span><span class="crater c2"></span><span class="crater c3"></span></span></button>
71            <small>Medium</small>
72          </div>
73          <div class="size">
74            <button type="button" class="dn-toggle" role="switch" aria-checked="false" aria-label="Dark mode (large)" style="--s: 1.4"><span class="sky"></span><span class="stars"><i style="--x: 22%; --y: 30%; --d: 0s"></i><i style="--x: 40%; --y: 65%; --d: 0.7s"></i><i style="--x: 14%; --y: 66%; --d: 1.2s"></i><i style="--x: 48%; --y: 22%; --d: 0.4s"></i></span><span class="clouds"><i></i><i></i><i></i></span><span class="knob"><span class="crater c1"></span><span class="crater c2"></span><span class="crater c3"></span></span></button>
75            <small>Large</small>
76          </div>
77        </div>
78      </section>
79    </main>
80
81    <script src="script.js"></script>
82  </body>
83</html>

CSS

css
1/* Importing Google Fonts - Nunito */
2@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "Nunito", sans-serif;
9}
10
11/* ---------- Theme tokens ---------- */
12:root,
13[data-theme="light"] {
14  --bg: #eaf2ff;
15  --bg-2: #fdf7e8;
16  --card: #ffffff;
17  --text: #13213f;
18  --muted: #5f6f8f;
19  --border: #d9e3f5;
20  --accent: #f5a623;
21  color-scheme: light;
22}
23
24[data-theme="dark"] {
25  --bg: #0b1026;
26  --bg-2: #1b1a4a;
27  --card: #141a3a;
28  --text: #e8ecff;
29  --muted: #98a2c9;
30  --border: #262e5c;
31  --accent: #b9c3ff;
32  color-scheme: dark;
33}
34
35body {
36  min-height: 100vh;
37  background: linear-gradient(180deg, var(--bg), var(--bg-2));
38  color: var(--text);
39  transition: background 0.5s, color 0.5s;
40}
41
42/* The circular reveal is done with the View Transitions API (script.js) */
43::view-transition-old(root),
44::view-transition-new(root) {
45  animation: none;
46  mix-blend-mode: normal;
47}
48
49.page {
50  display: grid;
51  justify-items: center;
52  max-width: 760px;
53  margin: 0 auto;
54  padding: 80px 20px;
55}
56
57/* ---------- Intro ---------- */
58.intro {
59  text-align: center;
60}
61
62.eyebrow {
63  color: var(--accent);
64  font-size: 0.82rem;
65  font-weight: 800;
66  letter-spacing: 0.16em;
67  text-transform: uppercase;
68}
69
70.intro h1 {
71  margin: 12px 0;
72  font-size: clamp(2.2rem, 6vw, 3.6rem);
73  font-weight: 900;
74  letter-spacing: -0.02em;
75}
76
77.night-word,
78[data-theme="dark"] .day-word {
79  display: none;
80}
81
82[data-theme="dark"] .night-word {
83  display: inline;
84}
85
86.intro p {
87  max-width: 540px;
88  margin: 0 auto;
89  color: var(--muted);
90  font-size: 1.05rem;
91  line-height: 1.65;
92}
93
94.hero-toggle {
95  margin: 54px 0 60px;
96}
97
98/* =========================================================
99   The toggle — everything is in `em`, so --s scales it
100   ========================================================= */
101.dn-toggle {
102  --s: 1;
103  --ease: cubic-bezier(0.6, -0.25, 0.3, 1.3);
104  position: relative;
105  width: 5.6em;
106  height: 2.4em;
107  padding: 0;
108  border: 0;
109  border-radius: 999px;
110  font-size: calc(var(--s) * 16px);
111  cursor: pointer;
112  overflow: hidden;
113  isolation: isolate;
114  box-shadow:
115    inset 0 0.12em 0.35em rgba(0, 0, 0, 0.3),
116    0 0.6em 1.4em -0.5em rgba(19, 33, 63, 0.45);
117  -webkit-tap-highlight-color: transparent;
118}
119
120.dn-toggle:focus-visible {
121  outline: calc(var(--s) * 3px) solid var(--accent);
122  outline-offset: calc(var(--s) * 3px);
123}
124
125/* Day and night skies, cross-faded */
126.sky,
127.sky::after {
128  position: absolute;
129  inset: 0;
130  background: linear-gradient(180deg, #3d9bff, #8fd0ff);
131}
132
133.sky::after {
134  content: "";
135  background: radial-gradient(circle at 70% 30%, #2d3592, #0b1026 75%);
136  opacity: 0;
137  transition: opacity 0.6s;
138}
139
140/* Stars (night only) */
141.stars i {
142  position: absolute;
143  left: var(--x);
144  top: var(--y);
145  width: 0.18em;
146  height: 0.18em;
147  border-radius: 50%;
148  background: #fff;
149  box-shadow: 0 0 0.3em #fff;
150  opacity: 0;
151  transform: scale(0);
152  transition: opacity 0.4s, transform 0.4s;
153}
154
155/* Clouds (day only) */
156.clouds i {
157  position: absolute;
158  border-radius: 50%;
159  background: #fff;
160  box-shadow: 0 -0.1em 0 rgba(0, 0, 0, 0.04) inset;
161  transition: transform 0.6s var(--ease);
162}
163
164.clouds i:nth-child(1) {
165  right: -0.3em;
166  bottom: -0.6em;
167  width: 1.9em;
168  height: 1.9em;
169}
170
171.clouds i:nth-child(2) {
172  right: 1.1em;
173  bottom: -0.9em;
174  width: 1.6em;
175  height: 1.6em;
176  transition-delay: 0.05s;
177}
178
179.clouds i:nth-child(3) {
180  right: 2.1em;
181  bottom: -1.1em;
182  width: 1.4em;
183  height: 1.4em;
184  opacity: 0.85;
185  transition-delay: 0.1s;
186}
187
188/* Sun → moon */
189.knob {
190  position: absolute;
191  top: 0.3em;
192  left: 0.3em;
193  width: 1.8em;
194  height: 1.8em;
195  border-radius: 50%;
196  background: radial-gradient(circle at 35% 35%, #fff7b8, #ffc43d 60%, #ffab1f);
197  box-shadow:
198    0 0 0 0.35em rgba(255, 255, 255, 0.2),
199    0 0 0 0.75em rgba(255, 255, 255, 0.1),
200    0 0 0 1.2em rgba(255, 255, 255, 0.06),
201    0.1em 0.15em 0.3em rgba(0, 0, 0, 0.25);
202  transition: transform 0.6s var(--ease), background 0.6s, box-shadow 0.6s;
203}
204
205.crater {
206  position: absolute;
207  border-radius: 50%;
208  background: #a8aec6;
209  box-shadow: inset 0.06em 0.06em 0.1em rgba(0, 0, 0, 0.25);
210  opacity: 0;
211  transition: opacity 0.4s;
212}
213
214.c1 {
215  top: 0.35em;
216  left: 0.5em;
217  width: 0.5em;
218  height: 0.5em;
219}
220
221.c2 {
222  top: 0.95em;
223  left: 0.25em;
224  width: 0.3em;
225  height: 0.3em;
226}
227
228.c3 {
229  top: 1em;
230  left: 0.95em;
231  width: 0.4em;
232  height: 0.4em;
233}
234
235.dn-toggle:hover .knob {
236  transform: translateX(0.15em);
237}
238
239/* ----- Night state ----- */
240.dn-toggle[aria-checked="true"] .sky::after {
241  opacity: 1;
242}
243
244.dn-toggle[aria-checked="true"] .knob {
245  transform: translateX(3.2em);
246  background: radial-gradient(circle at 35% 35%, #fbfcff, #d9ddee 65%, #c3c8dd);
247  box-shadow:
248    0 0 0 0.35em rgba(255, 255, 255, 0.08),
249    0 0 0 0.75em rgba(255, 255, 255, 0.05),
250    0 0 1.2em rgba(200, 210, 255, 0.5);
251}
252
253.dn-toggle[aria-checked="true"]:hover .knob {
254  transform: translateX(3.05em);
255}
256
257.dn-toggle[aria-checked="true"] .crater {
258  opacity: 1;
259  transition-delay: 0.25s;
260}
261
262.dn-toggle[aria-checked="true"] .clouds i {
263  transform: translateY(2em);
264}
265
266.dn-toggle[aria-checked="true"] .stars i {
267  opacity: 1;
268  transform: scale(1);
269  transition-delay: 0.3s;
270  animation: twinkle 2s var(--d) ease-in-out infinite;
271}
272
273@keyframes twinkle {
274  50% {
275    opacity: 0.3;
276    transform: scale(0.6);
277  }
278}
279
280/* ---------- Settings card ---------- */
281.card {
282  width: 100%;
283  padding: 28px;
284  border: 1px solid var(--border);
285  border-radius: 24px;
286  background: var(--card);
287  box-shadow: 0 30px 70px -40px rgba(19, 33, 63, 0.35);
288  transition: background 0.5s, border-color 0.5s;
289}
290
291.card h2 {
292  font-size: 1.25rem;
293  font-weight: 800;
294}
295
296.card p {
297  margin-top: 4px;
298  color: var(--muted);
299}
300
301.segmented {
302  display: grid;
303  grid-template-columns: repeat(3, 1fr);
304  gap: 6px;
305  margin: 22px 0 26px;
306  padding: 6px;
307  border-radius: 16px;
308  background: color-mix(in srgb, var(--text) 6%, transparent);
309}
310
311.segmented button {
312  padding: 12px 10px;
313  border: 0;
314  border-radius: 12px;
315  background: none;
316  color: var(--muted);
317  font-size: 0.95rem;
318  font-weight: 700;
319  cursor: pointer;
320  transition: background 0.3s, color 0.3s, box-shadow 0.3s;
321}
322
323.segmented button[aria-checked="true"] {
324  background: var(--card);
325  color: var(--text);
326  box-shadow: 0 4px 14px -6px rgba(19, 33, 63, 0.4);
327}
328
329.segmented button:focus-visible {
330  outline: 3px solid var(--accent);
331  outline-offset: 2px;
332}
333
334.sizes {
335  display: flex;
336  justify-content: space-around;
337  align-items: flex-end;
338  flex-wrap: wrap;
339  gap: 24px;
340  padding-top: 24px;
341  border-top: 1px dashed var(--border);
342}
343
344.size {
345  display: grid;
346  justify-items: center;
347  gap: 10px;
348}
349
350.size small {
351  color: var(--muted);
352  font-size: 0.8rem;
353  font-weight: 700;
354}
355
356/* ---------- Responsive ---------- */
357@media (max-width: 480px) {
358  .hero-toggle .dn-toggle {
359    --s: 1.5 !important;
360  }
361
362  .segmented button {
363    font-size: 0.85rem;
364  }
365}
366
367@media (prefers-reduced-motion: reduce) {
368  *,
369  *::before,
370  *::after {
371    animation: none !important;
372    transition: none !important;
373  }
374}

JavaScript

javascript
1const STORAGE_KEY = "theme-preference"; // "light" | "dark" | "system"
2const root = document.documentElement;
3const toggles = document.querySelectorAll(".dn-toggle");
4const prefButtons = document.querySelectorAll("[data-pref]");
5const systemDark = window.matchMedia("(prefers-color-scheme: dark)");
6const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
7
8const getPreference = () => {
9  try {
10    return localStorage.getItem(STORAGE_KEY) || "system";
11  } catch {
12    return "system";
13  }
14};
15
16const resolve = (pref) => (pref === "system" ? (systemDark.matches ? "dark" : "light") : pref);
17
18// Paint every control so it matches the current theme + preference
19const syncControls = () => {
20  const dark = root.dataset.theme === "dark";
21  toggles.forEach((t) => t.setAttribute("aria-checked", dark));
22  const pref = getPreference();
23  prefButtons.forEach((b) => b.setAttribute("aria-checked", b.dataset.pref === pref));
24};
25
26// Switch theme with a circle that grows from (x, y)
27const applyTheme = (theme, origin) => {
28  if (root.dataset.theme === theme) return syncControls();
29
30  const swap = () => {
31    root.dataset.theme = theme;
32    syncControls();
33  };
34
35  if (!document.startViewTransition || reduceMotion || !origin) {
36    swap();
37    return;
38  }
39
40  const { x, y } = origin;
41  const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
42  const transition = document.startViewTransition(swap);
43  transition.ready.then(() => {
44    root.animate(
45      { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
46      { duration: 750, easing: "cubic-bezier(0.65, 0, 0.35, 1)", pseudoElement: "::view-transition-new(root)" },
47    );
48  });
49};
50
51const setPreference = (pref, origin) => {
52  try {
53    localStorage.setItem(STORAGE_KEY, pref);
54  } catch {
55    // Storage can be blocked — the theme still changes for this visit
56  }
57  applyTheme(resolve(pref), origin);
58  syncControls();
59};
60
61const centerOf = (el) => {
62  const r = el.getBoundingClientRect();
63  return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
64};
65
66toggles.forEach((toggle) => {
67  toggle.addEventListener("click", () => {
68    const next = root.dataset.theme === "dark" ? "light" : "dark";
69    // Let the sun / moon animation start, then reveal the page from the knob
70    toggle.setAttribute("aria-checked", next === "dark");
71    setTimeout(() => setPreference(next, centerOf(toggle.querySelector(".knob"))), 180);
72  });
73});
74
75prefButtons.forEach((btn) => {
76  btn.addEventListener("click", () => setPreference(btn.dataset.pref, centerOf(btn)));
77});
78
79// Follow the OS setting live while the preference is "system"
80systemDark.addEventListener("change", () => {
81  if (getPreference() === "system") applyTheme(resolve("system"));
82});
83
84syncControls();

Built by MetaSkillGroup — copy it, use it, make it yours.

Features

  • Sun → moon toggle with clouds, craters and twinkling stars

  • Circular page reveal from the toggle (View Transitions API, with fallback)

  • Light / Dark / System preference with live OS theme following

  • Choice saved in localStorage, applied before first paint (no flash)

  • One --s variable scales the toggle to any size

  • role="switch" with aria-checked for screen readers

  • Theme colors as CSS variables — easy to drop into any site

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ FAQs

Copy the toggle markup, its CSS, the small inline script in <head> (prevents a flash on load) and script.js. Put your colors in the [data-theme="light"] and [data-theme="dark"] blocks.

The theme still switches instantly and the toggle still animates — only the circular page reveal is skipped.

Set the --s variable on the button, e.g. style="--s: 1.5". Everything inside is measured in em, so it scales perfectly.

MetaSkillGroup

Written by

MetaSkillGroup

October 15, 2026

Need it built for you?

Built to the same standard as our client work.

If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.

Day/Night Dark Mode Toggle (HTML CSS JS) | MetaSkillGroup