Next.js로 포트폴리오 만들기 1 - next-themes로 다크모드 구현하기

포트폴리오 사이트를 만들기 시작했다

지금까지는 기획상 애니메이션 등 보여지는 요소들을 많이 구현해보지 못했는데 포트폴리오는 보여지는게 목적이다보니 궁금했던 모션이나 상호작용 등을 활용해보려한다

 

첫번째로 다크모드를 처음 시도해보려한다

 

next-themes

Next.js에서 다크모드를 비롯한 테마 변경을 쉽게 구현할 수 있도록 하는 라이브러리

클라이언트 측에서 localStorage 또는 class 속성을 사용해 테마를 적용하고 유지하도록 한다

 

주요 기능 및 이점

- useTheme() 훅을 사용해 테마를 간편하게 변경할 수 있음

- 사용자가 선택한 테마를 localStorage로 유지

- class 기반 테마를 지원해 tailwindCSS를 활용할 때 유용

- system 옵션을 사용해 사용자의 OS 테마 설정에 따라 자동으로 테마가 적용되도록 할 수 있음

 

사용법

1) next-themes 설치

pnpm install next-themes

 

 

2) ThemeProvider 설정

최상위 레이아웃 파일에 ThemeProvider를 추가해 테마 변경이 모든 페이지에 적용되도록 한다

나는 Next.js를 사용하고 있으므로 최상위 layout.tsx에 적용!

더 마음에 드는 디자인이 다크모드여서 다크모드를 기본테마로 설정했다 (defaultTheme='dark')

그리고 tailwindCSS와 호환을 위해 attribute='class'로 설정했다

"use client";

import { ThemeProvider } from "next-themes";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <ThemeProvider attribute="class" defaultTheme="dark">
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

 

 

3) tailwind.config.ts 설정

tailwind에서 다크모드를 class 기반으로 적용하도록 설정한다

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: "class",
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

 

 

4) 테마 토글 버튼 구현

임시로 사용할 토글을 만들었다

'use client';

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

export default function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <button
      onClick={() => {
        setTheme(theme === 'dark' ? 'light' : 'dark');
      }}
    >
      {theme === 'dark' ? '라이트모드' : '다크모드'}
    </button>
  );
}

 

 

Hydration 에러 해결하기

테스트를 위해 야심차게 실행시켜봤지만.. 바로 에러가 떴다

서버에서는 버튼이 '다크모드'인데 클라이언트에서는 '라이트모드'여서 두 상태가 일치하지 않는다는 것이다

etc-image-0

 

더보기

Error: Text content does not match server-rendered HTML. See more info here: https://nextjs.org/docs/messages/react-hydration-error

Text content did not match. Server: "다크모드" Client: "라이트모드"

 

useEffect 활용

클라이언트에서 마운트된 후 현재 적용된 theme을 확인해 값을 설정하도록 했다

 

// ThemeToggle.tsx
'use client';

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

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

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

  // 서버 렌더링 중에는 기본 테마 사용
  const currentTheme = mounted ? resolvedTheme : 'dark';

  return (
    <button onClick={() => setTheme(currentTheme === 'dark' ? 'light' : 'dark')}>
      {currentTheme === 'dark' ? '라이트모드' : '다크모드'}
    </button>
  );
}

 

Hydration Error는 해결됐지만, 이렇게 구현하는 경우 기본값이 다크모드이기 때문에 라이트모드로 설정한 사용자도 새로고침시 '라이트모드'라는 버튼이 렌더링 됐다가 다시 '다크모드' 버튼으로 변하는 깜빡임이 발생한다

  

토글깜빡임.gif

 

 

 

깜빡임 문제 해결 - skeleton ui 활용

이 문제를 해결하려면 서버에서부터 사용자의 localStorage에 저장된 값을 알 수 있어야한다..

html에 적용된 data-theme을 가져오는 것도, localStorage에 저장된 theme을 가져오는 것도 모두 마운트된 후에 가능하기 때문에 사용자 입장에선 이게 로딩이 되지 않은 상태라고 생각이 들었고 skeleton ui를 적용했다

 

'use client';

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

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

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

  const toggleTheme = (): void => {
    if (!theme) return;
    const newTheme = theme === 'dark' ? 'light' : 'dark';
    setTheme(newTheme);
  };

  if (!mounted) {
    return <button className='w-[120px] h-[40px] rounded-md animate-pulse bg-gray-300'>{/* 로딩 중 버튼 */}</button>;
  }

  return (
    <button
      onClick={toggleTheme}
      className={`w-[120px] h-[40px] flex items-center justify-center rounded-md transition ${
        theme === 'dark' ? 'bg-gray-800 text-white hover:bg-gray-700' : 'bg-gray-200 text-black hover:bg-gray-300'
      }`}
    >
      {theme === 'dark' ? '라이트모드' : '다크모드'}
    </button>
  );
}

스켈레톤ui 적용.gif

 

거슬리던 현상은 모두 개선이 됐다

그런데 굳이 skeleton ui를 활용하지 않아도 가능한 방법이 있을 것 같아서..

내일 한번 더 알아봐야겠다!!