
블로그를 만들면서 다크모드를 구현 어떻게 구현할까 고민하다 next-themes를 발견했다.
next-themes 문서 사이트에서는 친절히 알려주고있긴하지만, app router에서는 약간의 오류가 보였다.
처음 나타난 에러는 클라이언트에서
app-index.js:34 Warning: Prop
classNamedid 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를 적용한 과정들을 적어보고자 한다. 🔨
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코드 인것 같다.
document.documentElement.dataset.theme = newTheme;
document.documentElement는 DOM객체 인데 즉 <html> 요소를 나타내준다.
dataset 속성은 HTML요소의 'data-*' 속성들을 가져오거나 설정할 수 있는 객체라고 한다.
즉 data.theme의 속성을 newTheme로 설정해준다는 의미.
preferredTheme함수 를 만들어 localStorage에 theme를 저장한다.
window.matchMedia를 사용해 변경에 따라 'dark' 와 'light'로 변경해준다.
마지막으로 위에서 정의한 코드를 스크립트로 HTML에 삽입 하여준다. ThemeScript
코드를 다 입력해준뒤에 localStorage를 확인해보면 토글될때마다 theme가 바뀌는걸 확인할 수 있다.

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;
전역객체 window의 theme의 상태를 setTheme에 넣어주고 global.window.setPreferredTheme를 이용해 상태를 토글시켜줘야한다.
setPreferredTheme는 웹 어플리케이션에서 테마를 변경할 때, 테마 변경에 따라 스타일이나 레이아웃이 동적으로 변하게끔 하는데 사용된다고한다.
useEffect를 사용해서 마운트 될때 onThemeChange함수를 호출해 테마의 상태를 setTheme로 변경시켜준다.
난 헤더에 토글 버튼을 넣어줄거기 때문에 토글버튼을 원하는 위치에 넣어주면 된다.
...
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 를 보여주도록 설정되어있다.
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 를 적용하는게 복잡시럽지만 하고나니 뿌듯...! 스타일링해줄게 많았던 시간이였다.. 후😫
이제 토글 버튼 예쁘게 만들러 가야겠다.. !
