Sun to Moon Theme Toggle with Circular Reveal

A day and night switch with a full demo panel behind it. Press it and the knob springs across, the sun's rays fold away while a mask carves the disc into a crescent, clouds leave and stars pop in. At the same time the dark theme spreads out from the switch in a growing circle until it covers the heading and the dashboard card. Press again and it closes back into the switch. Everything stacks into one column on a phone.

Code

import { useEffect, useId, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

/** Where the reveal circle grows from: the centre of the switch, measured from the top of the section. */
const ORIGIN = "50% 92px";
/** Seconds the circular reveal takes. */
const REVEAL = 1.15;

const week = [
  { day: "M", hours: 0.55 },
  { day: "T", hours: 0.8 },
  { day: "W", hours: 0.45 },
  { day: "T", hours: 0.95 },
  { day: "F", hours: 0.7 },
  { day: "S", hours: 0.3 },
  { day: "S", hours: 0.2 },
];

const themes = {
  light: {
    section: "bg-gradient-to-b from-sky-200 via-sky-50 to-amber-50 text-slate-900",
    eyebrow: "text-sky-700",
    body: "text-slate-600",
    card: "border-white bg-white/90 shadow-xl shadow-sky-900/10",
    tile: "bg-sky-50",
    muted: "text-slate-600",
    chip: "bg-amber-100 text-amber-900",
    avatar: "bg-amber-400 text-amber-950",
    bar: "bg-sky-200",
    barOn: "bg-sky-500",
    track: "bg-sky-100",
    fill: "bg-amber-400",
    mode: "Light mode",
    greeting: "Good morning, Imara",
    time: "08:14",
    next: "Stand-up",
    nextAt: "Starts 10:30",
  },
  dark: {
    section: "bg-slate-950 text-slate-50",
    eyebrow: "text-indigo-300",
    body: "text-slate-300",
    card: "border-white/10 bg-slate-900/90 shadow-2xl shadow-black/60",
    tile: "bg-white/5",
    muted: "text-slate-400",
    chip: "bg-indigo-400/20 text-indigo-100",
    avatar: "bg-indigo-400 text-indigo-950",
    bar: "bg-white/10",
    barOn: "bg-indigo-400",
    track: "bg-white/10",
    fill: "bg-indigo-400",
    mode: "Dark mode",
    greeting: "Good evening, Imara",
    time: "21:47",
    next: "Wind down",
    nextAt: "Starts 22:30",
  },
};

const stars = [
  [8, 18], [17, 62], [26, 30], [38, 78], [61, 70], [72, 24], [83, 55], [92, 14], [12, 84], [88, 86], [47, 8], [66, 44],
];

function Scene({ mode }: { mode: "light" | "dark" }) {
  const t = themes[mode];
  return (
    <div className={`relative h-full px-5 pb-14 pt-16 sm:pb-20 ${t.section}`}>
      {mode === "dark" && (
        <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ backgroundImage: "radial-gradient(70% 55% at 50% 0%, rgba(99,102,241,0.35), transparent 70%)" }}>
          {stars.map(([x, y], i) => (
            <span key={i} className="absolute rounded-full bg-white" style={{ left: `${x}%`, top: `${y}%`, width: i % 3 === 0 ? 3 : 2, height: i % 3 === 0 ? 3 : 2, opacity: 0.35 + (i % 4) * 0.15 }} />
          ))}
        </div>
      )}
      <div className="relative mx-auto max-w-2xl text-center">
        <div className="h-14" />
        <p className={`mt-4 text-xs font-semibold uppercase tracking-[0.2em] ${t.eyebrow}`}>{t.mode}</p>
        <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-5xl">Easy on the eyes, any hour</h2>
        <p className={`mx-auto mt-4 max-w-md ${t.body}`}>One switch repaints the whole panel. The new theme opens out from the toggle like a lens.</p>
      </div>
      <div className={`relative mx-auto mt-10 max-w-xl rounded-3xl border p-5 text-left sm:p-6 ${t.card}`}>
        <div className="flex items-center gap-3">
          <span className={`flex h-10 w-10 flex-none items-center justify-center rounded-full text-sm font-bold ${t.avatar}`}>IO</span>
          <div className="min-w-0 flex-1">
            <p className="truncate font-semibold">{t.greeting}</p>
            <p className={`text-sm ${t.muted}`}>Thursday, 12 tasks planned</p>
          </div>
          <span className={`rounded-full px-3 py-1 font-mono text-xs font-semibold ${t.chip}`}>{t.time}</span>
        </div>
        <div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
          <div className={`rounded-2xl p-4 ${t.tile}`}>
            <p className={`text-xs ${t.muted}`}>Focus time</p>
            <p className="mt-1 text-xl font-semibold tracking-tight">3h 20m</p>
            <div className={`mt-3 h-1.5 rounded-full ${t.track}`}>
              <div className={`h-full w-2/3 rounded-full ${t.fill}`} />
            </div>
          </div>
          <div className={`rounded-2xl p-4 ${t.tile}`}>
            <p className={`text-xs ${t.muted}`}>Next up</p>
            <p className="mt-1 truncate text-xl font-semibold tracking-tight">{t.next}</p>
            <p className={`mt-2 text-xs ${t.muted}`}>{t.nextAt}</p>
          </div>
          <div className={`col-span-2 rounded-2xl p-4 sm:col-span-1 ${t.tile}`}>
            <p className={`text-xs ${t.muted}`}>This week</p>
            <div className="mt-2 flex h-14 items-end gap-1.5">
              {week.map((d, i) => (
                <div key={i} className="flex h-full flex-1 flex-col items-center justify-end gap-1">
                  <div className={`w-full rounded-sm ${i === 3 ? t.barOn : t.bar}`} style={{ height: `${d.hours * 100}%` }} />
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

export default function SunToMoonThemeToggleWithCircularReveal() {
  const reduce = useReducedMotion();
  const maskId = useId();
  const ref = useRef<HTMLElement>(null);
  const inView = useInView(ref, { amount: 0.3 });
  const [dark, setDark] = useState(false);
  const [auto, setAuto] = useState(true);
  const first = useRef(true);

  // Demo: flips on its own until someone uses the switch.
  useEffect(() => {
    if (!auto || reduce || !inView) return;
    const id = window.setTimeout(() => {
      first.current = false;
      setDark((d) => !d);
    }, first.current ? 1100 : 3600);
    return () => window.clearTimeout(id);
  }, [auto, dark, inView, reduce]);

  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 320, damping: 26 };
  const ease = reduce ? { duration: 0 } : { duration: 0.5, ease: [0.4, 0, 0.2, 1] as const };

  return (
    <section ref={ref} className="relative overflow-hidden">
      <Scene mode="light" />
      <motion.div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        initial={false}
        animate={{ clipPath: dark ? `circle(150% at ${ORIGIN})` : `circle(0% at ${ORIGIN})` }}
        transition={reduce ? { duration: 0 } : { duration: REVEAL, ease: [0.65, 0, 0.35, 1] }}
      >
        <Scene mode="dark" />
      </motion.div>

      <div className="absolute left-1/2 top-16 -translate-x-1/2 sm:scale-125">
        <button
          type="button"
          role="switch"
          aria-checked={dark}
          aria-label="Dark mode"
          onClick={() => {
            setAuto(false);
            setDark((d) => !d);
          }}
          className="relative block h-14 w-28 overflow-hidden rounded-full shadow-lg shadow-slate-900/30 ring-1 ring-white/30 focus:outline-none focus-visible:ring-4 focus-visible:ring-amber-400"
        >
          <motion.span className="absolute inset-0 bg-gradient-to-r from-sky-400 to-sky-300" initial={false} animate={{ opacity: dark ? 0 : 1 }} transition={ease} />
          <motion.span className="absolute inset-0 bg-gradient-to-r from-indigo-700 to-indigo-950" initial={false} animate={{ opacity: dark ? 1 : 0 }} transition={ease} />
          {/* Clouds leave, stars arrive */}
          <motion.span aria-hidden="true" className="absolute right-3 top-3 h-3 w-8 rounded-full bg-white/90" initial={false} animate={{ x: dark ? 40 : 0, opacity: dark ? 0 : 1 }} transition={ease} />
          <motion.span aria-hidden="true" className="absolute bottom-3 right-7 h-2.5 w-6 rounded-full bg-white/70" initial={false} animate={{ x: dark ? 56 : 0, opacity: dark ? 0 : 1 }} transition={ease} />
          {[[18, 14, 3], [34, 30, 2], [22, 40, 2], [46, 16, 2]].map(([x, y, s], i) => (
            <motion.span
              key={i}
              aria-hidden="true"
              className="absolute rounded-full bg-white"
              style={{ left: x, top: y, width: s, height: s }}
              initial={false}
              animate={{ opacity: dark ? 1 : 0, scale: dark ? 1 : 0 }}
              transition={reduce ? { duration: 0 } : { delay: dark ? 0.15 + i * 0.07 : 0, type: "spring", stiffness: 400, damping: 18 }}
            />
          ))}
          <motion.span
            className="absolute left-1.5 top-1.5 flex h-11 w-11 items-center justify-center rounded-full shadow-md"
            initial={false}
            animate={{ x: dark ? 56 : 0, backgroundColor: dark ? "#1e1b4b" : "#ffffff" }}
            transition={spring}
          >
            <motion.svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true" initial={false} animate={{ rotate: dark ? 360 : 0 }} transition={spring}>
              <mask id={maskId}>
                <rect width="24" height="24" fill="white" />
                <motion.circle r="7.5" fill="black" initial={false} animate={{ cx: dark ? 17 : 30, cy: dark ? 7.5 : 0 }} transition={spring} />
              </mask>
              <motion.circle cx="12" cy="12" mask={`url(#${maskId})`} initial={false} animate={{ r: dark ? 9 : 5, fill: dark ? "#fde68a" : "#f59e0b" }} transition={spring} />
              <motion.g stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" style={{ transformOrigin: "12px 12px" }} initial={false} animate={{ scale: dark ? 0 : 1, rotate: dark ? 90 : 0, opacity: dark ? 0 : 1 }} transition={spring}>
                <path d="M12 2v2.2M12 19.8V22M2 12h2.2M19.8 12H22M4.9 4.9l1.6 1.6M17.5 17.5l1.6 1.6M4.9 19.1l1.6-1.6M17.5 6.5l1.6-1.6" />
              </motion.g>
            </motion.svg>
          </motion.span>
        </button>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/sun-to-moon-theme-toggle-with-circular-reveal.json

When to use it

How to customize it

Accessibility

Questions

How does the circular reveal work?

The panel is rendered twice, once per theme, with the dark copy laid exactly over the light one. The dark copy has a `clip-path: circle()` centred on the switch. Animating the radius from 0% to 150% uncovers it from the switch outwards, and animating it back hides it again.

How does the sun turn into a moon?

The icon is one SVG circle with a mask. A second circle inside the mask slides over it to cut out a crescent, while the main circle grows and the rays scale down to nothing and rotate. Each change runs on the same spring, so it reads as one shape changing.

Is rendering the panel twice slow?

For a card or a hero it is not noticeable, and `clip-path` is cheap for the browser to animate. For a whole application, keep the reveal on a smaller area or use the View Transitions API with the same circle idea.

How do I remember the choice?

Store it wherever your site keeps preferences, then pass the stored value into `useState` as the starting value. Read `prefers-color-scheme` for a first visit.

Can I use only the switch?

Yes. Copy the `button` and the `spring` and `ease` settings. It is self-contained apart from the `dark` state.

Related: Segmented Control with Spring Pill and Morphing Views, Settings Panel with Toggle Switches and Save Bar, Pricing Table with Monthly and Yearly Toggle.

More toggles and switches or all free UI components. Start for free to have a full site built around it.

More in Toggles

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap