Day-night Toggle
A theme switch drawn as a little sky. Flick it and the sun dips across the track and turns into a cratered moon, the blue sky deepens to night, the clouds sink away and stars twinkle in. The page’s theme switches with it. Flick it back and the day returns.
Light modeDark mode
How to use it
- Paste the snippet just before
</head>. - Add
data-day-night-toggleto an empty button, with anaria-label. - Write your dark theme under
html.dark. The switch adds and removes that class for you.
Options
Add any of these attributes to the same element.
data-target | CSS selector of the element whose class it toggles. A surrounding element that matches wins, then the first match on the page. Default html. |
data-class | The class it toggles there. Default dark. |
data-dark | Start in night mode. |
data-autoplay | It flicks itself every couple of seconds while in view, until someone presses it. Handy for demos. |
Size it with font-size: it is 4.4em wide and 2em tall. It is a switch for screen readers, and fires a change event with event.detail.dark.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Day-night Toggle: a theme switch drawn as a little sky. Flick it and
// the sun dips across the track and turns into a cratered moon, the blue
// sky darkens to night, the clouds sink away and stars twinkle in. Flick
// it back and the day returns.
// Usage: <button data-day-night-toggle aria-label="Dark mode"></button>
// Optional: data-target (CSS selector of the element whose class it
// toggles, default "html"; an ancestor matching it wins, then the first
// match in the page), data-class (the class it toggles there, default
// "dark"), data-dark (start in night mode), data-autoplay (it flicks
// itself every couple of seconds while in view, until someone presses it;
// handy for demos).
// Events: fires "change" on the toggle with event.detail.dark.
// Size it with font-size: it is 4.4em wide and 2em tall.
var DEFAULTS = { target: 'html', cls: 'dark', every: 2600 };
var STARS = [[0.55, 0.45, 0.2], [1.05, 1.25, 0.14], [1.5, 0.55, 0.16], [0.9, 0.85, 0.1], [2.05, 1.2, 0.18], [2.35, 0.4, 0.12], [1.55, 1.5, 0.1]];
var css =
':where(.dnn){font-size:inherit}' +
'.dnn{position:relative;display:inline-block;width:4.4em;height:2em;padding:0;border:0;border-radius:1em;overflow:hidden;cursor:pointer;' +
'vertical-align:middle;isolation:isolate;-webkit-tap-highlight-color:transparent;' +
'box-shadow:inset 0 .08em .2em rgba(0,0,0,.25),0 .12em .4em rgba(15,23,42,.18)}' +
'.dnn:focus-visible{outline:3px solid #f59e0b;outline-offset:3px}' +
'.dnn>span{position:absolute;pointer-events:none}' +
'.dnn-day,.dnn-night{inset:0}' +
'.dnn-day{background:linear-gradient(180deg,#7dd3fc,#38bdf8 70%,#0ea5e9)}' +
'.dnn-night{background:linear-gradient(180deg,#0b1026,#1e1b4b 70%,#312e81);opacity:0}' +
'.dnn-cloud{width:.85em;height:.85em;border-radius:50%;background:#fff;' +
'box-shadow:.55em -.12em 0 .02em #fff,1.1em .1em 0 -.08em #fff,1.6em .3em 0 -.12em #fff}' +
'.dnn-cloud.dnn-back{background:rgba(255,255,255,.55);box-shadow:.55em -.12em 0 .02em rgba(255,255,255,.55),1.1em .1em 0 -.08em rgba(255,255,255,.55)}' +
'.dnn-star{border-radius:50%;background:#fff;opacity:0}' +
'.dnn-dark .dnn-star{animation:dnn-tw 2.2s ease-in-out infinite}' +
'@keyframes dnn-tw{50%{background-color:rgba(255,255,255,.35)}}' +
'.dnn-knob{left:.2em;top:.2em;width:1.6em;height:1.6em;border-radius:50%;z-index:1}' +
'.dnn-sun,.dnn-moon{position:absolute;inset:0;border-radius:50%}' +
'.dnn-sun{background:radial-gradient(circle at 35% 35%,#fef3c7,#facc15 55%,#f59e0b);' +
'box-shadow:0 0 0 .22em rgba(254,240,138,.35),0 0 0 .5em rgba(254,240,138,.16),0 .08em .2em rgba(0,0,0,.18)}' +
'.dnn-moon{background:radial-gradient(circle at 35% 30%,#f8fafc,#cbd5e1 75%);opacity:0;' +
'box-shadow:0 0 .5em rgba(226,232,240,.45),inset -.12em -.1em .2em rgba(100,116,139,.45)}' +
'.dnn-moon i{position:absolute;border-radius:50%;background:#94a3b8;opacity:.55;box-shadow:inset .04em .04em .06em rgba(51,65,85,.45)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function part(cls, s) {
var n = document.createElement('span');
n.className = cls;
n.setAttribute('aria-hidden', 'true');
if (s) n.setAttribute('style', s);
return n;
}
function setup(el) {
var d = el.dataset;
el.classList.add('dnn');
el.setAttribute('role', 'switch');
if (!el.getAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', 'Dark mode');
el.textContent = '';
var day = part('dnn-day'), night = part('dnn-night');
var clouds = [part('dnn-cloud dnn-back', 'left:2.45em;top:.62em'), part('dnn-cloud', 'left:2.15em;top:1.05em')];
var stars = STARS.map(function (s) {
return part('dnn-star', 'left:' + s[0] + 'em;top:' + s[1] + 'em;width:' + s[2] + 'em;height:' + s[2] + 'em;animation-delay:' + (s[0] * -0.9).toFixed(2) + 's');
});
var knob = part('dnn-knob'), sun = part('dnn-sun'), moon = part('dnn-moon');
moon.innerHTML = '<i style="left:.32em;top:.38em;width:.42em;height:.42em"></i><i style="left:.9em;top:.9em;width:.3em;height:.3em"></i><i style="left:.95em;top:.3em;width:.18em;height:.18em"></i>';
knob.appendChild(sun);
knob.appendChild(moon);
[day, night].concat(stars, clouds, [knob]).forEach(function (n) { el.appendChild(n); });
var dark = false;
function travel() { return el.clientWidth - knob.offsetWidth - knob.offsetLeft * 2; }
function target() {
var sel = d.target || DEFAULTS.target;
try { return el.closest(sel) || document.querySelector(sel); } catch (e) { return null; }
}
function set(state, animate) {
dark = state;
el.classList.toggle('dnn-dark', state);
el.setAttribute('aria-checked', state ? 'true' : 'false');
var tg = target();
if (tg) tg.classList.toggle(d['class'] || DEFAULTS.cls, state);
var t = animate && !reduced() ? 1 : 0;
var h = el.clientHeight;
var dur = 700 * t;
// The sun dips as it crosses, like setting, and comes up as the moon.
anime.animate(knob, {
x: state ? travel() : 0, y: t ? [0, h * 0.16, 0] : 0, rotate: state ? 160 : 0,
duration: dur, ease: 'inOut(3)'
});
anime.animate(sun, { opacity: state ? 0 : 1, scale: state ? 0.8 : 1, duration: dur * 0.6, delay: state ? dur * 0.25 : dur * 0.2, ease: 'inOut(2)' });
anime.animate(moon, { opacity: state ? 1 : 0, duration: dur * 0.6, delay: state ? dur * 0.3 : dur * 0.1, ease: 'inOut(2)' });
anime.animate(night, { opacity: state ? 1 : 0, duration: dur, ease: 'inOut(2)' });
anime.animate(clouds, {
y: state ? h * 0.8 : 0, opacity: state ? 0 : 1, duration: dur * 0.7,
delay: anime.stagger(state ? 40 : 70, { start: state ? 0 : dur * 0.35 }), ease: state ? 'in(2)' : 'out(3)'
});
anime.animate(stars, {
opacity: state ? 1 : 0, scale: state ? [0, 1] : 0, duration: state ? 420 * t : 220 * t,
delay: state ? anime.stagger(60, { start: dur * 0.4, from: 'random' }) : 0, ease: state ? 'outBack(3)' : 'in(2)'
});
}
set(d.dark !== undefined && d.dark !== 'false', false);
new ResizeObserver(function () { if (dark) anime.utils.set(knob, { x: travel() }); }).observe(el);
var userTouched = false;
el.addEventListener('click', function (e) {
if (e.isTrusted) userTouched = true;
set(!dark, true);
el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { dark: dark } }));
});
if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
var timer = null;
var loop = function () {
if (userTouched) return;
if (!el.matches(':hover')) set(!dark, true);
timer = setTimeout(loop, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(loop, 900);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-day-night-toggle]').forEach(function (el) {
if (el._dnn) return;
el._dnn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-day-night-toggle aria-label="Dark mode"></button>
<style>
html.dark body { background: #0f172a; color: #e2e8f0; }
</style>