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
# No packages beyond React.
2. Files
"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>
);
}
.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;
}
/*
* 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
import { ThemeToggle } from "./ThemeToggle";
import "./ThemeToggle.css";
export default function Demo() {
return <ThemeToggle />;
}