Skip to content

Commit 393d59a

Browse files
authored
feat(frontend): Theme toggle (#73)
!release
1 parent 1965e52 commit 393d59a

File tree

5 files changed

+169
-37
lines changed

5 files changed

+169
-37
lines changed

apps/frontend/app/app.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
@plugin "@tailwindcss/typography";
44
@config '../tailwind.config.ts';
55

6+
@custom-variant dark (&:where(.dark, .dark *));
7+
68
@theme {
79
--font-sans:
810
"Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",

apps/frontend/app/components/icons.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ export function CircleCheckIcon(props: React.ComponentPropsWithoutRef<"svg">) {
1818
);
1919
}
2020

21+
export function ComputerDesktopIcon(
22+
props: React.ComponentPropsWithoutRef<"svg">,
23+
) {
24+
return (
25+
<svg viewBox="0 0 24 24" {...props}>
26+
<path
27+
fillRule="evenodd"
28+
d="M2.25 5.25a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3V15a3 3 0 0 1-3 3h-3v.257c0 .597.237 1.17.659 1.591l.621.622a.75.75 0 0 1-.53 1.28h-9a.75.75 0 0 1-.53-1.28l.621-.622a2.25 2.25 0 0 0 .659-1.59V18h-3a3 3 0 0 1-3-3V5.25Zm1.5 0v7.5a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5v-7.5a1.5 1.5 0 0 0-1.5-1.5H5.25a1.5 1.5 0 0 0-1.5 1.5Z"
29+
clipRule="evenodd"
30+
/>
31+
</svg>
32+
);
33+
}
34+
2135
export function InstagramIcon(props: React.ComponentPropsWithoutRef<"svg">) {
2236
return (
2337
<svg viewBox="0 0 24 24" aria-hidden="true" {...props}>
@@ -88,19 +102,8 @@ export function ChevronDownIcon(props: React.ComponentPropsWithoutRef<"svg">) {
88102

89103
export function SunIcon(props: React.ComponentPropsWithoutRef<"svg">) {
90104
return (
91-
<svg
92-
viewBox="0 0 24 24"
93-
strokeWidth="1.5"
94-
strokeLinecap="round"
95-
strokeLinejoin="round"
96-
aria-hidden="true"
97-
{...props}
98-
>
99-
<path d="M8 12.25A4.25 4.25 0 0 1 12.25 8v0a4.25 4.25 0 0 1 4.25 4.25v0a4.25 4.25 0 0 1-4.25 4.25v0A4.25 4.25 0 0 1 8 12.25v0Z" />
100-
<path
101-
d="M12.25 3v1.5M21.5 12.25H20M18.791 18.791l-1.06-1.06M18.791 5.709l-1.06 1.06M12.25 20v1.5M4.5 12.25H3M6.77 6.77 5.709 5.709M6.77 17.73l-1.061 1.061"
102-
fill="none"
103-
/>
105+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
106+
<path d="M12 2.25a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM7.5 12a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM18.894 6.166a.75.75 0 0 0-1.06-1.06l-1.591 1.59a.75.75 0 1 0 1.06 1.061l1.591-1.59ZM21.75 12a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1 0-1.5H21a.75.75 0 0 1 .75.75ZM17.834 18.894a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 1 0-1.061 1.06l1.59 1.591ZM12 18a.75.75 0 0 1 .75.75V21a.75.75 0 0 1-1.5 0v-2.25A.75.75 0 0 1 12 18ZM7.758 17.303a.75.75 0 0 0-1.061-1.06l-1.591 1.59a.75.75 0 0 0 1.06 1.061l1.591-1.59ZM6 12a.75.75 0 0 1-.75.75H3a.75.75 0 0 1 0-1.5h2.25A.75.75 0 0 1 6 12ZM6.697 7.757a.75.75 0 0 0 1.06-1.06l-1.59-1.591a.75.75 0 0 0-1.061 1.06l1.59 1.591Z" />
104107
</svg>
105108
);
106109
}

apps/frontend/app/layout/header.tsx

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef } from "react";
1+
import React, { useEffect, useRef } from "react";
22
import {
33
Popover,
44
PopoverButton,
@@ -15,6 +15,7 @@ import { type Header, type Header as HeaderType } from "@fxmk/payload-types";
1515
import { getLinkHref } from "../utils/links";
1616
import { MediaImage } from "~/components/media-image";
1717
import { Link } from "~/components/link";
18+
import { ThemeToggle } from "./theme";
1819

1920
function MobileNavItem({
2021
to,
@@ -117,28 +118,6 @@ function DesktopNavigation({ navItems, ...props }: DesktopNavigationProps) {
117118
);
118119
}
119120

120-
// function ThemeToggle() {
121-
// let { resolvedTheme, setTheme } = useTheme();
122-
// let otherTheme = resolvedTheme === "dark" ? "light" : "dark";
123-
// let [mounted, setMounted] = useState(false);
124-
125-
// useEffect(() => {
126-
// setMounted(true);
127-
// }, []);
128-
129-
// return (
130-
// <button
131-
// type="button"
132-
// aria-label={mounted ? `Switch to ${otherTheme} theme` : "Toggle theme"}
133-
// className="group rounded-full bg-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur-sm transition dark:bg-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20"
134-
// onClick={() => setTheme(otherTheme)}
135-
// >
136-
// <SunIcon className="h-6 w-6 fill-zinc-100 stroke-zinc-500 transition group-hover:fill-zinc-200 group-hover:stroke-zinc-700 dark:hidden [@media(prefers-color-scheme:dark)]:fill-teal-50 [@media(prefers-color-scheme:dark)]:stroke-teal-500 [@media(prefers-color-scheme:dark)]:group-hover:fill-teal-50 [@media(prefers-color-scheme:dark)]:group-hover:stroke-teal-600" />
137-
// <MoonIcon className="hidden h-6 w-6 fill-zinc-700 stroke-zinc-500 transition dark:block [@media(prefers-color-scheme:dark)]:group-hover:stroke-zinc-400 [@media_not_(prefers-color-scheme:dark)]:fill-teal-400/10 [@media_not_(prefers-color-scheme:dark)]:stroke-teal-500" />
138-
// </button>
139-
// );
140-
// }
141-
142121
function clamp(number: number, a: number, b: number) {
143122
const min = Math.min(a, b);
144123
const max = Math.max(a, b);
@@ -383,7 +362,7 @@ export function Header({ navItems, avatar }: HeaderProps) {
383362
</div>
384363
<div className="flex justify-end md:flex-1">
385364
<div className="pointer-events-auto">
386-
{/* <ThemeToggle /> */}
365+
<ThemeToggle />
387366
</div>
388367
</div>
389368
</div>

apps/frontend/app/layout/theme.tsx

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import clsx from "clsx";
2+
import { AnimatePresence, motion } from "motion/react";
3+
import {
4+
useEffect,
5+
useLayoutEffect,
6+
useState,
7+
type ComponentType,
8+
type SVGProps,
9+
} from "react";
10+
import { ComputerDesktopIcon, MoonIcon, SunIcon } from "~/components/icons";
11+
12+
function useSyncToSystemTheme(isEnabled: boolean) {
13+
useEffect(() => {
14+
function onMediaQueryChanged(e: MediaQueryListEvent) {
15+
updateCurrentTheme(e.matches ? "dark" : "light");
16+
}
17+
18+
const mediaQuery = getDarkModeMediaQuery();
19+
if (isEnabled) {
20+
mediaQuery.addEventListener("change", onMediaQueryChanged);
21+
} else {
22+
mediaQuery.removeEventListener("change", onMediaQueryChanged);
23+
}
24+
25+
return () => mediaQuery.removeEventListener("change", onMediaQueryChanged);
26+
}, [isEnabled]);
27+
}
28+
29+
export function ThemeInitScript() {
30+
// Best to add inline in 'head' to avoid FOUC
31+
return (
32+
<script type="text/javascript">
33+
{`
34+
document.documentElement.classList.toggle(
35+
"dark",
36+
localStorage.theme === "dark" ||
37+
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
38+
);`}
39+
</script>
40+
);
41+
}
42+
43+
function getDarkModeMediaQuery() {
44+
return window.matchMedia("(prefers-color-scheme: dark)");
45+
}
46+
47+
export function ThemeToggle() {
48+
const [themeSetting, setThemeSetting] = useState<ThemeSetting>();
49+
useLayoutEffect(() => {
50+
setThemeSetting(getStoredThemeSetting());
51+
}, []);
52+
53+
useSyncToSystemTheme(themeSetting === "system");
54+
55+
function onThemeSelected(themeSetting: ThemeSetting) {
56+
setThemeSetting(themeSetting);
57+
updateCurrentTheme(themeSetting);
58+
storeThemeSetting(themeSetting);
59+
}
60+
61+
return (
62+
<div className="relative h-10 w-11 overflow-hidden rounded-full shadow-lg ring-1 shadow-zinc-800/5 ring-zinc-900/5 backdrop-blur-sm dark:bg-zinc-800/90 dark:ring-white/10">
63+
<ThemeButton
64+
icon={MoonIcon}
65+
isSelected={themeSetting === "dark"}
66+
onSelected={() => onThemeSelected("light")}
67+
title="Dark Theme"
68+
/>
69+
<ThemeButton
70+
icon={SunIcon}
71+
isSelected={themeSetting === "light"}
72+
onSelected={() => onThemeSelected("system")}
73+
title="Light Theme"
74+
/>
75+
<ThemeButton
76+
icon={ComputerDesktopIcon}
77+
isSelected={themeSetting === "system"}
78+
onSelected={() => onThemeSelected("dark")}
79+
title="System Theme"
80+
/>
81+
</div>
82+
);
83+
}
84+
85+
function getStoredThemeSetting(): ThemeSetting {
86+
return localStorage.theme === "dark"
87+
? "dark"
88+
: localStorage.theme === "light"
89+
? "light"
90+
: "system";
91+
}
92+
93+
function storeThemeSetting(themeSetting: ThemeSetting) {
94+
if (themeSetting === "dark") {
95+
localStorage.theme = "dark";
96+
} else if (themeSetting === "light") {
97+
localStorage.theme = "light";
98+
} else {
99+
localStorage.removeItem("theme");
100+
}
101+
}
102+
103+
function updateCurrentTheme(themeSetting: ThemeSetting) {
104+
const theme = themeSetting === "system" ? getSystemTheme() : themeSetting;
105+
106+
document.documentElement.classList.toggle("dark", theme === "dark");
107+
}
108+
109+
function getSystemTheme(): Theme {
110+
return getDarkModeMediaQuery().matches ? "dark" : "light";
111+
}
112+
113+
type Theme = "dark" | "light";
114+
type ThemeSetting = Theme | "system";
115+
116+
function ThemeButton({
117+
isSelected,
118+
onSelected,
119+
icon: Icon,
120+
title,
121+
}: {
122+
icon: ComponentType<SVGProps<SVGSVGElement>>;
123+
isSelected: boolean;
124+
onSelected: () => void;
125+
title?: string;
126+
}) {
127+
return (
128+
<AnimatePresence>
129+
{isSelected ? (
130+
<motion.button
131+
animate={{ translateY: 0 }}
132+
initial={{ translateY: "-100%" }}
133+
exit={{ translateY: "100%" }}
134+
className={clsx(
135+
"absolute top-1/2 left-1/2 -translate-1/2 fill-zinc-500 py-2.5 hover:fill-zinc-600 dark:fill-zinc-400 dark:hover:fill-zinc-300",
136+
)}
137+
onClick={() => onSelected()}
138+
title={title}
139+
>
140+
<Icon className="size-5" />
141+
</motion.button>
142+
) : null}
143+
</AnimatePresence>
144+
);
145+
}

apps/frontend/app/root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getEnvironment } from "./utils/environment.server";
1919
import { Toaster } from "./layout/toaster";
2020
import { imagekitUrl } from "./utils/imagekit";
2121
import type { Media } from "@fxmk/payload-types";
22+
import { ThemeInitScript } from "./layout/theme";
2223

2324
export const links: Route.LinksFunction = () => [
2425
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
@@ -125,13 +126,15 @@ export function Layout({ children }: { children: React.ReactNode }) {
125126
<html
126127
lang={meta.locale ?? undefined}
127128
className="h-full scroll-pt-6 antialiased"
129+
suppressHydrationWarning={true} // Prevent hydration warning due to theme class added by client-side code
128130
>
129131
<head>
130132
<meta charSet="utf-8" />
131133
<meta name="viewport" content="width=device-width, initial-scale=1" />
132134
<Meta />
133135
<Links />
134136
<AnalyticsScript analyticsDomain={environment.analyticsDomain} />
137+
<ThemeInitScript />
135138
</head>
136139
<body className="flex h-full bg-zinc-50 dark:bg-black">
137140
<EnvironmentContext.Provider value={environment}>

0 commit comments

Comments
 (0)