Next.js Dark Mode 적용하기

2024년 02월 29일
8

다크/라이트 모드란?

화면을 어둡게 혹은 밝게 표시할 수 있는 인터페이스 옵션이다.
사용자마다 모드 선호도가 다를 수 있기 때문에,
사용자가 자신에게 적합한 환경을 직접 선택할 수 있도록 자유를 제공하는 것이 이상적이다.

다크/라이트 각 모드의 특징과 장점은 다음과 같다.

  • Dark Mode

    • 배경을 어둡게, 텍스트와 다른 요소를 밝게 표시
    • 눈의 피로를 줄일 수 있고, 특히 야간에 눈을 편안하게 유지할 수 있다.
    • OLED 화면 기기에서 검은 배경은 픽셀을 off하므로, 에너지 소모를 줄여 배터리 수명을 연장할 수 있다.
  • Light Mode

    • 밝은 배경과 어두운 텍스트 및 요소 사용
    • 일반적인 웹사이트 및 앱의 기본테마로, 더 익숙한 UI 스타일
    • 디자인이 더 다양한 콘텐츠 유형에 적합

목표

이 블로그에 다크/라이트 모드를 적용하는 것

단순 전환 뿐만 아니라, system 옵션도 추가하려고 한다.
(사용자 기기(핸드폰, PC, 웹 브라우저) 자체의 다크/라이트 설정을 따라가는 것)

맥북 테마 설정 화면맥북 테마 설정 화면

system 설정을 따라가는 option이 최근 들어 자주 보이는 것 같다. (요새 트렌드인가..?)

아래 예시 이미지처럼,
tailwindshadcn/ui 공식 웹페이지에도
세가지 옵션이 모두 적용되어 있는 것을 볼 수 있다.

3 theme options (dark/light/system) - tailwind css 공식 홈페이지3 theme options (dark/light/system) - tailwind css 공식 홈페이지

3 theme options (dark/light/system) - shadcn/ui 공식 홈페이지3 theme options (dark/light/system) - shadcn/ui 공식 홈페이지


과거 작업 내용

다른 웹사이트(Next.js)에 다크모드를 적용했던 경험이 있다.
라이브러리 없이 직접 작업했었는데, 주요 부분은 다음과 같다.

  1. 다크/라이트 모드에서 사용할 color palette 사전 정의
  2. 마지막으로 설정한 테마 기억
    • local storage에 해당 데이터 저장
    • SSR 과정에서 테마 주입 (flickering 방지)

1번의 경우는 팀 내 디자이너분이 정해주셔서, 큰 어려움이 없었다.
2번 과정이 생각보다 번거롭고, 예외 case가 많았다.

그 때 적용했던 script 예시는 아래와 같다.

export default function DarkThemeHandler() {
  // local storage theme을 기반으로 최초 theme setting (SSR)
  const darkThemeScript = `(
    function (){
        const localStorageTheme = window.localStorage.getItem("themeMode")
        const initialTheme = localStorageTheme === "Light" ? "Light" : "Dark"
        document.body.setAttribute("data-theme", initialTheme)
    })()`
 
  return <script dangerouslySetInnerHTML={{ __html: darkThemeScript }} />
}

Next-Themes 소개

이번에도 직접 작업하려고 했으나, 엄청난 라이브러리를 발견하게 되었다.
이름 그대로 Next.js 프로젝트에 손쉽게 theme를 적용해주는 라이브러리다.

Next-Themes

라이브러리의 주요 특징은 다음과 같다. (공식 github 발췌)

  1. 코드 단 2줄로 다크모드 완벽 적용
  2. system 옵션 추가 (prefers-color-scheme 변수 활용)
  3. 최초 load시 깜빡임 없음 (SSR, SSG 모두)
  4. 탭, 윈도우 전환시 테마 유지
  5. 테마 전환시 번쩍임 없음
  6. 페이지별로 테마 구분 지정 가능
  7. class, data-theme 환경에서 모두 사용 가능
  8. useTheme hook 사용 가능

