All tools

Free code

Haunted theme toggle

A dark mode switch that turns a sunny house into a haunted one

Flip it and the sun sets, the windows flicker on, a ghost drifts out of the chimney, bats fly past the moon and fog rolls in. Flip it back and the sun comes up. It's a real theme toggle: it switches your page's colors through CSS variables, follows the visitor's system setting, remembers their choice without a flash of the wrong theme, works from the keyboard and calms down for reduced motion. Restyle it with CSS variables and use it in a navbar or as big as you like. Made for Hackoween day 1. Plain HTML, CSS and JavaScript, no libraries.

Open the demo full screen

Free to use in personal and client work. No credit needed. Put the three files in one folder and open index.html, or copy them into your project below.

Want all of them, plus React versions? Join the Motion Pack waitlist.

index.html
<!doctype html>
<html lang="en" data-theme="light">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="color-scheme" content="light dark" />
    <title>Haunted theme toggle</title>
    <!-- Sets the theme before the page paints, so a dark mode visitor never sees a flash of the light one. Keep it inline, in the head. -->
    <script>
      (function () {
        var saved = null;
        try { saved = localStorage.getItem("theme"); } catch (e) {}
        var dark = saved ? saved === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
        document.documentElement.dataset.theme = dark ? "dark" : "light";
      })();
    </script>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="ambient" aria-hidden="true"></div>

    <header class="nav">
      <a class="brand" href="#">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 11.5 12 4l8 7.5V20H4Z" /><rect x="10" y="14" width="4" height="6" rx="1" /></svg>
        Hollow
      </a>
      <nav>
        <a href="#">Docs</a>
        <a href="#">Components</a>
        <a href="#">Blog</a>
      </nav>
      <button class="haunt" type="button" role="switch" aria-checked="false" aria-label="Dark mode"></button>
    </header>

    <main class="hero">
      <button class="haunt haunt-big" id="theme-toggle" type="button" role="switch" aria-checked="false"></button>
      <p class="state">
        <label for="theme-toggle">Dark mode</label>
        <code>&lt;html data-theme="<b class="theme-name">light</b>"&gt;</code>
      </p>

      <article class="post">
        <p class="tag">Changelog</p>
        <h2>v2.13, the spooky one</h2>
        <p>Fixed thirteen bugs. Added one ghost. Your theme is saved for next time.</p>
        <a class="read" href="#">Read more</a>
      </article>
    </main>

    <script src="script.js"></script>
  </body>
</html>
style.css
/* The page theme. The toggle sets data-theme on <html>; everything else reads these.
   Registering them as colors lets the whole page fade between themes at once. */
