다크 모드 - Remix

다크 모드 - Remix

Remix 앱에 다크 모드를 추가하는 방법을 단계별로 알려드릴게요. Remix는 서버 사이드 렌더링을 기본으로 하기 때문에, 사용자가 선택한 테마를 세션 스토리지에 저장해서 서버에서 복원하는 방식으로 동작해요. remix-themes 라이브러리가 이런 흐름을 편리하게 처리해줘요.

출처: 문서

본문

Remix에서 다크 모드를 설정하는 순서를 하나씩 지나가 볼게요.

tailwind.css 파일 수정하기

app/tailwind.css 파일에 :root[class~="dark"] 셀렉터를 추가해요. 이렇게 하면 html 요소에 dark 클래스가 붙었을 때 다크 모드 스타일이 적용돼요.

.dark,
:root[class~="dark"] {
  ...;
}

remix-themes 설치하기

먼저 remix-themes를 설치해요.

npm install remix-themes

세션 스토리지와 테마 세션 리졸버 만들기

테마 값을 저장할 쿠키 기반 세션 스토리지를 만들고, 그걸 바탕으로 테마 세션 리졸버를 만들어요. 아래 코드는 remix-themes의 createThemeSessionResolver를 이용해서 세션에서 테마를 읽고 쓰는 리졸버를 만드는 예시예요. 프로덕션 환경에서는 쿠키의 domain과 secure 옵션을 함께 지정해주는 걸 잊지 마세요.

import { createThemeSessionResolver } from "remix-themes"

// You can default to 'development' if process.env.NODE_ENV is not set
const isProduction = process.env.NODE_ENV === "production"

const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "theme",
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secrets: ["s3cr3t"],
    // Set domain and secure only if in production
    ...(isProduction
      ? { domain: "your-production-domain.com", secure: true }
      : {}),
  },
})

export const themeSessionResolver = createThemeSessionResolver(sessionStorage)

Remix Themes 설정하기

루트 레이아웃에 ThemeProvider를 추가해요. loader에서 세션에 저장된 테마를 읽어 specifiedTheme로 넘겨주면, 서버가 초기 렌더링 시점에 올바른 테마를 결정할 수 있어요. PreventFlashOnWrongTheme는 서버와 클라이언트의 테마가 어긋나서 스타일이 깜빡이는 현상을 막아주는 컴포넌트예요.

import clsx from "clsx"
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from "remix-themes"

import { themeSessionResolver } from "./sessions.server"

// Return the theme from the session storage using the loader
export async function loader({ request }: LoaderFunctionArgs) {
  const { getTheme } = await themeSessionResolver(request)
  return {
    theme: getTheme(),
  }
}
// Wrap your app with ThemeProvider.
// `specifiedTheme` is the stored theme in the session storage.
// `themeAction` is the action name that's used to change the theme in the session storage.
export default function AppWithProviders() {
  const data = useLoaderData<typeof loader>()
  return (
    <ThemeProvider specifiedTheme={data.theme} themeAction="/action/set-theme">
      <App />
    </ThemeProvider>
  )
}

export function App() {
  const data = useLoaderData<typeof loader>()
  const [theme] = useTheme()
  return (
    <html lang="en" className={clsx(theme)}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  )
}

액션 라우트 추가하기

/routes/action.set-theme.ts 파일을 만들어요. 이 라우트는 사용자가 테마를 바꿀 때 그 선택을 세션 스토리지에 저장하는 역할을 해요. 파일 이름을 ThemeProvider에 전달한 themeAction 값과 일치시켜야 해요.

import { createThemeAction } from "remix-themes"

import { themeSessionResolver } from "./sessions.server"

export const action = createThemeAction(themeSessionResolver)

모드 토글 추가하기

이제 사이트에 모드 토글을 배치해서 라이트 모드와 다크 모드를 오갈 수 있게 해요. useTheme 훅에서 제공하는 setTheme으로 언제든 테마를 바꿀 수 있어요.

import { Moon, Sun } from "lucide-react"
import { Theme, useTheme } from "remix-themes"

import { Button } from "./ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "./ui/dropdown-menu"

export function ModeToggle() {
  const [, setTheme] = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme(Theme.LIGHT)}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme(Theme.DARK)}>
          Dark
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

더 알아보기

  • 다크 모드 전체 개요: 다크 모드 문서를 참고해요.
  • 다른 프레임워크별 설정: Next.js, Vite, Astro 문서를 확인해요.