Remix Framework에 TailwindCSS Dark Mode(다크모드) 적용하기
Remix Framework TailwindCSS Dark Mode, 다크 모드
안녕하세요?
Remix Framework에 TailwindCSS를 적용하는 방법을 저번에 배웠는데요.
https://cpro95.tistory.com/674
오늘은 다크 모드 적용에 대해 알아보겠습니다.
TailwindCSS의 다크 모드의 작동원리는 <html class='dark'> 즉, html 태그에 class가 'dark'일 때는 다크 모드로 작동하게 됩니다.
html 태그의 class에 'dark'이 없으면 다크 모드는 적용되지 않게 됩니다.
그래서 TailwindCSS의 홈페이지에서도 다크 모드로 스위칭하는 방법을 document.DocumentElement의 classList를 조정해서 스위치 하라고 하는데요.
그래서 Remix에도 적용해 봤는데 이왕 적용하는 김에 Remix의 대부인 Kent C. Dodds의 방식을 사용해 볼까 합니다.
Kent C. Dodds 님의 깃 헙에서 theme 관련 코드만 추려내서 적용하겠습니다.
0. tailwind.config.js
TailwindCSS에서 다크 모드는 기본 옵션이 아니기 때문에 아래 파일과 같이 tailwind.config.js 파일을 수정해 줘야 합니다.
module.exports = {
content: ["./app/**/*.{ts,tsx,js,jsx}"],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
};
darkMode 가 class 이면 사용자가 다크 모드를 스위칭할 수 있고, 만약에 darkMode가 media이면 운영체제의 다크 모드에 따라가게 됩니다.
1. theme.server.ts
먼저, /app/utils 폴더에 theme.server.ts 파일을 만듭니다.
그리고 이 파일에 theme 관련 쿠키를 만들도록 하겠습니다.
import { createCookieSessionStorage } from 'remix'
import { Theme, isTheme } from './theme-provider'
const themeStorage = createCookieSessionStorage({
cookie: {
name: 'my_theme',
secure: true,
secrets: [`${process.env.SESSION_SECRET}`],
sameSite: 'lax',
path: '/',
expires: new Date('2088-10-18'),
httpOnly: true,
},
})
async function getThemeSession(request: Request) {
const session = await themeStorage.getSession(request.headers.get('Cookie'))
return {
getTheme: () => {
const themeValue = session.get('theme')
return isTheme(themeValue) ? themeValue : Theme.LIGHT
},
setTheme: (theme: Theme) => session.set('theme', theme),
commit: () => themeStorage.commitSession(session),
}
}
export { getThemeSession }
위 코드를 보면 themeStorage라는 쿠키 세션 스토리지를 만들고 getThemeSession 함수를 만들어서 언제든지 이 쿠키를 가져올 수 있게 해주고 있습니다.
그리고 getThemeSession으로 가져온 themeSession을 이용해서 getTheme 함수와 setTheme 함수, commit 함수를 이용할 수 있게 해주는 거죠.
그런데 두 번째 줄에 보면 theme-provider 파일이 없죠. 이제 만들어 볼까요?
2. theme-provider.tsx
/app/utils 폴더에 theme-provider.tsx 파일을 만듭시다.
import * as React from "react";
import { useFetcher } from "remix";
enum Theme {
DARK = "dark",
LIGHT = "light",
}
const themes: Array<Theme> = Object.values(Theme);
type ThemeContextType = [
Theme | null,
React.Dispatch<React.SetStateAction<Theme | null>>
];
const ThemeContext = React.createContext<ThemeContextType | undefined>(
undefined
);
ThemeContext.displayName = "ThemeContext";
const prefersLightMQ = "(prefers-color-scheme: light)";
const getPreferredTheme = () =>
window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK;
function ThemeProvider({
children,
specifiedTheme,
}: {
children: React.ReactNode;
specifiedTheme: Theme | null;
}) {
const [theme, setTheme] = React.useState<Theme | null>(() => {
// On the server, if we don't have a specified theme then we should
// return null and the clientThemeCode will set the theme for us
// before hydration. Then (during hydration), this code will get the same
// value that clientThemeCode got so hydration is happy.
if (specifiedTheme) {
if (themes.includes(specifiedTheme)) return specifiedTheme;
else return null;
}
// there's no way for us to know what the theme should be in this context
// the client will have to figure it out before hydration.
if (typeof window !== "object") return null;
return getPreferredTheme();
});
const persistTheme = useFetcher();
// TODO: remove this when persistTheme is memoized properly
const persistThemeRef = React.useRef(persistTheme);
React.useEffect(() => {
persistThemeRef.current = persistTheme;
}, [persistTheme]);
const mountRun = React.useRef(false);
React.useEffect(() => {
if (!mountRun.current) {
mountRun.current = true;
return;
}
if (!theme) return;
persistThemeRef.current.submit(
{ theme },
{ action: "action/set-theme", method: "post" }
);
}, [theme]);
React.useEffect(() => {
const mediaQuery = window.matchMedia(prefersLightMQ);
const handleChange = () => {
setTheme(mediaQuery.matches ? Theme.LIGHT : Theme.DARK);
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
return (
<ThemeContext.Provider value={[theme, setTheme]}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const context = React.useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
function isTheme(value: unknown): value is Theme {
return typeof value === "string" && themes.includes(value as Theme);
}
export { ThemeProvider, useTheme, themes, Theme, isTheme };
Kent C. Dodds 님이 만들었는데 상당히 어렵습니다.
우리가 볼 거는 아래 enum으로 Theme 타입이 DARK와 LIGHT 두 개가 있다는 것만 알면 됩니다.
enum Theme {
DARK = "dark",
LIGHT = "light",
}
theme-provider는 React의 useContext 훅을 이용해서 ThemeContext를 만들었는데요.
useTheme 함수를 이용해서 ThemeContext를 이용할 수 있고 ThemeContext는 theme과 setTheme함수를 돌려줍니다.
theme는 현재 theme 정보를 가지고 있고요.
이제 theme-provider를 이용해서 전체 리액트 컴포넌트를 감쌰야겠죠.
3. root.tsx
/app 폴더의 root.tsx 파일을 엽니다.
import {
json,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "remix";
import type { MetaFunction, LinksFunction, LoaderFunction } from "remix";
import { useTheme, ThemeProvider, Theme } from "./utils/theme-provider";
import { getThemeSession } from "./utils/theme.server";
import styles from "./tailwind.css";
import invariant from "tiny-invariant";
export const meta: MetaFunction = () => {
return { title: "New Remix App", description: "리믹스 프레임워크 강좌" };
};
export const links: LinksFunction = () => {
return [
{
rel: "stylesheet",
href: styles,
},
];
};
type LoaderData = {
requestInfo: {
session: {
theme: Theme | null;
};
};
};
export const loader: LoaderFunction = async ({ request }) => {
require("dotenv").config();
const themeSession = await getThemeSession(request);
return json<LoaderData>({
requestInfo: {
session: {
theme: themeSession.getTheme(),
},
},
});
};
function Document({ children }: { children: React.ReactNode }) {
const [theme] = useTheme();
invariant(theme, "theme must be set");
return (
<html lang="en" className={theme}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
export default function App() {
const data = useLoaderData<LoaderData>();
return (
<ThemeProvider specifiedTheme={data.requestInfo.session.theme}>
<Document>
<Outlet />
</Document>
</ThemeProvider>
);
}
loader 함수를 이용해서 themeSession에서 theme 정보를 가져온 다음 그 theme을 html 태그에 적용시켰습니다.
<html lang="en" className={theme}>
그리고 마지막으로 ThemeProvider를 모든 컴포넌트를 감싸는 최상위 컴포넌트로 두었습니다.
이제 준비 작업은 끝났는데요.
테스트를 해보겠습니다.
4. index.tsx
/app/routes 폴더의 index.tsx 파일입니다.
import { Link } from "remix";
export default function Index() {
return (
<div className="w-full flex flex-col items-center justify-center py-12
leading-6 space-y-6 dark:bg-gray-700">
<h1 className="text-blue-700 text-2xl dark:text-blue-300">
Welcome to Remix
</h1>
<Link
to="/movies"
className="py-2.5 px-5 mr-2 mb-2 text-sm font-medium text-gray-900
focus:outline-none bg-white rounded-lg border border-gray-200
hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4
focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800
dark:text-gray-400 dark:border-gray-600 dark:hover:text-white
dark:hover:bg-gray-700"
>
Go to Movies
</Link>
</div>
);
}
위 코드를 잘 보시면 dark:로 시작하는 CSS가 있습니다.
이 dark:로 시작하는 CSS가 바로 Tailwind의 다크 모드에서 적용될 CSS인 거죠.
그럼, 다크 모드를 스위칭할 수 있는 버튼이 있어야겠죠.
5. darkmode.tsx
/app 폴더 밑에 components 폴더를 만들고 그 밑에 darkmode.tsx파일을 만듭시다.
/app/components/darkmode.tsx
import { Theme, useTheme } from "~/utils/theme-provider";
export default function DarkMode() {
const [theme, setTheme] = useTheme();
return (
<button
id="theme-toggle"
type="button"
className="rounded-lg p-2.5 text-sm text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-700"
onClick={() => {
setTheme((previousTheme) =>
previousTheme === Theme.DARK ? Theme.LIGHT : Theme.DARK
);
}}
>
<svg
id="theme-toggle-dark-icon"
className={`${theme === Theme.DARK ? "hidden" : ""} h-5 w-5`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg
id="theme-toggle-light-icon"
className={`${theme === Theme.LIGHT ? "hidden" : ""} h-5 w-5`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
);
}
darkmode 컴포넌트는 한 개의 커다란 button 태그를 리턴하는데요.
button을 클릭하면 아래 코드와 같이 previousTheme에 의해 Theme.LIGHT와 Theme.DARK가 스위치 되게 됩니다.
onClick={() => {
setTheme((previousTheme) =>
previousTheme === Theme.DARK ? Theme.LIGHT : Theme.DARK
);
}}
이제 이 darkmode.tsx 컴포넌트를 index.tsx 파일에 넣어 보겠습니다.
<DarkMode />
<h1 className="text-blue-700 text-2xl dark:text-blue-300">
Welcome to Remix
</h1>
index.tsx파일에서 h1 태그 위에 DarkMode 컴포넌트를 추가했습니다.
클릭해 볼까요?
작동이 잘 되네요.
다크 모드로 전환한 상태에서 브라우저의 새로고침 버튼을 눌러보면 다시 LIGHT 모드로 돌아가는 걸 볼 수 있을 겁니다.
왜냐하면 현재 만들어진 코드는 클라이언트 위에서의 다크모드 스위칭인데요.
우리가 themeSession을 만들었기 때문에 서버사이드 단에서 theme 정보를 세션 업데이트해줘야 합니다.
Kent C. Dodds님의 코드를 자세히 보면 theme-provider에서 fetcher를 이용해서 submit 해주는데요.
어디로 submit 하냐면 action/set-theme로 method="post" 방식으로 submit 합니다.
이 코드까지 완성해야 Remix에서는 진정한 다크 모드가 완성된다고 생각합니다.
6. set-theme.tsx
/app/routes 폴더 밑에 action 폴더를 만들고 set-theme.tsx 파일을 만들도록 합시다.
import { json, redirect } from "remix";
import type { ActionFunction } from "remix";
import { getThemeSession } from "~/utils/theme.server";
import { isTheme } from "~/utils/theme-provider";
export const action: ActionFunction = async ({ request }) => {
const themeSession = await getThemeSession(request);
const requestText = await request.text();
const form = new URLSearchParams(requestText);
const theme = form.get("theme");
if (!isTheme(theme))
return json({
success: false,
message: `theme value of ${theme} is not a valid theme.`,
});
themeSession.setTheme(theme);
return json(
{ success: true },
{
headers: { "Set-Cookie": await themeSession.commit() },
}
);
};
export const loader = () => redirect("/", { status: 404 });
set-theme.tsx 파일은 action 함수와 loader 함수만 있습니다.
loader 함수는 그냥 루트로 redirect 하고 있고, action 함수만 복잡하게 있는데요.
아까 useFetcher를 통해서 submit 한 경로가 바로 action/set-theme입니다.
아래 코드는 theme-provider 코드의 일부인데요.
theme이 바뀔때 마다 fetcher를 이용해서 submit해주고 있습니다.
React.useEffect(() => {
if (!mountRun.current) {
mountRun.current = true;
return;
}
if (!theme) return;
persistThemeRef.current.submit(
{ theme },
{ action: "action/set-theme", method: "post" }
);
}, [theme]);
그래서 최종적으로는 action 함수에서 처리하게 되는데요.
themeSession.setTheme(theme)으로 themeSession 정보를 업데이트해주고 있습니다.
이제 다시 다크 모드를 클릭하고 다시 브라우저를 리로딩해볼까요?
이제는 서버사이드단에서 theme 세션을 저장하고 있고, 클라이언트단에서도 완벽히 작동하는 코드가 완성되었습니다.
다시 한번 Kent C. Dodds님에게 감사드립니다.