theme: transition the tokens themselves — @property makes the fade real

The element-level transition blanket never could work here: the theme
lives in custom properties consumed through color-mix() inside
GRADIENTS (background-image is not animatable), inherited colors, and
per-frame inline writes. The tokens are now registered as typed
<color> @property rules and the .theme-anim class transitions the
VARIABLES on the root over 0.75s — every consumer re-resolves each
animated frame, gradients and scrims included, with zero per-element
transitions to fight hover states or the ruler's per-frame color
writes. A forced reflow commits the transition property before the
token flip, and watchThemeColors now derives twice (at flip and after
settle) so WebGL color targets land on the true final values instead
of a mid-fade sample.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
This commit is contained in:
Tanish Manku committed 2026-09-10 23:56:39 -07:00
1 parent 5af1afdc0a
commit c886121dff
3 files changed
+57 -15

No files matched your search

@@ -124,19 +124,47 @@ body {
outline-offset: 2px;
}
/* Theme switch cross-fade: the toggle stamps .theme-anim on <html> for
~0.8s so every themed property (text, backgrounds, hairlines, SVG fills
— the wordmark's logo-A included) transitions together over 0.75s, then
lifts the blanket so hover states and per-frame color writes stay
instant. */
html.theme-anim,
html.theme-anim * {
/* Theme switch cross-fade. The tokens are registered as typed <color>
custom properties so the VARIABLES THEMSELVES can transition: when the
toggle stamps .theme-anim on <html>, the root transitions every token
over 0.75s and every consumer — utilities, borders, SVG fills,
color-mix() strings, and gradients (background-image is not animatable,
so element-level transitions could never cover the scrims and pools) —
re-resolves each animated frame. No per-element transitions exist, so
hover states and per-frame color writes stay instant throughout. */
@property --bg { syntax: "<color>"; inherits: true; initial-value: #f2f1ee; }
@property --bg-raised { syntax: "<color>"; inherits: true; initial-value: #faf9f7; }
@property --bg-sunken { syntax: "<color>"; inherits: true; initial-value: #e9e8e4; }
@property --ink { syntax: "<color>"; inherits: true; initial-value: #101014; }
@property --ink-strong { syntax: "<color>"; inherits: true; initial-value: #000000; }
@property --muted { syntax: "<color>"; inherits: true; initial-value: rgba(16, 16, 20, 0.62); }
@property --faint { syntax: "<color>"; inherits: true; initial-value: rgba(16, 16, 20, 0.42); }
@property --line { syntax: "<color>"; inherits: true; initial-value: rgba(16, 16, 20, 0.16); }
@property --line-strong { syntax: "<color>"; inherits: true; initial-value: rgba(16, 16, 20, 0.4); }
@property --accent { syntax: "<color>"; inherits: true; initial-value: #2d81ff; }
@property --accent-ink { syntax: "<color>"; inherits: true; initial-value: #1659c4; }
@property --invert-bg { syntax: "<color>"; inherits: true; initial-value: #101014; }
@property --invert-fg { syntax: "<color>"; inherits: true; initial-value: #f2f1ee; }
@property --danger { syntax: "<color>"; inherits: true; initial-value: #b3261e; }
@property --scanline { syntax: "<color>"; inherits: true; initial-value: rgba(16, 16, 20, 0.03); }
html.theme-anim {
transition:
background-color 0.75s ease,
color 0.75s ease,
border-color 0.75s ease,
fill 0.75s ease,
stroke 0.75s ease;
--bg 0.75s ease,
--bg-raised 0.75s ease,
--bg-sunken 0.75s ease,
--ink 0.75s ease,
--ink-strong 0.75s ease,
--muted 0.75s ease,
--faint 0.75s ease,
--line 0.75s ease,
--line-strong 0.75s ease,
--accent 0.75s ease,
--accent-ink 0.75s ease,
--invert-bg 0.75s ease,
--invert-fg 0.75s ease,
--danger 0.75s ease,
--scanline 0.75s ease;
}
/* Signature interaction: label blocks invert on hover/active. */
@@ -41,6 +41,9 @@ export default function ThemeToggle({ className }: { className?: string }) {
() => root.classList.remove("theme-anim"),
800,
);
// Commit the transition property in its own style pass BEFORE the
// token flip — same-recalc changes can skip the transition entirely.
void root.offsetWidth;
root.setAttribute("data-theme", next);
try {
localStorage.setItem(STORAGE_KEY, next);
@@ -67,17 +67,28 @@ export function watchThemeColors(
onChange: (colors: ThemeColors) => void,
): () => void {
const derive = () => onChange(deriveThemeColors());
// Theme flips animate the tokens over 0.75s (see globals.css), so a
// derive at flip time reads MID-transition values. Deliver both: the
// immediate read (consumers that lerp start moving at once) and a final
// read after the fade settles so targets land on the true colors.
let settleTimer: ReturnType<typeof setTimeout> | undefined;
const deriveTwice = () => {
derive();
clearTimeout(settleTimer);
settleTimer = setTimeout(derive, 850);
};
derive();
const observer = new MutationObserver(derive);
const observer = new MutationObserver(deriveTwice);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", derive);
mq.addEventListener("change", deriveTwice);
return () => {
clearTimeout(settleTimer);
observer.disconnect();
mq.removeEventListener("change", derive);
mq.removeEventListener("change", deriveTwice);
};
}