@property --bg { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --text { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --muted { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --card { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --line { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --accent { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --glow { syntax: "<color>"; inherits: true; initial-value: transparent; }

:root {
  --bg: #f4f0ea;
  --text: #1d1b24;
  --muted: rgba(29, 27, 36, 0.58);
  --card: rgba(255, 255, 255, 0.82);
  --line: rgba(29, 27, 36, 0.1);
  --accent: #6d55f0;
  --glow: rgba(255, 214, 140, 0.55);
  --fade: 0.9s ease-in-out 0.25s;
  color-scheme: light;
  transition: --bg var(--fade), --text var(--fade), --muted var(--fade), --card var(--fade), --line var(--fade), --accent var(--fade), --glow var(--fade);
}
:root[data-theme="dark"] {
  --bg: #110e24;
  --text: #ece8fb;
  --muted: rgba(236, 232, 251, 0.6);
  --card: rgba(34, 28, 66, 0.72);
  --line: rgba(236, 232, 251, 0.1);
  --accent: #ffb557;
  --glow: rgba(124, 92, 255, 0.4);
  --fade: 1.1s ease-in-out 0.5s; /* the page darkens along with the toggle's sky */
  color-scheme: dark;
}

/* The toggle's own colors. Change any of them, per toggle or for all. */
.haunt {
  --day-top: #6fc0f4;
  --day-bottom: #d4f0ff;
  --dusk-top: #6a5fc9;
  --dusk-bottom: #ffa47c;
  --night-top: #0d0a2a;
  --night-bottom: #4a3283;
  --sun: #ffcf4a;
  --moon: #fff4d8;
  --hill-day: #a6dba0;
  --hill-night: #2a2357;
  --ground-day: #7cc56f;
  --ground-night: #1b163b;
  --wall-day: #fde6c6;
  --wall-night: #3a3168;
  --roof-day: #e0705a;
  --roof-night: #241c49;
  --trim-day: #fffaf1;
  --trim-night: #2a2250;
  --glass-day: #b2e0ff;
  --glass-night: #171236;
  --tree-day: #64b768;
  --bark-day: #93603f;
  --bark-night: #17122f;
  --lamp: #ffcf6e;
  --ghost: #fbf8ff;
  --ink: #2a2350;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100dvh;
  overflow-x: hidden;
  background: var(--bg);
  color: var(--text);
  font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}

.ambient {
  position: fixed;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(50% 42% at 50% 42%, var(--glow), transparent 72%);
}

/* ---------- The toggle ---------- */

.haunt {
  --size: 64px;
  position: relative;
  flex: none;
  width: var(--size);
  aspect-ratio: 20 / 9;
  padding: 0;
  border: 0;
  border-radius: 999px;
  overflow: hidden;
  background: var(--day-top);
  cursor: pointer;
  isolation: isolate;
  container: haunt / inline-size;
  -webkit-tap-highlight-color: transparent;
}
.haunt svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
/* A soft inner edge, so it reads as a switch set into the page. */
.haunt::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 1px 2px rgba(10, 8, 30, 0.28), inset 0 0 0 1px rgba(10, 8, 30, 0.12);
  pointer-events: none;
}
.haunt:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.haunt-big {
  --size: min(480px, 86vw);
  box-shadow: 0 30px 70px -34px rgba(30, 20, 70, 0.55);
}
.haunt-big::after { box-shadow: inset 0 2px 6px rgba(10, 8, 30, 0.28), inset 0 0 0 1.5px rgba(10, 8, 30, 0.1); }
:root[data-theme="dark"] .haunt { box-shadow: 0 0 0 1px rgba(200, 185, 255, 0.16); }
:root[data-theme="dark"] .haunt-big { box-shadow: 0 0 0 1px rgba(200, 185, 255, 0.14), 0 30px 80px -30px rgba(124, 92, 255, 0.6); }

/* Colors that cross from day to night as script.js moves --n from 0 to 1. */
.haunt .hill { fill: color-mix(in oklab, var(--hill-day), var(--hill-night) calc(var(--n, 0) * 100%)); }
.haunt .ground { fill: color-mix(in oklab, var(--ground-day), var(--ground-night) calc(var(--n, 0) * 100%)); }
.haunt .path { fill: color-mix(in oklab, #ecd9b0, #2c2456 calc(var(--n, 0) * 100%)); }
.haunt .wall { fill: color-mix(in oklab, var(--wall-day), var(--wall-night) calc(var(--n, 0) * 100%)); }
.haunt .shade { fill: color-mix(in oklab, #f2cfa6, #2d2558 calc(var(--n, 0) * 100%)); }
.haunt .roof { fill: color-mix(in oklab, var(--roof-day), var(--roof-night) calc(var(--n, 0) * 100%)); }
.haunt .roof-edge { fill: color-mix(in oklab, #c25845, #1a1438 calc(var(--n, 0) * 100%)); }
.haunt .brick { fill: color-mix(in oklab, #b9604c, #2b2350 calc(var(--n, 0) * 100%)); }
.haunt .trim { fill: color-mix(in oklab, var(--trim-day), var(--trim-night) calc(var(--n, 0) * 100%)); }
.haunt .glass { fill: color-mix(in oklab, var(--glass-day), var(--glass-night) calc(var(--n, 0) * 100%)); }
.haunt .shine { fill: #fff; opacity: calc((1 - var(--n, 0)) * 0.5); }
.haunt .rim { fill: color-mix(in oklab, var(--moon), #a99cff 40%); opacity: calc(var(--n, 0) * 0.5); }
.haunt .cat { fill: #1b1433; }
.haunt .door { fill: color-mix(in oklab, #9d5a3e, #211941 calc(var(--n, 0) * 100%)); }
.haunt .stone { fill: color-mix(in oklab, #d9c3a3, #262050 calc(var(--n, 0) * 100%)); }
.haunt .bark { fill: color-mix(in oklab, var(--bark-day), var(--bark-night) calc(var(--n, 0) * 100%)); }
.haunt .twig { fill: none; stroke: var(--bark-night); stroke-linecap: round; }
.haunt .leaf { fill: var(--tree-day); }
.haunt .leaf-hi { fill: color-mix(in oklab, var(--tree-day), #fff 22%); }
.haunt .bush { fill: color-mix(in oklab, var(--tree-day), #2f8a45 25%); }
.haunt .grave { fill: color-mix(in oklab, #8f88bd, var(--hill-night) 30%); }
.haunt .lit { fill: var(--lamp); }
.haunt .pumpkin { fill: color-mix(in oklab, #f39233, #c4612a calc(var(--n, 0) * 60%)); }
.haunt .cloud { fill: #fff; }
.haunt .star { fill: #fff6d6; }
.haunt .sun { fill: var(--sun); }
.haunt .sun-hi { fill: color-mix(in oklab, var(--sun), #fff 45%); }
.haunt .moon { fill: var(--moon); }
.haunt .crater { fill: color-mix(in oklab, var(--moon), #b9a87a 30%); }
.haunt .ghost { fill: var(--ghost); }
.haunt .ink { fill: var(--ink); }
.haunt .blush { fill: #ff9fc0; }
.haunt .bat { fill: #0b0820; }
.haunt .s-day-top { stop-color: var(--day-top); }
.haunt .s-day-bottom { stop-color: var(--day-bottom); }
.haunt .s-dusk-top { stop-color: var(--dusk-top); }
.haunt .s-dusk-bottom { stop-color: var(--dusk-bottom); }
.haunt .s-night-top { stop-color: var(--night-top); }
.haunt .s-night-bottom { stop-color: var(--night-bottom); }
.haunt .s-lamp { stop-color: var(--lamp); }
.haunt .s-sun { stop-color: var(--sun); }
.haunt .s-moon { stop-color: var(--moon); }
.haunt .s-ghost { stop-color: var(--ghost); }

/* Navbar size: drop the details too small to see. */
@container haunt (max-width: 120px) {
  .haunt .detail { display: none; }
}

/* ---------- The demo page ---------- */

.nav {
  position: relative;
  display: flex;
  align-items: center;
  gap: 28px;
  max-width: 960px;
  margin: 0 auto;
  padding: 16px 24px;
  border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 8px; color: inherit; font-weight: 600; letter-spacing: -0.01em; text-decoration: none; }
.brand svg { width: 20px; height: 20px; fill: var(--accent); }
.brand rect { fill: var(--bg); }
.nav nav { display: flex; gap: 20px; margin-right: auto; }
.nav nav a { color: var(--muted); font-size: 14px; text-decoration: none; }
.nav nav a:hover { color: var(--text); }

.hero {
  position: relative;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 18px;
  min-height: calc(100dvh - 72px);
  padding: 28px 20px 40px;
}

.state { display: flex; align-items: center; gap: 12px; margin: 0; color: var(--muted); font-size: 14px; }
.state label { color: var(--text); font-weight: 500; cursor: pointer; }
.state code {
  padding: 4px 9px;
  border: 1px solid var(--line);
  border-radius: 8px;
  font: 12px/1.4 ui-monospace, "SF Mono", Menlo, monospace;
}
.state b { color: var(--accent); font-weight: 500; }

.post {
  width: min(380px, 100%);
  margin-top: 10px;
  padding: 18px 20px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: var(--card);
  box-shadow: 0 24px 50px -34px rgba(30, 20, 70, 0.45);
}
.post .tag { margin: 0; color: var(--accent); font-size: 12px; font-weight: 500; }
.post h2 { margin: 2px 0 4px; font-size: 18px; font-weight: 500; letter-spacing: -0.01em; }
.post p:not(.tag) { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
.read { color: var(--text); font-size: 14px; text-underline-offset: 3px; }

@media (max-width: 560px) {
  .nav { gap: 16px; padding: 14px 16px; }
  .nav nav { gap: 14px; }
  .nav nav a:nth-child(n + 2) { display: none; }
  .state { flex-direction: column; gap: 6px; }
}

@media (prefers-reduced-motion: reduce) {
  :root { transition: none; }
}
script.js
// Haunted theme toggle: a real light and dark mode switch. Flip it and the
// little house goes from a sunny afternoon to a haunted night: the sun sets,
// the windows flicker on, a ghost drifts out of the chimney and bats fly past
// the moon. The page itself switches too, through data-theme on <html>.
// Put as many <button class="haunt" role="switch"> on the page as you like.

const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = document.documentElement;
const toggles = [...document.querySelectorAll(".haunt")];
const labels = document.querySelectorAll(".theme-name");

/* ---------- Light or dark ---------- */

// The inline script in <head> already picked one before the first paint:
// your last choice if there is one, otherwise what your system prefers.
const KEY = "theme";
const system = matchMedia("(prefers-color-scheme: dark)");
const saved = () => {
  try { return localStorage.getItem(KEY); } catch { return null; }
};

function setTheme(theme, save = false) {
  root.dataset.theme = theme;
  for (const el of toggles) el.setAttribute("aria-checked", theme === "dark");
  for (const el of labels) el.textContent = theme;
  if (save) try { localStorage.setItem(KEY, theme); } catch {}
}

const isDark = () => root.dataset.theme === "dark";
const flip = () => setTheme(isDark() ? "light" : "dark", true);
for (const el of toggles) el.addEventListener("click", flip);

// Follow the system until you choose, and keep other open tabs in step.
system.addEventListener("change", (event) => {
  if (!saved()) setTheme(event.matches ? "dark" : "light");
});
addEventListener("storage", (event) => {
  if (event.key === KEY && event.newValue) setTheme(event.newValue);
});
setTheme(isDark() ? "dark" : "light");

/* ---------- The house ---------- */

const DARKEN = 2.4; // seconds from sunny to haunted
const BRIGHTEN = 1.7;
const LIGHTS = [0.56, 0.62, 0.68, 0.73, 0.77, 0.83, 0.88]; // when each window comes on: attic, four windows, door, pumpkin
const FLICKER = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1];

const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const span = (v, a, b) => clamp((v - a) / (b - a));
const mix = (a, b, k) => a + (b - a) * k;
const smooth = (v) => v * v * (3 - 2 * v);
const inCubic = (v) => v ** 3;
const outCubic = (v) => 1 - (1 - v) ** 3;
const outBack = (v) => 1 + 2.6 * (v - 1) ** 3 + 1.6 * (v - 1) ** 2;

let night = isDark() ? 1 : 0; // 0 is day, 1 is night. Eases toward the theme.
let last = null;
let creak = null;
let batsAt = null;

const scenes = toggles.map((el, i) => {
  el.innerHTML = drawing(`haunt-${i}`);
  const one = (name) => el.querySelector(`[data-part="${name}"]`);
  const all = (name) => [...el.querySelectorAll(`[data-${name}]`)];
  const scene = {
    el, over: false, down: false, hover: 0, press: 0, seen: true,
    ...Object.fromEntries([...el.querySelectorAll("[data-part]")].map((part) => [part.dataset.part, part])),
    stars: all("star"), lit: all("lit"), glows: all("glow"), fogs: all("fog"), bats: all("bat"), clouds: all("cloud"),
    eyes: one("eyes"),
  };
  el.addEventListener("pointerenter", () => (scene.over = true));
  el.addEventListener("pointerleave", () => (scene.over = scene.down = false));
  el.addEventListener("pointerdown", () => (scene.down = true));
  addEventListener("pointerup", () => (scene.down = false));
  el.addEventListener("keydown", (event) => event.key === " " && (scene.down = true));
  el.addEventListener("keyup", () => (scene.down = false));
  return scene;
});

// Toggles scrolled out of view stop drawing.
const watch = new IntersectionObserver((entries) => {
  for (const entry of entries) scenes.find((s) => s.el === entry.target).seen = entry.isIntersecting;
});
for (const s of scenes) watch.observe(s.el);

function frame(time) {
  requestAnimationFrame(frame);
  const dt = last === null ? 0 : Math.min(0.05, (time - last) / 1000);
  last = time;
  const t = time / 1000;
  const before = night;
  night = still ? Number(isDark()) : clamp(night + (isDark() ? dt / DARKEN : -dt / BRIGHTEN));

  // The house creaks as the dark comes in and settles when the sun is back.
  if (before < 0.64 && night >= 0.64) creak = { at: t, size: 1 };
  if (before > 0.5 && night <= 0.5) creak = { at: t, size: -0.45 };
  if ((before < 0.8 && night >= 0.8) || (batsAt === null && night === 1)) batsAt = t - (before === night ? 6 : 0);

  for (const s of scenes) if (s.seen) paint(s, t, dt);
}

function paint(s, t, dt) {
  const p = night;
  const live = still ? 0 : 1;
  s.hover = still ? 0 : mix(s.hover, s.over ? 1 : 0, 1 - Math.exp(-dt * 14));
  s.press = still ? 0 : mix(s.press, s.down ? 1 : 0, 1 - Math.exp(-dt * 26));
  const knob = 1 + s.hover * 0.07 - s.press * 0.1;
  s.el.style.setProperty("--n", smooth(span(p, 0.2, 0.75)).toFixed(3));

  // The sky goes through a pink dusk on its way to night.
  s.dusk.style.opacity = Math.sin(Math.PI * span(p, 0.04, 0.78)) * 0.95;
  s.night.style.opacity = smooth(span(p, 0.3, 0.82));
  s.stars.forEach((el, i) => (el.style.opacity = span(p, 0.5, 0.92) * (0.6 + 0.4 * live * Math.sin(t * (1.3 + (i % 4) * 0.5) + i * 2.1))));

  // The sun sets on the left and the moon comes up on the right, like a switch's knob.
  const set = inCubic(span(p, 0, 0.42));
  s.sun.setAttribute("transform", `translate(${44 + set * 12} ${44 + set * 74}) scale(${knob})`);
  s.rays.setAttribute("transform", `rotate(${t * 9 * live})`);
  s.rays.style.opacity = 1 - span(p, 0.04, 0.26);
  const rise = span(p, 0.42, 0.86);
  s.moon.setAttribute("transform", `translate(${156 - (1 - rise) * 10} ${118 - outBack(rise) * 82}) scale(${knob})`);

  const clear = span(p, 0.06, 0.4);
  s.clouds.forEach((el, i) => {
    el.style.opacity = 1 - clear;
    el.setAttribute("transform", `translate(${(i ? -1 : 1) * clear * 16 + Math.sin(t * 0.21 + i * 3) * 3 * live} 0)`);
  });

  // The tree drops its leaves, the bush sinks and two little graves come up.
  s.leaves.style.opacity = 1 - span(p, 0.25, 0.6);
  s.leaves.setAttribute("transform", `rotate(${Math.sin(t * 1.3) * 1.2 * live} 159 80)`);
  s.twigs.style.opacity = span(p, 0.32, 0.66);
  const sink = span(p, 0.28, 0.55);
  s.bush.setAttribute("transform", `translate(0 ${sink * 9})`);
  s.graves.setAttribute("transform", `translate(0 ${(1 - outBack(span(p, 0.55, 0.86))) * 11})`);

  // Windows flicker on one by one, then glow like candles.
  s.lit.forEach((el) => {
    const i = Number(el.dataset.lit);
    const k = (p - LIGHTS[i]) / 0.11;
    const on = k <= 0 ? 0 : k >= 1 ? 1 : FLICKER[Math.floor(k * FLICKER.length)] * (0.5 + 0.5 * k);
    const glow = on * (1 - live * 0.16 * (0.5 + 0.5 * Math.sin(t * 6.3 + i * 2.4) * Math.sin(t * 2.7 + i)));
    el.style.opacity = glow;
    s.glows[i].style.opacity = glow;
    if (i === 4) s.cat.style.opacity = on;
  });

  // The house leans and creaks, and its door swings open a little.
  const age = creak ? t - creak.at : 99;
  const wobble = creak && live ? creak.size * 2.6 * Math.exp(-age * 3.4) * Math.sin(age * 20) : 0;
  s.house.setAttribute("transform", `rotate(${smooth(span(p, 0.6, 0.95)) * 1.7 + wobble} 100 80)`);
  const open = outBack(span(p, 0.85, 1)) * 0.5;
  s.door.setAttribute("transform", `translate(95 0) scale(${1 - open} 1) translate(-95 0)`);

  // The ghost rises out of the chimney, stretching, then floats over to the left and bobs.
  const g = span(p, 0.7, 1);
  const up = outCubic(span(g, 0, 0.55));
  const glide = smooth(span(g, 0.35, 1));
  const hop = s.hover * 2.2 * Math.abs(Math.sin(t * 5)) * live;
  const gx = mix(114.5, 84 + Math.sin(t * 0.7) * 4.5 * live, glide);
  const gy = mix(31 - up * 19, 15 + Math.sin(t * 1.8) * 1.6 * live - hop, glide);
  const stretch = Math.sin(Math.PI * span(g, 0, 0.62)) * 0.24 * live;
  const size = 0.3 + 0.7 * outBack(span(g, 0, 0.66));
  const lean = (Math.cos(t * 0.7) * 7 * glide - (1 - glide) * up * 10) * live;
  s.ghost.style.opacity = span(g, 0, 0.12);
  s.ghost.setAttribute("transform", `translate(${gx} ${gy}) rotate(${lean}) scale(${size * (1 - stretch * 0.45)} ${size * (1 + stretch)})`);
  s.hem.setAttribute("d", ghostShape(t * 4 * live));
  const blink = live && t % 3.7 < 0.13 ? 0.12 : 1;
  s.eyes.setAttribute("transform", `translate(0 -2) scale(1 ${blink}) translate(0 2)`);

  // Fog rolls in from both sides.
  const fog = smooth(span(p, 0.42, 1));
  s.fogs.forEach((el, i) => {
    const side = i % 2 ? 1 : -1;
    el.style.opacity = fog * [0.55, 0.75, 0.6, 0.8][i];
    el.setAttribute("transform", `translate(${side * (1 - fog) * 70 + Math.sin(t * 0.3 + i * 2) * 7 * live} 0)`);
  });

  // Bats fly past the moon when night falls, and again every so often.
  s.bats.forEach((el, i) => {
    const k = batsAt === null || !live ? -1 : (((t - batsAt) % 9) - i * 0.24) / 2.5;
    if (k <= 0 || k >= 1 || p < 0.5) return void (el.style.opacity = 0);
    el.style.opacity = span(p, 0.6, 0.85);
    const x = 214 - k * 250;
    const y = [34, 27, 40][i] - k * 20 + Math.sin(k * 13 + i * 2) * 2.6;
    el.setAttribute("transform", `translate(${x} ${y}) scale(${[1, 0.8, 0.68][i]})`);
    const flap = Math.sin(t * 30 + i * 1.7) * 40;
    el.children[0].setAttribute("transform", `rotate(${flap})`);
    el.children[1].setAttribute("transform", `rotate(${-flap})`);
  });

  // Then a shooting star over the moon.
  const c = batsAt === null || !live ? -1 : (((t - batsAt) % 9) - 3.1) / 0.8;
  s.comet.style.opacity = c > 0 && c < 1 ? Math.sin(Math.PI * c) * span(p, 0.8, 1) : 0;
  s.comet.setAttribute("transform", `translate(${mix(116, 180, c)} ${mix(4, 26, c)}) rotate(19)`);
}

// The ghost's body, with a hem that ripples.
function ghostShape(phase) {
  let d = "M-7 3 C-7 -5 -3.8 -9 0 -9 C3.8 -9 7 -5 7 3 L7 7.5";
  for (let i = 0; i < 5; i++) {
    const x = 7 - (i + 1) * 2.8;
    const y = 7.5 + Math.sin(phase + i * 1.4) * 0.7;
    d += ` Q${(x + 1.4).toFixed(2)} ${(y + 3).toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)}`;
  }
  return `${d} Z`;
}

requestAnimationFrame(frame);

/* ---------- The drawing ---------- */

// One per toggle. Ids get a prefix so several toggles can share a page.
function drawing(id) {
  const STARS = [[22, 26, 1.7], [34, 12, 0.8], [52, 20, 0.7], [64, 9, 1.4], [78, 26, 0.8], [92, 8, 0.7], [110, 15, 0.6], [124, 7, 1.1], [136, 17, 1.5], [174, 10, 0.7], [184, 24, 0.9], [46, 36, 0.6], [189, 46, 0.6], [148, 6, 0.6]];
  const star = ([x, y, r], i) =>
    r > 1
      ? `<path class="star" data-star d="M${x} ${y - r * 2}Q${x} ${y} ${x + r * 2} ${y}Q${x} ${y} ${x} ${y + r * 2}Q${x} ${y} ${x - r * 2} ${y}Q${x} ${y} ${x} ${y - r * 2}Z" opacity="0"/>`
      : `<circle class="star${i % 2 ? " detail" : ""}" data-star cx="${x}" cy="${y}" r="${r}" opacity="0"/>`;
  const rays = Array.from({ length: 8 }, (_, i) => `<rect class="sun" x="-1.3" y="-27.5" width="2.6" height="5.5" rx="1.3" transform="rotate(${i * 45})"/>`).join("");

  // [x, y, width, height] for the four windows; the attic, door and pumpkin glow too.
  const WINDOWS = [[81.5, 53.5, 9, 8], [109.5, 53.5, 9, 8], [81.5, 66, 9, 7.5], [109.5, 66, 9, 7.5]];
  const glow = (cx, cy, r, i) => `<circle data-glow="${i}" cx="${cx}" cy="${cy}" r="${r}" fill="url(#${id}-lamp)" opacity="0"/>`;
  const glows =
    glow(100, 37.5, 10, 0) +
    WINDOWS.map(([x, y, w, h], i) => glow(x + w / 2, y + h / 2, 12, i + 1)).join("") +
    glow(100, 72, 12, 5);
  const windows = WINDOWS.map(([x, y, w, h], i) => `
    <rect class="trim" x="${x - 1.1}" y="${y - 1.1}" width="${w + 2.2}" height="${h + 2.2}" rx="1.2"/>
    <rect class="glass" x="${x}" y="${y}" width="${w}" height="${h}" rx="0.4"/>
    <rect class="lit" data-lit="${i + 1}" x="${x}" y="${y}" width="${w}" height="${h}" rx="0.4" opacity="0"/>
    <path class="shine" d="M${x + 1} ${y + h}L${x + w * 0.62} ${y}H${x + w * 0.9}L${x + 3.4} ${y + h}Z"/>
    ${i === 3 ? `<path class="cat" data-part="cat" opacity="0" d="M${x + 2} ${y + h}C${x + 2} ${y + h - 3} ${x + 3} ${y + h - 4.2} ${x + 4.2} ${y + h - 4.2}L${x + 4.2} ${y + h - 5.4}L${x + 4.8} ${y + h - 6.6}L${x + 5.4} ${y + h - 5.6}H${x + 6.2}L${x + 6.8} ${y + h - 6.6}L${x + 7.4} ${y + h - 5.4}V${y + h - 4}C${x + 7.8} ${y + h - 3} ${x + 7.8} ${y + h - 1.4} ${x + 7.4} ${y + h}Z"/>` : ""}
    <rect class="trim" x="${x + w / 2 - 0.5}" y="${y}" width="1" height="${h}"/>
    <rect class="trim" x="${x}" y="${y + h / 2 - 0.5}" width="${w}" height="1"/>
    <rect class="trim" x="${x - 2}" y="${y + h + 0.7}" width="${w + 4}" height="1.5" rx="0.6"/>`).join("");
  const bat = (i) => `<g data-bat="${i}" opacity="0">
    <path class="bat" d="M-0.6 0C-2 -2.6 -4.2 -3.2 -6.4 -2.2C-5.6 -1.4 -5.6 -0.6 -6 0.4C-5 -0.2 -4 0.1 -3.4 0.9C-2.8 0.2 -1.8 0.1 -0.6 0.8Z"/>
    <path class="bat" d="M0.6 0C2 -2.6 4.2 -3.2 6.4 -2.2C5.6 -1.4 5.6 -0.6 6 0.4C5 -0.2 4 0.1 3.4 0.9C2.8 0.2 1.8 0.1 0.6 0.8Z"/>
    <path class="bat" d="M-1 -1.4L-0.8 -2.6L-0.3 -1.8H0.3L0.8 -2.6L1 -1.4C1.3 -0.4 1 1.2 0 1.8C-1 1.2 -1.3 -0.4 -1 -1.4Z"/>
  </g>`;
  const fog = (cx, cy, rx, ry, i) => `<ellipse data-fog="${i}" cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="url(#${id}-fog)" opacity="0"/>`;

  return `<svg viewBox="0 0 200 90" aria-hidden="true" focusable="false">
  <defs>
    <linearGradient id="${id}-day" x2="0" y2="1"><stop class="s-day-top" offset="0"/><stop class="s-day-bottom" offset="1"/></linearGradient>
    <linearGradient id="${id}-dusk" x2="0" y2="1"><stop class="s-dusk-top" offset="0"/><stop class="s-dusk-bottom" offset="0.85"/></linearGradient>
    <linearGradient id="${id}-night" x2="0" y2="1"><stop class="s-night-top" offset="0.1"/><stop class="s-night-bottom" offset="1"/></linearGradient>
    <radialGradient id="${id}-sun"><stop class="s-sun" offset="0.4" stop-opacity="0.7"/><stop class="s-sun" offset="1" stop-opacity="0"/></radialGradient>
    <radialGradient id="${id}-moon"><stop class="s-moon" offset="0.4" stop-opacity="0.42"/><stop class="s-moon" offset="1" stop-opacity="0"/></radialGradient>
    <radialGradient id="${id}-lamp"><stop class="s-lamp" offset="0" stop-opacity="0.75"/><stop class="s-lamp" offset="1" stop-opacity="0"/></radialGradient>
    <radialGradient id="${id}-fog"><stop class="s-ghost" offset="0" stop-opacity="0.7"/><stop class="s-ghost" offset="1" stop-opacity="0"/></radialGradient>
    <linearGradient id="${id}-comet"><stop class="s-moon" offset="0" stop-opacity="0"/><stop class="s-moon" offset="1"/></linearGradient>
    <radialGradient id="${id}-aura"><stop class="s-ghost" offset="0.3" stop-opacity="0.4"/><stop class="s-ghost" offset="1" stop-opacity="0"/></radialGradient>
  </defs>

  <rect width="200" height="90" fill="url(#${id}-day)"/>
  <rect data-part="dusk" width="200" height="90" fill="url(#${id}-dusk)" opacity="0"/>
  <rect data-part="night" width="200" height="90" fill="url(#${id}-night)" opacity="0"/>
  ${STARS.map(star).join("")}
  <g data-part="comet" opacity="0"><rect x="-16" y="-0.45" width="16" height="0.9" rx="0.45" fill="url(#${id}-comet)"/><circle class="star" r="0.8"/></g>

  <g data-part="sun">
    <circle r="36" fill="url(#${id}-sun)"/>
    <g data-part="rays" class="detail">${rays}</g>
    <circle class="sun" r="18"/>
    <circle class="sun-hi" cx="-4" cy="-4.5" r="10" opacity="0.65"/>
    <circle cx="-7.5" cy="-8" r="2.6" fill="#fff" opacity="0.6"/>
  </g>
  <g data-part="moon">
    <circle r="38" fill="url(#${id}-moon)"/>
    <circle class="moon" r="16.5"/>
    <circle class="crater" cx="-5" cy="-4" r="3.8"/>
    <circle class="crater" cx="5.5" cy="4.5" r="2.7"/>
    <circle class="crater detail" cx="4.5" cy="-7.5" r="1.6"/>
    <circle class="crater detail" cx="-4.5" cy="8.5" r="1.5"/>
  </g>
  <g class="cloud" data-cloud><circle cx="128" cy="21" r="6"/><circle cx="135" cy="17" r="7.5"/><circle cx="143" cy="21.5" r="5.5"/><rect x="122" y="21" width="27" height="6" rx="3"/></g>
  <g class="cloud detail" data-cloud><circle cx="70" cy="13" r="4"/><circle cx="75" cy="10.5" r="5"/><circle cx="80.5" cy="13.5" r="3.6"/><rect x="66" y="13" width="18" height="4" rx="2"/></g>

  <path class="hill" d="M0 66C22 58 44 58 66 64C84 69 96 62 116 60C140 57 164 64 200 60V90H0Z"/>
  ${bat(0)}${bat(1)}${bat(2)}

  <g data-part="tree">
    <path class="bark" d="M156.6 81C157.4 74 157.8 66 156.8 57.5H160.4C159.8 66 160.4 74 162.2 81Z"/>
    <g data-part="twigs" class="twig" opacity="0">
      <path stroke-width="1.8" d="M158.4 63C156 57 151.5 54 147 52.5M158.8 60.5C160.5 55 165 52 170.5 50.5M158.6 59C158 53 159 48.5 158 43.5"/>
      <path stroke-width="1.1" d="M147 52.5C145 50.5 144 48.5 144.5 46M152 55C150 51 150.8 48 153 45.5M170.5 50.5C172.5 48.5 173 46.5 172.5 44M164.5 53C165.5 49.5 164.5 46.5 162.5 44.5M158 43.5C156.8 41.5 157.2 39.5 158.4 38"/>
    </g>
    <g data-part="leaves">
      <circle class="leaf" cx="159" cy="49.5" r="10.5"/>
      <circle class="leaf" cx="150.5" cy="56" r="7.5"/>
      <circle class="leaf" cx="167.5" cy="56" r="8"/>
      <circle class="leaf-hi" cx="155" cy="45.5" r="4.6"/>
      <circle class="leaf-hi detail" cx="148" cy="54" r="2.6"/>
    </g>
  </g>
  ${fog(100, 75, 74, 8, 0)}

  <g data-part="graves">
    <path class="grave" d="M53.5 83V74.5a4.2 4.2 0 0 1 8.4 0V83Z"/>
    <path class="grave" d="M65 85V78.2a3.2 3.2 0 0 1 6.4 0V85Z"/>
    <path class="ink detail" opacity="0.35" d="M57.3 73.6h0.8v1.2h1.2v0.8h-1.2v2.6h-0.8v-2.6h-1.2v-0.8h1.2Z"/>
  </g>
  <g data-part="bush"><circle class="bush" cx="56" cy="79.5" r="4.6"/><circle class="bush" cx="62" cy="77.5" r="5.2"/><circle class="bush" cx="67.5" cy="80" r="4"/></g>

  <g data-part="house">
    <g data-part="ghost" opacity="0">
      <circle r="16" fill="url(#${id}-aura)"/>
      <path class="ghost" d="M-6.6 0.5C-9.4 1.4 -9.8 3.6 -9.4 5C-8.4 4 -7.6 3.8 -6.6 4.2Z"/>
      <path class="ghost" d="M6.6 0.5C9.4 1.4 9.8 3.6 9.4 5C8.4 4 7.6 3.8 6.6 4.2Z"/>
      <path class="ghost" data-part="hem"/>
      <g data-part="eyes"><ellipse class="ink" cx="-2.6" cy="-2" rx="1.25" ry="1.7"/><ellipse class="ink" cx="2.6" cy="-2" rx="1.25" ry="1.7"/></g>
      <circle cx="-2.2" cy="-2.7" r="0.45" fill="#fff"/><circle cx="3" cy="-2.7" r="0.45" fill="#fff"/>
      <ellipse class="ink" cx="0" cy="2.3" rx="1.1" ry="1.4"/>
      <circle class="blush" cx="-4.7" cy="0.6" r="1.2" opacity="0.55"/><circle class="blush" cx="4.7" cy="0.6" r="1.2" opacity="0.55"/>
    </g>
    <rect class="brick" x="110.5" y="26" width="8" height="16"/>
    <rect class="roof-edge" x="109" y="24" width="11" height="3" rx="0.9"/>
    <rect class="wall" x="76" y="46" width="48" height="34"/>
    <rect class="shade" x="76" y="50" width="48" height="2.6"/>
    <rect class="ink detail" opacity="0.06" x="76" y="59.5" width="48" height="0.5"/>
    <rect class="ink detail" opacity="0.06" x="76" y="64" width="48" height="0.5"/>
    <rect class="ink detail" opacity="0.06" x="76" y="75" width="48" height="0.5"/>
    <path class="roof" d="M70.4 50.5Q68.2 50.5 69.8 49L98.5 22.4Q100 21 101.5 22.4L130.2 49Q131.8 50.5 129.6 50.5Z"/>
    <rect class="roof-edge detail" opacity="0.4" x="86" y="34" width="28" height="0.9" rx="0.45"/>
    <rect class="roof-edge detail" opacity="0.4" x="78" y="42" width="44" height="0.9" rx="0.45"/>
    <path class="rim" d="M101.5 22.4L130.2 49Q131.8 50.5 129.6 50.5H128.6L100.6 24.4Z"/>
    <rect class="rim" x="122.6" y="51.6" width="1.4" height="27"/>
    <rect class="roof-edge" x="68.5" y="49.2" width="63" height="2.4" rx="1.2"/>
    ${glows}
    <circle class="trim" cx="100" cy="37.5" r="5.2"/>
    <circle class="glass" cx="100" cy="37.5" r="3.9"/>
    <circle class="lit" data-lit="0" cx="100" cy="37.5" r="3.9" opacity="0"/>
    <rect class="trim" x="99.5" y="33.6" width="1" height="7.8"/>
    <rect class="trim" x="96.1" y="37" width="7.8" height="1"/>
    ${windows}
    <path class="trim" d="M93.6 80V68.6a6.4 6.4 0 0 1 12.8 0V80Z"/>
    <path class="ink" d="M95 80V68.6a5 5 0 0 1 10 0V80Z"/>
    <path class="lit" data-lit="5" d="M95 80V68.6a5 5 0 0 1 10 0V80Z" opacity="0"/>
    <g data-part="door">
      <path class="door" d="M95 80V68.6a5 5 0 0 1 10 0V80Z"/>
      <rect class="ink detail" opacity="0.14" x="96.6" y="68.5" width="2.6" height="4.8" rx="0.6"/>
      <rect class="ink detail" opacity="0.14" x="100.8" y="68.5" width="2.6" height="4.8" rx="0.6"/>
      <circle class="sun" cx="103.2" cy="75" r="0.8"/>
    </g>
    <rect class="stone" x="75" y="78.6" width="50" height="2.4" rx="0.4"/>
    <rect class="stone" x="92.5" y="79.4" width="15" height="2.4" rx="0.7"/>
  </g>

  <path class="ground" d="M0 79C30 76.5 60 78.5 100 80.5C140 82 170 77 200 78.5V90H0Z"/>
  <path class="path" d="M94.6 81.6H105.4L111 90H89Z"/>
  <g transform="translate(87.6 80.2)">
    <circle data-glow="6" r="8" fill="url(#${id}-lamp)" opacity="0"/>
    <ellipse class="pumpkin" rx="3.9" ry="3"/>
    <ellipse class="roof-edge" rx="1.5" ry="3" opacity="0.3"/>
    <path class="bark" d="M-0.5 -2.6Q-0.3 -4 0.9 -4.4L1.2 -3.9Q0.4 -3.5 0.4 -2.6Z"/>
    <path class="lit" data-lit="6" opacity="0" d="M-2.3 -0.7L-1.4 -1.8L-0.5 -0.7ZM0.5 -0.7L1.4 -1.8L2.3 -0.7ZM-2.5 0.6H2.5L1.9 1.8L1.3 1.2L0.6 1.9L0 1.2L-0.6 1.9L-1.3 1.2L-1.9 1.8Z"/>
  </g>
  ${fog(34, 85, 52, 8.5, 1)}${fog(160, 86, 58, 8.5, 2)}${fog(96, 89, 46, 6.5, 3)}
</svg>`;
}