Skip to content

Theme Toggle Sun Moon Morph

A dark-mode switch whose thumb morphs from a sun into a crescent moon through a real SVG mask, while the track shifts from drifting clouds to twinkling stars. Controlled or uncontrolled, no dependencies.

DOM + CSS0 deps1 file · 12 KB TSXreduced-motion aware

Freeplayfulminimal
Preview

Props

160
Start in dark
Disabled
No runtime dependencies
"use client";

import { useId, useState } from "react";
import type { ButtonHTMLAttributes, CSSProperties, MouseEvent } from "react";

export interface ThemeToggleSunMoonMorphProps
  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "role" | "children" | "type"> {
  /** Controlled state. true = dark (moon). */
  checked?: boolean;
  /** Initial state when uncontrolled. */
  defaultChecked?: boolean;
  /** Fires with the new dark boolean. */
  onChange?: (dark: boolean) => void;
  /** Height of the pill in px. The pill is 1.78x as wide. */
  size?: number;
  /** Accessible name. */
  label?: string;
}

const TSM_CSS = [
  ".tsm{position:relative;display:inline-block;flex:none;padding:0;margin:0;border:0;background:none;cursor:pointer;border-radius:9999px;line-height:1;-webkit-tap-highlight-color:transparent;touch-action:manipulation;--tsm-x:0em;--tsm-s:1}",
  ".tsm[data-dark='true']{--tsm-x:.78em}",
  ".tsm[data-press='true']{--tsm-s:.9}",
  ".tsm:disabled{cursor:not-allowed;opacity:.5;filter:saturate(.6)}",
  ".tsm:focus-visible{outline:2px solid #4d8dff;outline-offset:3px}",
  ".tsm-track{position:absolute;inset:0;overflow:hidden;border-radius:9999px;box-shadow:inset 0 .04em .1em rgba(0,0,0,.32),0 0 0 1px rgba(127,127,127,.38)}",
  ".tsm-sky{position:absolute;inset:0;transition:opacity 700ms ease}",
  ".tsm-sky-day{background:linear-gradient(180deg,#4fb2ff 0%,#9fd9ff 100%)}",
  ".tsm-sky-night{opacity:0;background:linear-gradient(180deg,#04061a 0%,#1b2457 100%)}",
  ".tsm[data-dark='true'] .tsm-sky-night{opacity:1}",
  ".tsm-clouds{position:absolute;inset:0;transition:transform 720ms cubic-bezier(.5,0,.25,1),opacity 480ms ease}",
  ".tsm[data-dark='true'] .tsm-clouds{transform:translateY(.55em);opacity:0}",
  ".tsm-cloud{position:absolute;display:block;animation:tsm-drift 7s ease-in-out infinite alternate}",
  ".tsm-stars{position:absolute;inset:0}",
  ".tsm-star{position:absolute;display:block;transform:scale(0) rotate(-40deg);opacity:0;transition:transform 520ms cubic-bezier(.34,1.56,.5,1),opacity 260ms ease}",
  ".tsm[data-dark='true'] .tsm-star{transform:scale(1) rotate(0deg);opacity:1;transition-delay:var(--tsm-sd)}",
  ".tsm-star svg{display:block;width:100%;height:100%;animation:tsm-twinkle 2.6s ease-in-out infinite;animation-delay:var(--tsm-td)}",
  ".tsm-thumb{position:absolute;left:.11em;top:.11em;width:.78em;height:.78em;transform:translateX(var(--tsm-x)) scale(var(--tsm-s));transition:transform 640ms cubic-bezier(.34,1.45,.5,1)}",
  ".tsm[data-press='true'] .tsm-thumb{transition-duration:180ms;transition-timing-function:ease-out}",
  ".tsm-glow{position:absolute;inset:-.1em;border-radius:9999px;transition:opacity 600ms ease}",
  ".tsm-glow-day{background:radial-gradient(closest-side,rgba(255,240,170,.75),rgba(255,214,90,0))}",
  ".tsm-glow-night{opacity:0;background:radial-gradient(closest-side,rgba(180,198,255,.42),rgba(150,170,255,0))}",
  ".tsm[data-dark='true'] .tsm-glow-day{opacity:0}",
  ".tsm[data-dark='true'] .tsm-glow-night{opacity:1}",
  ".tsm-icon{position:absolute;inset:0;width:100%;height:100%;overflow:visible;transition:transform 820ms cubic-bezier(.34,1.3,.5,1)}",
  ".tsm[data-dark='true'] .tsm-icon{transform:rotate(-24deg)}",
  ".tsm-icon *{transform-box:fill-box}",
  ".tsm-rays{transform-box:view-box;transform-origin:24px 24px;transition:transform 900ms cubic-bezier(.3,1.2,.4,1)}",
  ".tsm:hover:not([data-dark='true']) .tsm-rays{transform:rotate(22.5deg)}",
  ".tsm-ray{transform-box:view-box;transform-origin:24px 24px;transform:rotate(var(--r)) scaleY(1);opacity:1;transition:transform 420ms cubic-bezier(.34,1.5,.5,1) var(--tsm-in),opacity 220ms ease var(--tsm-in)}",
  ".tsm[data-dark='true'] .tsm-ray{transform:rotate(var(--r)) scaleY(.15);opacity:0;transition:transform 300ms cubic-bezier(.5,0,.75,0) var(--tsm-out),opacity 200ms ease var(--tsm-out)}",
  ".tsm-core{transform-origin:50% 50%;transform:scale(.667);transition:transform 640ms cubic-bezier(.34,1.35,.5,1)}",
  ".tsm[data-dark='true'] .tsm-core{transform:scale(1)}",
  ".tsm-sun,.tsm-moon{transition:opacity 480ms ease}",
  ".tsm-moon{opacity:0}",
  ".tsm[data-dark='true'] .tsm-sun{opacity:0}",
  ".tsm[data-dark='true'] .tsm-moon{opacity:1}",
  ".tsm-bite{transform:translate(15px,-15px);transition:transform 560ms cubic-bezier(.55,0,.3,1)}",
  ".tsm[data-dark='true'] .tsm-bite{transform:translate(0,0);transition-delay:140ms}",
  ".tsm-crater{opacity:0;transition:opacity 400ms ease}",
  ".tsm[data-dark='true'] .tsm-crater{opacity:1;transition-delay:480ms}",
  ".tsm-ring{position:absolute;inset:0;border-radius:9999px;border:.03em solid rgba(255,196,70,.95);pointer-events:none;animation:tsm-ring 720ms cubic-bezier(.2,.7,.3,1) forwards}",
  ".tsm[data-dark='true'] .tsm-ring{border-color:rgba(190,206,255,.95)}",
  "@keyframes tsm-drift{from{transform:translateX(-.05em)}to{transform:translateX(.06em)}}",
  "@keyframes tsm-twinkle{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.7)}}",
  "@keyframes tsm-ring{from{transform:scale(1);opacity:.9}to{transform:scale(2.3);opacity:0}}",
  "@media (prefers-reduced-motion:reduce){.tsm *,.tsm{animation:none!important;transition-duration:200ms!important;transition-delay:0s!important;transition-timing-function:ease!important}.tsm-ring{display:none}.tsm-rays,.tsm:hover:not([data-dark='true']) .tsm-rays{transform:none}}",
].join("\n");

