Teseria
All elements

Theme toggle

Sun becomes moon. The disc shrinks while a second disc slides in to bite a crescent out of it, and the rays fold away one after another.

Code

1. Install

Terminal
# No packages beyond React.

2. Files

ThemeToggle.tsx93 lines
"use client";
// ThemeToggle: sun becomes moon. The disc shrinks while a second disc slides in to bite a crescent out of it, and the rays fold away one after another.
// The page changes through a circle that grows out of the button (View Transitions), falling back to a plain instant switch.
// By default it follows and drives `data-theme` on <html> and remembers the choice in localStorage; both can be turned off.
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";

export type Theme = "light" | "dark";
export type ThemeToggleCue = "disable" | "enable";

export type ThemeToggleProps = {
  /** Current theme (controlled). By default the button reads it from `data-theme` on <html>. */
  value?: Theme;
  /** Called with the next theme when the button is pressed. */
  onChange?: (theme: Theme) => void;
  /** Write `data-theme` on <html> (inside the view transition) when pressed. Turn off to apply the theme yourself in `onChange`. */
  syncDocument?: boolean;
  /** localStorage key the choice is saved under; null saves nothing. */
  storageKey?: string | null;
  /** Sound hook: "disable" when going dark, "enable" when going light. */
  onSound?: (cue: ThemeToggleCue) => void;
  /** Diameter of the button in px. */
  size?: number;
  className?: string;
  style?: CSSProperties;
};

type ViewTransitionDoc = Document & { startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> } };

export function ThemeToggle({ value, onChange, syncDocument = true, storageKey = "teseria-theme", onSound, size = 52, className, style }: ThemeToggleProps) {
  const [seen, setSeen] = useState<Theme>("light");
  const theme = value ?? seen;
  const btn = useRef<HTMLButtonElement>(null);
  const mask = `thmt-${useId().replace(/:/g, "")}`;
  useEffect(() => {
    const read = () => setSeen((document.documentElement.dataset.theme as Theme) ?? "light");
    read();
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
    return () => mo.disconnect();
  }, []);

  const apply = (next: Theme) => {
    if (syncDocument) document.documentElement.dataset.theme = next;
    if (storageKey) {
      try {
        localStorage.setItem(storageKey, next);
      } catch {
        /* storage blocked: the theme still switches */
      }
    }
    setSeen(next);
    onChange?.(next);
  };
  const flip = () => {
    const next: Theme = theme === "dark" ? "light" : "dark";
    onSound?.(next === "dark" ? "disable" : "enable");
    const doc = document as ViewTransitionDoc;
    const el = btn.current;
    if (!el || !syncDocument || !doc.startViewTransition || window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return apply(next);
    const b = el.getBoundingClientRect();
    const x = b.left + b.width / 2;
    const y = b.top + b.height / 2;
    const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
    const html = document.documentElement;
    html.classList.add("thmt-vt");
    const vt = doc.startViewTransition(() => apply(next));
    vt.ready
      .then(() => html.animate({ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] }, { duration: 600, easing: "cubic-bezier(.22,1,.36,1)", pseudoElement: "::view-transition-new(root)" }))
      .catch(() => {});
    // only drop the class once the snapshot pseudo-elements are gone; dropping it earlier lets the browser's
    // default crossfade re-apply to the snapshots for a frame, which reads as a flash
    vt.finished.finally(() => html.classList.remove("thmt-vt"));
  };

  return (
    <div className={`thmt-root${className ? " " + className : ""}`} style={style}>
      <button ref={btn} type="button" className="thmt-btn" data-dark={theme === "dark" || undefined} aria-label={theme === "dark" ? "Switch to light" : "Switch to dark"} style={{ width: size, height: size }} onClick={flip}>
        <svg viewBox="0 0 24 24" width={size * 0.5} height={size * 0.5} aria-hidden>
          <mask id={mask}>
            <rect width="24" height="24" fill="#fff" />
            <circle className="thmt-bite" cx="30" cy="4" r="8" fill="#000" />
          </mask>
          <circle className="thmt-disc" cx="12" cy="12" r="5" mask={`url(#${mask})`} />
          <g className="thmt-rays">
            {Array.from({ length: 8 }, (_, i) => (
              <line key={i} x1="12" y1="2.5" x2="12" y2="5" style={{ "--thmt-i": i, rotate: `${i * 45}deg` } as CSSProperties} />
            ))}
          </g>
        </svg>
      </button>
    </div>
  );
}
ThemeToggle.css92 lines
.thmt-root,
.thmt-root *,
.thmt-root *::before,
.thmt-root *::after {
  box-sizing: border-box;
}
.thmt-root {
  font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.thmt-root) button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
}
:where(.thmt-root) :focus-visible {
  outline: 2px solid var(--ts-focus, #3446eb);
  outline-offset: 2px;
}
.thmt-btn {
  border: 0;
  border-radius: 999px;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.22s cubic-bezier(.22, 1, .36, 1), color 0.22s cubic-bezier(.22, 1, .36, 1);
}
.thmt-btn:active {
  scale: 0.94;
}
.thmt-btn svg {
  overflow: visible;
}
.thmt-disc {
  fill: currentColor;
  transition: r 0.42s cubic-bezier(.24, 1.34, .38, 1);
}
.thmt-btn[data-dark] .thmt-disc {
  r: 8;
}
.thmt-bite {
  transition: cx 0.42s cubic-bezier(.22, 1, .36, 1), cy 0.42s cubic-bezier(.22, 1, .36, 1);
}
.thmt-btn[data-dark] .thmt-bite {
  cx: 17;
  cy: 7;
}
.thmt-rays line {
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  transform-origin: 12px 12px;
  transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.42s cubic-bezier(.24, 1.34, .38, 1);
  transition-delay: calc(var(--thmt-i) * 22ms);
}
.thmt-btn[data-dark] .thmt-rays line {
  opacity: 0;
  scale: 0.4;
  transition: opacity 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
  transition-delay: calc((7 - var(--thmt-i)) * 18ms);
}
/* circular reveal: the new view is clipped by the circle animated from the button; the old one just sits underneath */
.thmt-vt::view-transition-group(root),
.thmt-vt::view-transition-image-pair(root),
.thmt-vt::view-transition-old(root),
.thmt-vt::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}
.thmt-vt::view-transition-image-pair(root) {
  isolation: auto;
}
.thmt-vt::view-transition-old(root) {
  z-index: 1;
}
.thmt-vt::view-transition-new(root) {
  z-index: 2;
}
.thmt-root {
  display: flex;
  justify-content: center;
}

/* defaults for the custom properties the component sets inline */
.thmt-rays line {
  --thmt-i: 0;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { ThemeToggle } from "./ThemeToggle";
import "./ThemeToggle.css";

export default function Demo() {
  return <ThemeToggle />;
}