next-themes dark모드 App router에서 적용하기

슬로그·2024년 1월 12일

nextjs

목록 보기
3/4
post-thumbnail

✨ next-themes

블로그를 만들면서 다크모드를 구현 어떻게 구현할까 고민하다 next-themes를 발견했다.
next-themes 문서 사이트에서는 친절히 알려주고있긴하지만, app router에서는 약간의 오류가 보였다.

처음 나타난 에러는 클라이언트에서

app-index.js:34 Warning: Prop className did not match. Server: "variable_c20c21 variable_21a6e9 light" Client: "variable_c20c21 variable_21a6e9"

이러한 에러가 발생했다.
찾아보니 서버 측 렌더링과 클라이언트 측 렌더링 간에 클래스 이름이 일치하지 않아 발생하는 문제라고 한다.

코드에 문제가 있나 살펴보다가 Layout.tsx html안에 suppressHydrationWarning를 넣지 않아서 생긴 문제 같았다.

Layout.tsx

...

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="ko"
      className={`${suit.variable} ${pre.variable}`}
      suppressHydrationWarning
    >
      <head>
        <ThemeScript />
      </head>
      <body className="relative mx-auto flex w-full max-w-[910px] flex-col px-8 lg:px-0">
        <Header />
        <main className="relative mt-36 grow">
          <Toaster />
          {children}
        </main>
        <Footer />
      </body>
    </html>
  );
}

next App router 에서는 Page router와 다르게 _app 폴더가 없다.
그래서 next-themes를 감싸주기위한 Providers가 따로 필요해 Providers.tsx를 생성해서 Layout에 감싸주어야한다.

"use client";

import { ThemeProvider } from "next-themes";
import { FC } from "react";

export const Providers: FC<{ children: React.ReactNode }> = ({ children }) => {
  return <ThemeProvider attribute="class">{children}</ThemeProvider>;
};

export default Providers;

이렇게하고나서 나는 Header에 button아이콘을 넣어 toggleTheme 함수를 만들어 dark모드와 light모드를 토글시켜줬는데 뭔가 잘 동작하지 않았다.

오류에 대해 막 찾아보다가 다른 사이트에서 제안한 어느 외국인분?의 코드를 적용했더니 다행이 정상적으로 작동했다..! (무야호)
사이트

위에 링크에 기반하여 내가 dark mode를 적용한 과정들을 적어보고자 한다. 🔨

✨ next-themes 적용하기

1️⃣

Layout 페이지에 감싸줬던 Provider 대신 ThemeScript<head> 에 넣어준다.

`Layout.tsx`

...
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="ko"
      className={`${suit.variable} ${pre.variable}`}
      suppressHydrationWarning
    >
      <head>
        <ThemeScript />
      </head>
      <body className="relative mx-auto flex w-full max-w-[910px] flex-col px-8 lg:px-0">
        <Header />
        <main className="relative mt-36 grow">
          <Toaster />
          {children}
        </main>
        <Footer />
      </body>
    </html>
  );
}

ThemeScript.tsx

type Theme = "light" | "dark";

declare global {
  interface Window {
    __theme: Theme;
    __onThemeChange: (theme: Theme) => void;
    __setPreferredTheme: (theme: Theme) => void;
  }
}

function code() {
  window.__onThemeChange = function () {};

  function setTheme(newTheme: Theme) {
    window.__theme = newTheme;
    preferredTheme = newTheme;
    document.documentElement.dataset.theme = newTheme;
    
    window.__onThemeChange(newTheme);
  }

  var preferredTheme;

  try {
    preferredTheme = localStorage.getItem("theme") as Theme;
  } catch (err) {}

  window.__setPreferredTheme = function (newTheme: Theme) {
    setTheme(newTheme);
    try {
      localStorage.setItem("theme", newTheme);
    } catch (err) {}
  };

  
  var darkQuery = window.matchMedia("(prefers-color-scheme: dark)");

  darkQuery.addEventListener("change", function (e) {
    window.__setPreferredTheme(e.matches ? "dark" : "light");
  });

  
  setTheme(preferredTheme || (darkQuery.matches ? "dark" : "light"));

  
}

export default function ThemeScript() {
  return <script dangerouslySetInnerHTML={{ __html: `(${code})();` }} />;
}

ThemeScript는 간단히 해석해보자면 다크 모드를 지원하는 테마 시스템을 설정하기 위한 JavaScript코드 인것 같다.

  1. 전역적으로 인터페이스를 설정해주고 setTheme 함수를 만들어 localStorage에 theme의 상태를 저장시켜줘야한다.
	    document.documentElement.dataset.theme = newTheme;
  1. document.documentElement는 DOM객체 인데 즉 <html> 요소를 나타내준다.
    dataset 속성은 HTML요소의 'data-*' 속성들을 가져오거나 설정할 수 있는 객체라고 한다.
    즉 data.theme의 속성을 newTheme로 설정해준다는 의미.

  2. preferredTheme함수 를 만들어 localStorage에 theme를 저장한다.

  3. window.matchMedia를 사용해 변경에 따라 'dark''light'로 변경해준다.

  4. 마지막으로 위에서 정의한 코드를 스크립트로 HTML에 삽입 하여준다. ThemeScript

코드를 다 입력해준뒤에 localStorage를 확인해보면 토글될때마다 theme가 바뀌는걸 확인할 수 있다.

