Low Love - King Gnu

cd

obvoso

star

Next App Router에서 다크모드를 적용해보자

MUI 컴포넌트와 next-themes 그리고 타도 FOUC

개발
Blog
Nextjs
thumbnail

next-themes로 다크모드 적용하기

MUI의 useTheme()

MUI에서도Theme Provider가 존재합니다.

MUI의 useTheme()컴포넌트는 현재 클라이언트 컴포넌트로만 사용할 수 있습니다.
여기서 문제의 근원은 React의 hook인 useTheme를 사용한다는 것인데, 이는 서버 컴포넌트와 호환되지 않습니다.

(<AppRouterCacheProvider>를 사용하면 SSR도 가능하다고 합니다)

또한, 클라이언트 컴포넌트에서 사용한다고 하더라도 새로고침 시 FOUC(깜빡이는 플래시 현상)이 발생했습니다.

해당 현상에 대해 찾아보다 next-themes 라이브러리를 발견하게 됐습니다.

next-themes의 Readme

  • ✅ 2줄의 코드로 완벽한 다크 모드 구현
  • ✅ prefers-color-scheme을 사용한 시스템 설정
  • ✅ 색상 구성표가 적용된 테마별 브라우저 UI
  • ✅ Next.js 13 지원appDir
  • ✅ 로드 시 플래시 없음(SSR 및 SSG 모두)
  • ✅ 탭과 창 전체에서 테마 동기화
  • ✅ 테마 변경 시 깜빡임 비활성화
  • ✅ 특정 테마로 페이지 강제 적용
  • ✅ 클래스 또는 데이터 속성 선택기
  • ✅ useTheme hook

로드시 플래시 없음 SSR, SSG 모두

현재 겪고 있는 문제를 해결해줄 적절한 라이브러리를 찾은 거 같습니다.
바로 next-themes를 프로젝트에 설치했습니다.

1. 라이브러리 설치

$ npm install next-themes
# or
$ yarn add next-themes

2. ThemeProvider 적용

body 안에 themes가 적용될 요소를 provider로 감싸줍니다.
next-themes가 html의 요소를 업데이트 하기 때문에 경고가 표시됩니다.
html 태그에 suppressHydrationWarning 를 추가해서 경고를 막아줍니다.

// app/layout.jsx
import { ThemeProvider } from 'next-themes'

export default function Layout({ children }) {
  return (
    <html suppressHydrationWarning>
      <head />
      <body>
        <ThemeProvider >{children}</ThemeProvider>
      </body>
    </html>
  )
}

3. 끝

끝입니다. 이제 테마가 변경될 때 마다 사용할 색상을 변수에 담아주기만 하면 됩니다.

// styles/themes.css

html.light {
  --background: #ffffff;
  --background-secondary: #eef9f8;
  --text: #333;
}

html.dark {
  --background: #212222;
  --background-secondary: #2e4644;
  --text: #eee;
}

저는 themes.css 파일에 적용될 테마의 색상 변수를 light, dark 두개의 class selector를 만들어 저장한 후, 해당 파일을 globals.css 파일에서 import하여 사용했습니다.

MUI에 theme을 적용하기까지

사용하고있는 mui의 컴포넌트들에 색상을 변경하기 위해서 시도한 방법들.. 너무 많습니다. 반나절을 썼습니다..

앞서 말했지만, MUI의 theme provider와 함께 사용하려 했는데 theme의 색상을 참고하여 렌더링 해야할 하위 컴포넌트들에서 useTheme()훅을 사용하여 theme에 접근해야하기 때문에 ISR에는 적합하지 않았습니다.

그렇다면 css파일에 class selector를 정의해두고 사용하면?

.test {
  background-color: var(--background);
  color: var(--text);
}
export default function Header() {
  return (
    <AppBar position="static" className="test">
      <Toolbar sx={{ justifyContent: "space-between" }}>
        <Typography variant="h6">obvoso blog</Typography>
        <ThemeToggle />
      </Toolbar>
    </AppBar>
  )
}

AppBar의 스타일을 직접 설정한게 아니라 className으로 적용했기 때문에, 렌더링시 우선순위가 밀려서 그런지 새로고침을 하면 기본 색상인 파란색이 먼저 렌더링 되어 깜빡이는 현상이 생겼습니다.

c46870af-4e09-4768-8924-8c7ddf3d0795--0.gif

MUI(v5)는 기본적으로 Emotion을 사용하여 CSS-in-JS 방식으로 스타일을 적용합니다.

컴포넌트가 렌더링될 때 동적으로 생성된 클래스명을 가진 스타일 시트가 문서의 <head>에 삽입되어 우선순위가 높습니다.
반면, 외부 CSS 파일에서 정의한 .test 클래스는 브라우저가 CSS 파일을 로드한 후에 적용됩니다.

따라서 초기 렌더링 시에는 MUI의 기본 스타일(파란색)이 보이고, 이후에 .test의 스타일이 적용되어 깜빡이는 현상(FOUC)이 발생한 것입니다.

적용한 방법

이를 해결하기 위해 MUI의 sx 프로퍼티를 사용해 우선순위를 정해주었습니다.

MUI의 기본 스타일과 sx는 CSS의 specificity와 적용 순서에 따라 결정되지만, 일반적으로 sx 스타일이 기본스타일을 덮어씁니다.

