SOITZ

Next.js Dark Mode

Published on

next-themes는 Next.js 프로젝트에서 라이트모드와 다크모드를 관리하고 적용할 수 있는 간편한 방법을 제공합니다.전환 시 깜빡임 없이 부드러운 경험을 제공하며, localStorage를 사용하여 사용자의 테마 선호도를 저장합니다. 또한, 사용자의 시스템 테마 설정을 자동으로 감지하여 초기 테마를 설정할 수 있는 기능을 제공하여 사용자 경험을 향상시킵니다.

next-themes 설치

npm install next-themes
# 또는
yarn add next-themes

theme-provider.tsx

next-themes를 사용하기 위해, 가장 먼저 해야 할 일은 ThemeProvider를 앱의 최상위 컴포넌트에 추가하는 것입니다. ThemeProvider를 한번 더 감싼 컴포넌트를 만듭니다.

'use client';

import { ThemeProvider as NextThemeProvider } from 'next-themes';
import { ReactNode, useEffect, useState } from 'react';

export default function ThemeProvider({ children }: { children: ReactNode }) {
  const [isMount, setMount] = useState(false);

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

  if (!isMount) {
    return null;
  }

  return <NextThemeProvider attribute="class">{children}</NextThemeProvider>;
}

attribute="class"는 테마가 적용될 때 <body> 태그의 클래스 속성을 통해 스타일이 변경되도록 설정합니다.

app/layout.tsx에 추가 ThemeProvider 추가

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

tailwind.config.ts

tailwind config에 darkMode: "class"를 추가합니다.

const config: Config = {
	darkMode: "class",
	...
}

모드를 변경할 수 있는 버튼을 만듭니다.

테마 전환 컴포넌트 생성하기

사용자가 테마를 전환할 수 있게 하려면, 테마 전환 버튼이나 선택 도구를 제공해야 합니다. useTheme 훅을 사용하여 현재 테마를 가져오고, 테마를 전환할 수 있습니다.

theme-switcher.tsx

'use client';

import { useTheme } from 'next-themes';

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

  return (
    <div>
      <span>테마: {theme}</span>
      <div className="flex gap-x-4">
        <button onClick={() => setTheme('light')}>light</button>
        <button onClick={() => setTheme('dark')}>dark</button>
        <button onClick={() => setTheme('system')}>system</button>
      </div>
    </div>
  );
}

다크모드 디자인 적용

<body className="bg-[#F2F3F5] text-slate-800 dark:bg-slate-900 dark:text-slate-100">

app/page.tsx

import ThemeSwitch from './theme-switcher';

export default function Home() {
  return (
    <div>
      <div className="bg-white text-slate-800 dark:bg-slate-800 dark:text-slate-100">
        App
      </div>
      <ThemeSwitch />
    </div>
  );
}

next-themes 라이브러리를 사용하면 Next.js 프로젝트에서 테마 관리를 손쉽게 할 수 있으며, 사용자에게 더 나은 경험을 제공할 수 있습니다. 위


theme-switch.tsx

'use client';

import { Monitor, MoonStar, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { ReactNode, RefObject, useEffect, useRef, useState } from 'react';

import { cn } from '@/lib/utils';

export default function ThemeSwitch() {
  const { setTheme } = useTheme();
  const [opened, setOpened] = useState(false);

  const wrapperRef = useRef<HTMLDivElement>(null);

  function handleSelectTheme(value: Theme) {
    setTheme(value);
    closePopup();
  }

  function togglePopup() {
    setOpened((prev) => !prev);
  }

  function closePopup() {
    setOpened(false);
  }

  return (
    <div className="relative" ref={wrapperRef}>
      <button
        onClick={togglePopup}
        className="flex h-8 w-8 items-center justify-center"
      >
        <ThemeIcon />
      </button>
      {opened && (
        <Popup
          onChange={handleSelectTheme}
          onClose={closePopup}
          wrapperRef={wrapperRef}
        />
      )}
    </div>
  );
}

function Popup({
  onChange,
  onClose,
  wrapperRef,
}: {
  wrapperRef: RefObject<HTMLDivElement>;
  onChange(theme: Theme): void;
  onClose(): void;
}) {
  const { theme } = useTheme();

  useEffect(() => {
    function handleOutsideClick(event: MouseEvent) {
      if (
        wrapperRef.current &&
        !wrapperRef.current.contains(event.target as Node)
      ) {
        onClose();
      }
    }
    document.addEventListener('mousedown', handleOutsideClick, true);
    return () => {
      document.removeEventListener('mousedown', handleOutsideClick, true);
    };
  }, [wrapperRef, onClose]);

  return (
    <div className="dark:border-white/15 absolute -right-8 top-14 w-32 overflow-hidden rounded-md border border-slate-300 bg-white shadow-2xl  dark:border-none dark:bg-slate-700">
      <div className="flex flex-col text-left">
        <ButtonTheme
          value={'light'}
          isActive={theme === 'light'}
          onClick={onChange}
        >
          Light
        </ButtonTheme>
        <ButtonTheme
          value={'dark'}
          isActive={theme === 'dark'}
          onClick={onChange}
        >
          Dark
        </ButtonTheme>
        <ButtonTheme
          value={'system'}
          isActive={theme === 'system'}
          onClick={onChange}
        >
          System
        </ButtonTheme>
      </div>
    </div>
  );
}

function ButtonTheme({
  value,
  isActive,
  onClick,
  children,
}: {
  value: Theme;
  isActive: boolean;
  onClick(value: Theme): void;
  children: ReactNode;
}) {
  const Icon = () => {
    const commonClassName = cn('text-slate-400', { 'text-network': isActive });
    switch (value) {
      case 'light':
        return <Sun className={commonClassName} />;
      case 'dark':
        return <MoonStar className={commonClassName} />;
      case 'system':
        return <Monitor className={commonClassName} />;
      default:
        return null;
    }
  };

  return (
    <button
      onClick={() => onClick(value)}
      className={cn(
        'flex w-full items-center gap-x-2 px-3 py-2 text-sm font-semibold hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-600',
        {
          'text-network dark:text-network': isActive,
        },
      )}
    >
      <Icon />
      {children}
    </button>
  );
}

function ThemeIcon() {
  const { theme, resolvedTheme } = useTheme();

  switch (theme) {
    case 'light':
      return <Sun className="text-network" />;
    case 'dark':
      return <MoonStar className="text-network" />;
    case 'system':
      if (resolvedTheme === 'light') {
        return <Sun className="text-slate-300" />;
      } else if (resolvedTheme === 'dark') {
        return <MoonStar className="text-slate-300" />;
      }
      return null;
    default:
      return null;
  }
}

type Theme = 'light' | 'dark' | 'system';