🌜 Next.js로 다크모드 구현하기 (next-themes)
최근에는 다크 모드가 웹사이트의 기본 기능처럼 제공된다. 특히 블로그, 문서 뷰어, 개발자 도구처럼 텍스트 중심의 콘텐츠를 제공하는 서비스에서는 가독성이 사용자 경험의 핵심 요소가 된다. 밝은 화면은 밤이나 어두운 환경에서는 눈부심을 유발해 피로감을 줄 수 있기 때문이다. Next.js를 이용하면 쉽게 다크 모드를 구현할 수 있는데 단계별로 알아보자.
* next-themes
Next.js에서 다크 모드를 간단하게 구현하는 방법은 next-themes 라이브러리를 사용하는 것이다. 개발자가 직접 코드를 작성하지 않아도 다크/라이트 테마 토글 기능, 사용자의 시스템 설정 감지 기능, 설정된 테마를 localStorage에 저장하는 기능 등이 내장되어 있다. 사용 방법도 아주 간단한데 다크 모드를 적용할 layout에서 자식 컴포넌트를 <ThemeProvider>로 감싸주기만 하면 된다.
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="data-theme"
defaultTheme="system"
enableSystem={true}
>
{children}
</ThemeProvder>
);
}
ThemeProvider 컴포넌트는 여러 옵션을 props로 제공한다. 그중에서도 자주 사용되는 주요 설정은 다음과 같다.
- attribute
- 다크 모드 테마를 어떤 방식으로 HTML 태그에 적용할지를 정의한다.
- “class”의 경우 <html class="dark">로 적용되며 “data-theme”의 경우 <html data-theme="dark">의 형태로 적용된다.
- Tailwind 기반으로 구현하는 경우 class를 사용하며 직접 CSS를 작성하여 커스터마이징하는 경우 data-theme 방식을 주로 사용한다.
- defaultTheme
- 사용자 설정이 없는 경우 어떤 테마를 기본값으로 사용할지를 지정한다.
- “light”, “dark”, “system” 세 가지 옵션이 있다.
- enableSystem
- defaultTheme 이 “system” 일 때 운영체제의 설정을 감지할지의 여부를 정의한다.
- true로 설정 시 사용자의 시스템 테마에 따라 자동으로 전환된다.
- storageKey
- localStorage에 테마 설정을 저장할 때 사용할 키 이름을 지정할 수 있다.
- 기본값은 “theme”이다.
ThemeProvide를 설정한 이후에는 useTheme() 훅을 사용하여 현재 테마 상태를 가져오고 이를 토글 할 수 있는 버튼만 추가하면 된다.
'use client';
import { useTheme } from 'next-themes';
import { MdOutlineDarkMode, MdOutlineLightMode } from 'react-icons/md';
export const Header: React.FC = () => {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
// 다크 모드 토글 버튼
<div onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{resolvedTheme === 'light' ? (
<MdOutlineDarkMode size={24} />
) : (
<MdOutlineLightMode size={24} />
)}
</div>
);
}
resolvedTheme의 경우 next-themes에서 제공하는 값 중 하나로 현재 적용된 테마를 의미한다. 사용자가 theme 값을 “system”으로 설정한 경우 브라우저의 시스템 테마에 따라 실제 적용된 테마가 달라지므로 이때의 정확한 상태를 파악하기 위해 사용된다.
이렇게 기본적인 다크 모드 세팅이 끝나고 나면 추가적으로 고려해야 할 사항이 있다. 바로 초기 렌더링 시 발생할 수 있는 깜빡임 현상 다른 말로 FOUC(Flash of Unstyled Content) 현상이다.
Next.js를 이용한 서버사이드 렌더링을 구현하면 사용자가 페이지에 접속했을 때 localStorage에 저장되어 있는 다크 모드 설정을 서버 측에서는 알 수 없고 이로 인해 사용자가 이전에 다크 모드를 설정해두었더라도 기본 테마인 라이트 테마로 HTML을 렌더링 하게 된다.
이후 클라이언트 측에서 자바스크립트가 실행되면서 localStorage의 다크 모드 설정에 따라 테마를 바꾸게 되는데 이 과정에서 UI가 한 번 깜빡이는 현상이 발생한다.
이러한 현상을 방지하기 위해 클라이언트가 테마 정보를 읽기 전까지 테마에 의존하는 UI 요소를 일시적으로 숨기거나 렌더링 하지 않을 필요가 있다. 따라서 useTheme() 훅을 사용할 때 mounted 상태를 활용하여 클라이언트에서 마운트가 완료되었을 때만 UI를 보여주도록 처리한다.
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { MdOutlineDarkMode, MdOutlineLightMode } from 'react-icons/md';
export const Header: React.FC = () => {
const { theme, resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// 클라이언트에서 마운트가 되지 않으면 렌더링 하지 않는다.
if (!mounted) return null;
return (
// 다크 모드 토글 버튼...(생략)
);
}
추가로 SSR과 CSR이 일치하지 않을 경우 Hydration mismatch 경고가 발생하게 되는데 이를 숨기고 싶다면 HTML 태그 혹은 오류가 발생하는 태그에 suppressHydrationWarning 을 추가하면 된다.
<html lang="ko" suppressHydrationWarning>
모든 설정을 마치고 나면 마지막으로 다크 모드와 라이트 모드에서 각각 적용할 CSS 스타일을 globals.css 파일에 정의해 주기만 하면 된다. Tailwind를 사용한다면 dark: 접두어를 사용하여 스타일을 분기할 수 있지만 직접 CSS를 작성하는 경우에는 ThemeProvider의 attribute 설정에 따라 [data-theme="dark"] 혹은 .dark 셀렉터를 활용해 테마별 스타일을 적용할 수 있다.
/* 라이트 테마 */
:root {
--bg-color: #F8F9FA;
--text-color: #212529;
}
/* 다크 테마 */
html[data-theme="dark"] {
--bg-color: #121212;
--text-color: #E0E0E0;
}
* CSS 변수를 정의해두면 전체 스타일을 통일하여 유지 보수를 쉽게 할 수 있다.