const TSM_RAYS = [0, 45, 90, 135, 180, 225, 270, 315];
const TSM_STARS: { x: number; y: number; s: number }[] = [
  { x: 0.2, y: 0.2, s: 0.11 },
  { x: 0.48, y: 0.6, s: 0.08 },
  { x: 0.33, y: 0.76, s: 0.055 },
  { x: 0.6, y: 0.2, s: 0.065 },
  { x: 0.12, y: 0.6, s: 0.065 },
  { x: 0.74, y: 0.47, s: 0.05 },
  { x: 0.46, y: 0.33, s: 0.045 },
];
const TSM_SPARKLE = "M5 0L6.1 3.9L10 5L6.1 6.1L5 10L3.9 6.1L0 5L3.9 3.9Z";
const TSM_CLOUD = "M7 16a6 6 0 0 1 .8-11.9A9 9 0 0 1 24.6 3.2A7 7 0 0 1 34.5 8.8 4.6 4.6 0 0 1 33.5 16Z";

export function ThemeToggleSunMoonMorph({
  checked,
  defaultChecked = false,
  onChange,
  size = 36,
  label = "Dark mode",
  className,
  style,
  onClick,
  onPointerDown,
  onPointerUp,
  onPointerLeave,
  onPointerCancel,
  disabled,
  ...rest
}: ThemeToggleSunMoonMorphProps) {
  const [inner, setInner] = useState(defaultChecked);
  const [press, setPress] = useState(false);
  const [burst, setBurst] = useState(0);
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const dark = checked ?? inner;
  const px = Math.min(256, Math.max(16, size));
  const hit = "calc(max(0px, (40px - 1em) / 2) * -1)";

  function toggle(e: MouseEvent<HTMLButtonElement>) {
    onClick?.(e);
    if (e.defaultPrevented) return;
    const next = !dark;
    if (checked === undefined) setInner(next);
    setBurst((n) => n + 1);
    onChange?.(next);
  }

  return (
    <>
      <style>{TSM_CSS}</style>
      <button
        {...rest}
        type="button"
        role="switch"
        aria-checked={dark}
        aria-label={label}
        disabled={disabled}
        data-dark={dark ? "true" : "false"}
        data-press={press && !disabled ? "true" : "false"}
        className={"tsm" + (className ? " " + className : "")}
        style={{ fontSize: px + "px", width: "1.78em", height: "1em", ...style }}
        onClick={toggle}
        onPointerDown={(e) => {
          setPress(true);
          onPointerDown?.(e);
        }}
        onPointerUp={(e) => {
          setPress(false);
          onPointerUp?.(e);
        }}
        onPointerLeave={(e) => {
          setPress(false);
          onPointerLeave?.(e);
        }}
        onPointerCancel={(e) => {
          setPress(false);
          onPointerCancel?.(e);
        }}
      >
        <span aria-hidden="true" style={{ position: "absolute", inset: hit }} />
        <span aria-hidden="true" className="tsm-track">
          <span className="tsm-sky tsm-sky-day" />
          <span className="tsm-sky tsm-sky-night" />
          <span className="tsm-clouds">
            <span className="tsm-cloud" style={{ right: ".12em", bottom: ".05em", width: ".52em", height: ".21em" }}>
              <svg viewBox="0 0 40 16" width="100%" height="100%" style={{ display: "block" }}>
                <path d={TSM_CLOUD} fill="#ffffff" />
              </svg>
            </span>
            <span
              className="tsm-cloud"
              style={{ right: ".56em", bottom: "-.03em", width: ".36em", height: ".15em", opacity: 0.7, animationDirection: "alternate-reverse", animationDuration: "9s" }}
            >
              <svg viewBox="0 0 40 16" width="100%" height="100%" style={{ display: "block" }}>
                <path d={TSM_CLOUD} fill="#ffffff" />
              </svg>
            </span>
          </span>
          <span className="tsm-stars">
            {TSM_STARS.map((st, i) => (
              <span
                key={i}
                className="tsm-star"
                style={
                  {
                    left: st.x + "em",
                    top: st.y + "em",
                    width: st.s + "em",
                    height: st.s + "em",
                    "--tsm-sd": 260 + i * 70 + "ms",
                    "--tsm-td": i * 0.37 + "s",
                  } as CSSProperties
                }
              >
                <svg viewBox="0 0 10 10">
                  <path d={TSM_SPARKLE} fill="#eef2ff" />
                </svg>
              </span>
            ))}
          </span>
        </span>
        <span aria-hidden="true" className="tsm-thumb">
          <span className="tsm-glow tsm-glow-day" />
          <span className="tsm-glow tsm-glow-night" />
          {burst > 0 ? <span key={burst} className="tsm-ring" /> : null}
          <svg className="tsm-icon" viewBox="0 0 48 48">
            <defs>
              <linearGradient id={"tsmS" + uid} x1="0.2" y1="0" x2="0.8" y2="1">
                <stop offset="0" stopColor="#ffe27a" />
                <stop offset="1" stopColor="#ff9a1f" />
              </linearGradient>
              <linearGradient id={"tsmM" + uid} x1="0.1" y1="0" x2="0.9" y2="1">
                <stop offset="0" stopColor="#fbfcff" />
                <stop offset="1" stopColor="#b9c4f2" />
              </linearGradient>
              <mask id={"tsmK" + uid} maskUnits="userSpaceOnUse" x="-8" y="-8" width="64" height="64">
                <rect x="-8" y="-8" width="64" height="64" fill="#fff" />
                <circle className="tsm-bite" cx="33" cy="15" r="12" fill="#000" />
              </mask>
            </defs>
            <g className="tsm-rays">
              {TSM_RAYS.map((deg, i) => (
                <line
                  key={deg}
                  className="tsm-ray"
                  x1="24"
                  y1="3"
                  x2="24"
                  y2="8"
                  stroke="#ffb23a"
                  strokeWidth="3.2"
                  strokeLinecap="round"
                  style={
                    {
                      "--r": deg + "deg",
                      "--tsm-in": 280 + i * 24 + "ms",
                      "--tsm-out": i * 16 + "ms",
                    } as CSSProperties
                  }
                />
              ))}
            </g>
            <g mask={"url(#tsmK" + uid + ")"}>
              <g className="tsm-core">
                <circle className="tsm-sun" cx="24" cy="24" r="15" fill={"url(#tsmS" + uid + ")"} />
                <circle className="tsm-moon" cx="24" cy="24" r="15" fill={"url(#tsmM" + uid + ")"} />
                <g className="tsm-crater" fill="#8e9bd6" fillOpacity="0.4">
                  <circle cx="15.5" cy="27" r="2.8" />
                  <circle cx="21" cy="35" r="1.8" />
                  <circle cx="13.5" cy="19.5" r="1.6" />
                </g>
              </g>
            </g>
          </svg>
        </span>
      </button>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

One set of SVG shapes plays both icons. A core disc sits inside a mask that holds a black bite circle: by day the bite is parked off the disc and the disc is scaled to a sun with eight rays, going dark the rays retract in a short stagger, the disc grows, the bite slides across and carves a crescent, three faint craters fade in and the whole icon tilts 24 degrees. Because the crescent is a mask, not a circle painted in the thumb colour, it stays correct on any background. The thumb is a squishy bead: pressing it squishes it to 90 percent, and it overshoots slightly as it lands. The track is two sky layers cross-fading, with two clouds drifting by day that sink away at night while seven sparkle stars pop in one after another and twinkle. Each toggle fires a single light ring from the thumb. It is controlled (checked) or uncontrolled (defaultChecked), reports the new state through onChange, scales from one size prop because everything is sized in em, and passes other button attributes through. All CSS ships in one plain style element. The demo wraps it in a stage whose ground opens from the toggle as an expanding circle.

Curator’s note

Theme Toggle Sun Moon Morph, rebuilt in October. The first version tweened a bite circle with framer-motion; this one uses a true SVG mask and hand-written CSS motion, and needs nothing but React.

Pairs well with