나에게는 2, 3번이 중요했다.

사용해보도록 하자!


라이브러리 적용

Next.js 14 버전 (app router), Tailwind CSS 를 사용하고 있습니다.

설치

먼저, nexth-themes 라이브러리를 설치하자.

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

Theme Provider 생성

그 다음, ThemeProvider component를 만들어준다.

layout.tsx에 바로 적용해도 되지만,
use client를 통해 client component로 사용하기 위해 분리했다.

layout.tsxuse client 선언하기는 좀 찝찝해서,,

'use client';
 
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
 
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return (
    <NextThemesProvider attribute='class' defaultTheme='system' {...props}>
      {children}
    </NextThemesProvider>
  );
}

Theme Provider 적용

만든 ThemeProviderapp/layout.tsx에 적용하자.

import { ThemeProvider } from "@/components/theme-provider"
 
export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="en" suppressHydrationWarning>
        <head />
        <body>
          <ThemeProvider>
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}

tailwind config 수정

tailwind css config도 수정해주어야 한다.

// tailwind.config.ts
 
const config = {
    darkMode : ['class'],
    ...
}

dark/light에서 사용할 color varible도 구분해서 정의하자.

/* globals.css */
 
:root {
  --white: hsl(210 40% 98%);
  --black: #121212;
 
  --background: white;
  --foreground: var(--black);
 
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
 
}
 
.dark {
  --background: var(--black);
  --foreground: var(--white);
 
  --primary: 210 40% 98%;
  --primary-foreground: 222.2 47.4% 11.2%;
}

게시글 본문에 적용

PostDetail 페이지에도 테마를 적용해주어야 한다.

이 블로그는 현재 Tailwind Typography Plugin이 적용되어 있다.

heading, paragraph, div, img 등 html 요소에 대해
tailwind에서 기본적으로 만들어둔 스타일링이 적용된다.

적용할 문서 최상위 부모 태그에 prose classname을 부여하면 된다.
자세한 내용은 위 링크를 참고하자.

아무튼, 이 블로그에는 prose가 적용되어 있기 때문에,
다크/라이트 모드를 추가 설정해주어야 한다.

어렵지 않다!

dark:prose-invert classname을 추가해주면 된다.

const PostDetail = async ({ params: { category, slug } }: Props) => {
  const post = await getPostDetail(category, slug);
  return (
    <div className='prose dark:prose-invert'>
      <PostHeader post={post} />
      <PostBody post={post} />
    </div>
  );
};

Theme Switch 버튼 생성

테마 적용은 끝났다.
이제 테마를 전환할 수 있는 스위치 버튼 컴포넌트만 만들어주면 된다.

shadcn/ui의 Dropdown Component를 설치해서 사용했다.

$ npx shadcn-ui@latest add dropdown-menu

버튼 컴포넌트는 다음과 같이 만들었다.
원하는 파일에서 import해와서 사용하면 된다.

const ThemeSwitch = () => {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme } = useTheme();
 
  // useEffect only runs on the client, so now we can safely show the UI
  useEffect(() => { setMounted(true) }, []);
 
  if (!mounted) return null;
 
  const Item = ({ t, Icon, label }) => (
    <DropdownMenuItem onClick={() => setTheme(t)} >
      <div className='flex items-center gap-2'>
        <Icon width={14} /> {label}
      </div>
      {theme === t && <Dot />}
    </DropdownMenuItem>
  );
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant='ghost' size='icon'>
          <Sun />
          <Moon />
          <span className='sr-only'>Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align='end'>
        <Item t='light' label='Light' Icon={Sun} />
        <Item t='dark' label='Dark' Icon={Moon} />
        <Item t='system' label='System' Icon={Monitor} />
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
 
export default ThemeSwitch;
 

Reference