2️⃣

toggle되는 버튼을 만들어줘야한다.

"use client";
import { useState, useEffect } from "react";

const SetThemeButton = () => {
  const [theme, setTheme] = useState(global.window?.__theme || "light");

  const isDark = theme === "dark";

  const toggleTheme = () => {
    global.window?.__setPreferredTheme(isDark ? "light" : "dark");
  };

  useEffect(() => {
    global.window.__onThemeChange = setTheme;
  }, []);

  return (
    <button style={{ width: "10ch", height: "auto" }} onClick={toggleTheme}>
      {isDark ? "dark" : "light"}
    </button>
  );
};

export default SetThemeButton;
  1. 전역객체 window의 theme의 상태를 setTheme에 넣어주고 global.window.setPreferredTheme를 이용해 상태를 토글시켜줘야한다.
    setPreferredTheme는 웹 어플리케이션에서 테마를 변경할 때, 테마 변경에 따라 스타일이나 레이아웃이 동적으로 변하게끔 하는데 사용된다고한다.

  2. useEffect를 사용해서 마운트 될때 onThemeChange함수를 호출해 테마의 상태를 setTheme로 변경시켜준다.

  3. 난 헤더에 토글 버튼을 넣어줄거기 때문에 토글버튼을 원하는 위치에 넣어주면 된다.

...

const SetThemeButton = dynamic(() => import("@/components/SetThemeButton"), {
  ssr: false,
  loading: () => <LoadingThemeButton />,
});

export function Header() {
  const [scrolled, setScrolled] = useState(false);
  const [selected, setSelected] = useState("");
  useEffect(() => {
    const handleScroll = () => {
      if (window.scrollY > 0) {
        setScrolled(true);
      } else {
        setScrolled(false);
      }
    };

    window.addEventListener("scroll", handleScroll);

    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []); //

  const handleSelected = (event: React.MouseEvent<HTMLLIElement>) => {
    const menuName = event.currentTarget.innerText;
    setSelected(menuName);
  };

  return (
    <header className="relative">
      <div
        className={`fixed left-1/2 z-10 h-20 w-full ${
          scrolled ? "backdrop" : "bg-white"
        } -translate-x-1/2 transform shadow-md`}
      ></div>
      <nav className="fixed left-1/2 z-20 flex w-full max-w-[910px] -translate-x-1/2 transform justify-between gap-5 py-6 sm:flex-col md:flex-row">
        <Link href="/">
          <div className="flex items-center gap-1">
            <Image src={heart_icon} alt="아이콘" width={30} height={30} />
            <h1 className="text-xl">chosule blog</h1>
          </div>
        </Link>
        <div className="flex gap-5">
          <ul className="flex items-center gap-5">
            {navMenu.map((menuName) => (
              <li key={menuName.id} className="suit" onClick={handleSelected}>
                <Link
                  href={`${menuName.path}`}
                  className={`${
                    selected === menuName.name
                      ? "font-bold"
                      : "text-neutral-900"
                  }`}
                >
                  {menuName.name}
                </Link>
              </li>
            ))}
          </ul>
          <SetThemeButton />
        </div>
      </nav>
      <div className="absolute top-[300px]"></div>
    </header>
  );
}

export default Header;

코드를 보면 알수있지만 SetThemeButton은 import 된게 아니라 함수로 들어가있다.
next의 dynamic을 이용해 동적으로 SetThemeButton을 가져와 테마 버튼이 로딩중일때는 LoadingThemeButton 를 보여주도록 설정되어있다.

4️⃣

theme에 따라 css 설정을 스타일링해줘야한다.
global.css 에서 color-sceme에 따라 지정하여 사용해줄수있다.

global.css

html,
body {
  ::-webkit-scrollbar {
    display: none;
  }
  height: 100%;
  color:var(--color-primary);
  background: var(--color-bg-primary);
}


:root {
  color-scheme: light;
  --color-bg-primary:#fff;
  --color-text-secondary:

}

:root[data-theme="dark"] {
  color-scheme: dark;
  --color-bg-primary:#15232d;
  --color-text-secondary:#fff;
}

내 코드에서는 tailwindCss를 사용하고 있는데 global에서 스타일을 변경해줄경우 일일이 다 설정해주기도 쉽지않고 상황에 따라 다른설정을 해주고 싶어서 tailwindcss로 변경해주고싶었다.

tailwind로 설정을해주기 위해서는 class를 붙여주기 위해서 js의 classList를 이용하면 된다고 한다.

아까 만들었던 ThemeScript에서 setTheme함수를 만들때 처음과 끝에 넣어주면 된다. !

	...
     function setTheme(newTheme: Theme) {
    document.documentElement.classList.remove(window.__theme);
    window.__theme = newTheme;
    preferredTheme = newTheme;
    document.documentElement.dataset.theme = newTheme;
    
    window.__onThemeChange(newTheme);
    document.documentElement.classList.add(newTheme);
  }

📃짧은후기

next app router에서 dark mode 를 적용하는게 복잡시럽지만 하고나니 뿌듯...! 스타일링해줄게 많았던 시간이였다.. 후😫

이제 토글 버튼 예쁘게 만들러 가야겠다.. !

profile
빨리가는 유일한 방법은 제대로 가는것

0개의 댓글