Skip to content

Commit d28ea0f

Browse files
committed
Animate theme changes with a biological bloom
1 parent a1913ee commit d28ea0f

3 files changed

Lines changed: 259 additions & 1 deletion

File tree

‎src/components/Navigation.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Menu, Moon, Sun, X } from "lucide-react";
44
import { useTheme } from "next-themes";
55
import { SiteLogo } from "@/components/SiteLogo";
66
import { SITE_FAVICON_DARK_SRC, SITE_FAVICON_LIGHT_SRC } from "@/lib/siteLogo";
7+
import { runBioThemeTransition } from "@/lib/themeTransition";
78

89
const navItems = [
910
{ name: "home", path: "/" },
@@ -36,7 +37,13 @@ const ThemeToggle = ({ mobile = false }: { mobile?: boolean }) => {
3637
return (
3738
<button
3839
type="button"
39-
onClick={() => setTheme(isDark ? "light" : "dark")}
40+
onClick={(event) =>
41+
runBioThemeTransition({
42+
nextTheme: isDark ? "light" : "dark",
43+
setTheme,
44+
trigger: event.currentTarget,
45+
})
46+
}
4047
aria-label={`Switch to ${isDark ? "light" : "dark"} theme`}
4148
className={`inline-flex min-h-9 items-center justify-center gap-1.5 rounded border border-border bg-button-surface font-sans text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-heading focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:translate-y-px ${
4249
mobile ? "w-full px-3" : "px-2.5"

‎src/index.css‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,62 @@
176176
}
177177
}
178178

179+
@media (prefers-reduced-motion: no-preference) {
180+
::view-transition-old(root),
181+
::view-transition-new(root) {
182+
animation: none;
183+
mix-blend-mode: normal;
184+
}
185+
186+
::view-transition-old(root) {
187+
z-index: 1;
188+
}
189+
190+
::view-transition-new(root) {
191+
z-index: 2;
192+
}
193+
}
194+
195+
.theme-bio-bloom {
196+
--theme-bloom-fill: hsl(0 0% 100%);
197+
--theme-bloom-membrane: hsl(41.4 98.3% 53.7% / 0.72);
198+
--theme-bloom-nucleus: hsl(209.4 100% 19.2% / 0.72);
199+
position: fixed;
200+
left: 0;
201+
top: 0;
202+
z-index: 100;
203+
display: block;
204+
width: 5rem;
205+
height: 5rem;
206+
contain: paint;
207+
pointer-events: none;
208+
border: 1px solid var(--theme-bloom-membrane);
209+
border-radius: 999px;
210+
background:
211+
radial-gradient(
212+
circle at 38% 38%,
213+
var(--theme-bloom-nucleus) 0 6%,
214+
transparent 7%
215+
),
216+
radial-gradient(
217+
circle at 50% 50%,
218+
transparent 0 63%,
219+
var(--theme-bloom-membrane) 64% 67%,
220+
transparent 68%
221+
),
222+
var(--theme-bloom-fill);
223+
box-shadow:
224+
inset 0 0 0 1px var(--theme-bloom-membrane),
225+
0 0 20px var(--theme-bloom-membrane);
226+
will-change: transform, opacity;
227+
}
228+
229+
.theme-bio-bloom--dark {
230+
--theme-bloom-fill: hsl(218.2 52.4% 8.2%);
231+
--theme-bloom-membrane: hsl(210.9 69.5% 70.4% / 0.62);
232+
--theme-bloom-nucleus: hsl(41.4 98.3% 53.7% / 0.8);
233+
}
234+
179235
@layer components {
180236
.bg-lab-grid {
181237
background-image:

‎src/lib/themeTransition.ts‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
type SiteTheme = "light" | "dark";
2+
3+
interface BioThemeTransitionOptions {
4+
nextTheme: SiteTheme;
5+
setTheme: (theme: SiteTheme) => void;
6+
trigger: HTMLElement;
7+
}
8+
9+
let themeTransitionActive = false;
10+
11+
const applyTheme = (
12+
nextTheme: SiteTheme,
13+
setTheme: BioThemeTransitionOptions["setTheme"],
14+
) => {
15+
const root = document.documentElement;
16+
root.classList.toggle("dark", nextTheme === "dark");
17+
root.style.colorScheme = nextTheme;
18+
setTheme(nextTheme);
19+
};
20+
21+
const runFallbackBloom = ({
22+
nextTheme,
23+
originX,
24+
originY,
25+
revealRadius,
26+
setTheme,
27+
}: {
28+
nextTheme: SiteTheme;
29+
originX: number;
30+
originY: number;
31+
revealRadius: number;
32+
setTheme: BioThemeTransitionOptions["setTheme"];
33+
}) => {
34+
const bloom = document.createElement("span");
35+
const bloomSize = 80;
36+
const revealScale = (revealRadius + bloomSize) / (bloomSize / 2);
37+
const translatedOrigin = `translate3d(${originX - bloomSize / 2}px, ${
38+
originY - bloomSize / 2
39+
}px, 0)`;
40+
41+
bloom.className = `theme-bio-bloom theme-bio-bloom--${nextTheme}`;
42+
bloom.setAttribute("aria-hidden", "true");
43+
document.body.appendChild(bloom);
44+
45+
if (typeof bloom.animate !== "function") {
46+
bloom.remove();
47+
applyTheme(nextTheme, setTheme);
48+
themeTransitionActive = false;
49+
return;
50+
}
51+
52+
const growth = bloom.animate(
53+
[
54+
{
55+
opacity: 0.35,
56+
transform: `${translatedOrigin} scale(0.04)`,
57+
},
58+
{
59+
opacity: 1,
60+
transform: `${translatedOrigin} scale(${revealScale * 1.025})`,
61+
offset: 0.82,
62+
},
63+
{
64+
opacity: 1,
65+
transform: `${translatedOrigin} scale(${revealScale})`,
66+
},
67+
],
68+
{
69+
duration: 640,
70+
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
71+
fill: "forwards",
72+
},
73+
);
74+
75+
growth.finished
76+
.then(() => {
77+
applyTheme(nextTheme, setTheme);
78+
return bloom.animate([{ opacity: 1 }, { opacity: 0 }], {
79+
duration: 260,
80+
easing: "cubic-bezier(0.4, 0, 1, 1)",
81+
fill: "forwards",
82+
}).finished;
83+
})
84+
.catch(() => {
85+
applyTheme(nextTheme, setTheme);
86+
})
87+
.finally(() => {
88+
bloom.remove();
89+
themeTransitionActive = false;
90+
});
91+
};
92+
93+
export const runBioThemeTransition = ({
94+
nextTheme,
95+
setTheme,
96+
trigger,
97+
}: BioThemeTransitionOptions) => {
98+
const reduceMotion = window.matchMedia(
99+
"(prefers-reduced-motion: reduce)",
100+
).matches;
101+
102+
if (reduceMotion) {
103+
applyTheme(nextTheme, setTheme);
104+
return;
105+
}
106+
107+
if (themeTransitionActive) return;
108+
themeTransitionActive = true;
109+
110+
const triggerBounds = trigger.getBoundingClientRect();
111+
const originX = triggerBounds.left + triggerBounds.width / 2;
112+
const originY = triggerBounds.top + triggerBounds.height / 2;
113+
const horizontalReach = Math.max(originX, window.innerWidth - originX);
114+
const verticalReach = Math.max(originY, window.innerHeight - originY);
115+
const revealRadius = Math.hypot(horizontalReach, verticalReach);
116+
const membraneColor =
117+
nextTheme === "dark"
118+
? "rgb(138 190 235 / 0.52)"
119+
: "rgb(255 183 18 / 0.58)";
120+
121+
trigger.animate(
122+
[
123+
{ transform: "scale(1)" },
124+
{ transform: "scale(0.92)", offset: 0.32 },
125+
{ transform: "scale(1)" },
126+
],
127+
{
128+
duration: 460,
129+
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
130+
},
131+
);
132+
133+
if (!document.startViewTransition) {
134+
runFallbackBloom({
135+
nextTheme,
136+
originX,
137+
originY,
138+
revealRadius,
139+
setTheme,
140+
});
141+
return;
142+
}
143+
144+
try {
145+
const transition = document.startViewTransition(() => {
146+
applyTheme(nextTheme, setTheme);
147+
});
148+
149+
transition.ready
150+
.then(() => {
151+
const newTheme = document.documentElement.animate(
152+
{
153+
clipPath: [
154+
`circle(0px at ${originX}px ${originY}px)`,
155+
`circle(${revealRadius * 0.32}px at ${originX}px ${originY}px)`,
156+
`circle(${revealRadius}px at ${originX}px ${originY}px)`,
157+
],
158+
filter: [
159+
`drop-shadow(0 0 0 ${membraneColor}) saturate(1)`,
160+
`drop-shadow(0 0 18px ${membraneColor}) saturate(1.08)`,
161+
"drop-shadow(0 0 0 transparent) saturate(1)",
162+
],
163+
},
164+
{
165+
duration: 820,
166+
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
167+
pseudoElement: "::view-transition-new(root)",
168+
},
169+
);
170+
171+
const oldTheme = document.documentElement.animate(
172+
{
173+
opacity: [1, 0.94],
174+
transform: ["scale(1)", "scale(0.992)"],
175+
},
176+
{
177+
duration: 540,
178+
easing: "cubic-bezier(0.4, 0, 1, 1)",
179+
fill: "forwards",
180+
pseudoElement: "::view-transition-old(root)",
181+
},
182+
);
183+
184+
return Promise.allSettled([newTheme.finished, oldTheme.finished]);
185+
})
186+
.catch(() => undefined);
187+
188+
transition.finished.finally(() => {
189+
themeTransitionActive = false;
190+
}).catch(() => undefined);
191+
} catch {
192+
themeTransitionActive = false;
193+
applyTheme(nextTheme, setTheme);
194+
}
195+
};

0 commit comments

Comments
 (0)