1. 테마 토글 버튼 만들기

우선, 토글을 클릭하면 light, dark 변환이 되는 ThemeToggle 컴포넌트를 만들어 줍니다.

토글 컴포넌트는 RCC로 만들었습니다. 클라이언트에 마운트 되기 전까지 서버는 theme 의 값을 알지 못하기 때문입니다. (서버에서 렌더하면 useTheme의 값은 undefined입니다.)

클라이언트에 마운트 하기 전에 현재 테마에 따라 UI를 렌더링하려고 하면 hydration mismatch Error가 표시됩니다.

그렇게 때문에 토글 UI를 클라이언트 페이지에서 마운트 한 후 렌더링 해야합니다.

"use client"

import { useTheme } from "next-themes"
import { useEffect, useState } from "react"

export default function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { resolvedTheme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }
  
  const handleToggle = () => {
	  setTheme(resolvedTheme === "light" ? "light" : "dark")
	}

  return (
    <FormControlLabel
      control={<IOSSwitch sx={{ m: 1 }} checked={resolvedTheme === "dark"} />}
      label=""
      onClick={handleToggle}
    />
  )
}

useTheme 훅에서 리턴해주는 값들은 아래와 같습니다.

  • theme: 활성 테마 이름
  • setTheme(name): 테마를 업데이트하는 함수. 새 테마 값을 전달하거나 콜백을 사용하여 현재 테마를 기반으로 새 테마를 설정합니다..
  • forcedTheme: 강제 페이지 테마 또는 falsy. 설정된 경우 테마 전환 UI를 비활성화해야 합니다.
  • resolvedTheme: enableSystem 이 true이고 활성 테마가 "system"인 경우 시스템 기본 설정이 "dark" 또는 "light"으로 해결되었는지 여부를 반환.
  • systemTheme: enableSystem 이 true인 경우 활성 테마가 무엇이든 시스템 테마 기본 설정("dark" 또는 "light")을 나타냅니다.
  • themesThemeProviderenableSystem: 전달된 테마 목록 (true인 경우 "system"이 추가됨)

이 중 처음 접속한 사용자일 경우, 사용자의 기본 system의 테마를 따라가도록 resolvedTheme를 사용했고,
토글 클릭시 테마를 변경하기 위해 setTheme 를 리턴 받아 사용했습니다.

2. 전역적으로 <ThemeProvider> 적용하기

layout 파일에서 ThemeProvider를 import하여 테마를 적용할 부분을 감싸줍니다.

// app/layout.tsx

import { ThemeProvider } from "next-themes"

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={inter.className}>
        <ThemeProvider attribute="class" defaultTheme="system">
          <Header />
          <main>{children}</main>
        </ThemeProvider>
      </body>
    </html>
  )
}

ThemeProvider에서 attribute="class"로 설정하면 현재 테마(light 또는 dark)에 해당하는 클래스를 <html> 요소에 추가합니다. 이 클래스를 이용하여 CSS에서 테마별 스타일을 적용할 수 있습니다.

defaultTheme=”system”옵션으로 사용자의 prefers-color-scheme에 정의된 모드를 local storage에 저장합니다.

사용자가 처음 방문시 themes가 system으로 초기화되어 사용자의 시스템을 따라가고,
이후 토글버튼을 클릭하면 local storage의 themes가 해당 mode로 대체되어 저장됩니다.

3. 컴포넌트에 테마 적용하기

테마를 적용할 컴포넌트에서는 아래와 같이 사용했습니다.

MUI 컴포넌트의 sx 프로퍼티 내에 themes.css에서 정의한 변수를 사용하여 스타일링 하였습니다.

export default function Header() {
  return (
    <AppBar
      position="static"
      sx={{
        backgroundColor: "var(--background)",
        color: "var(--primary)",
      }}
    >
      <Toolbar sx={{ justifyContent: "space-between" }}>
        <Typography variant="h6">obvoso blog</Typography>
        <ThemeToggle />
      </Toolbar>
    </AppBar>
  )
}

c46870af-4e09-4768-8924-8c7ddf3d0795--2.gif

토글을 클릭할 때마다 <html> 요소의 클래스가 변경되고, themes.css에서 정의된 해당 클래스 선택자 내부의 변수들이 적용됩니다.

next-themes를 사용하여 새로고침을 하거나 모드를 변경해도 FOUC 현상 없이 다크모드가 적용되었습니다.

회고

만약 공식문서를 잘 읽고 CSS 라이브러리로 tailwind를 사용했다면 시간을 절약할 수 있었을 것입니다.. RTFM

emotion을 사용해보고 싶었는데, 설치 후 사용을 해보니 작동하지 않았습니다.
역시 공식문서를 살펴보니, emotion은 아직 지원하지 않는다 하여 MUI를 사용했습니다. RTFM

추후 MUI의 공식문서를 따라 <AppRouterCacheProvider>를 사용하여 서버 컴포넌트와 클라이언트 컴포넌트 간에 Emotion의 스타일 캐시를 공유하여 일관성을 유지할 수 있도록 개선해 봐야겠습니다.

RTFM……

레퍼런스

star

다음 포스트

Next.js 14의 unstable_cache

use cache, 제대로 알고 사용하자

2024년 8월 16일