next-themes는 다크모드를 쉽게 구현할 수 있게 해주는 라이브러리입니다.
복잡한 설정 없이 몇 줄의 코드로 다크모드를 만들 수 있어요!
// src/main.tsx
import { ThemeProvider } from "next-themes";
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<App />
</ThemeProvider>
설명:
attribute="class": HTML에 class="dark"를 추가해서 다크모드 적용defaultTheme="system": 처음엔 컴퓨터 설정을 따라감enableSystem: 컴퓨터 다크모드 설정을 자동으로 감지// src/components/layout/global-layout.tsx
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function GlobalLayout() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// ⚠️ 중요: 이 부분이 왜 필요한지 아래에서 설명!
useEffect(() => {
setMounted(true);
}, []);
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
// mounted가 false면 기본 아이콘만 보여줌
if (!mounted) {
return <MoonIcon />;
}
// mounted가 true면 실제 테마에 맞는 아이콘 보여줌
return (
<Button onClick={toggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</Button>
);
}
상황: 친구에게 "오늘 날씨 어때?"라고 물어봤는데, 친구는 아직 밖에 나가지 않아서 모른다고 해요.
// ❌ 문제가 있는 코드
const { theme } = useTheme();
// 처음 렌더링: theme이 아직 정해지지 않음 (undefined)
// 나중에 렌더링: theme이 "dark"로 정해짐
// → 처음과 나중이 달라서 React가 경고를 냄!
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
해결책: 친구가 밖에 나가서 날씨를 확인할 때까지 기다리기
// ✅ 올바른 코드
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true); // "이제 테마를 확인했어요!"
}, []);
// 아직 확인 안 했으면 기본값 보여주기
if (!mounted) {
return <MoonIcon />; // 기본 아이콘
}
// 확인했으면 실제 테마 보여주기
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
왜 이렇게 하나요?
useEffect 실행 후: localStorage 읽음 → 테마 알게 됨mounted로 "준비됐는지" 체크해서 에러 방지!컴퓨터가 다크모드면 웹사이트도 자동으로 다크모드가 되는 기능입니다.
// 브라우저가 알아서 체크해줌
// 컴퓨터가 다크모드면 → 웹사이트도 다크모드
// 컴퓨터가 라이트모드면 → 웹사이트도 라이트모드
사용자가 "시스템 설정 따르기"를 선택하면:
// src/components/layout/global-layout.tsx
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { SunIcon, MoonIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function GlobalLayout() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
if (!mounted) {
return (
<Button variant="ghost" size="icon" disabled>
<MoonIcon className="h-5 w-5" />
</Button>
);
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="테마 변경"
>
{theme === "dark" ? (
<SunIcon className="h-5 w-5" />
) : (
<MoonIcon className="h-5 w-5" />
)}
</Button>
);
}
const { theme, resolvedTheme } = useTheme();
theme (사용자가 선택한 값):
"light" → 사용자가 라이트모드 선택"dark" → 사용자가 다크모드 선택 "system" → 사용자가 "컴퓨터 설정 따르기" 선택resolvedTheme (실제로 적용된 값):
"light" → 지금 라이트모드로 보임"dark" → 지금 다크모드로 보임예시:
// 사용자가 "시스템 설정 따르기" 선택 + 컴퓨터가 다크모드
theme === "system" // 사용자가 선택한 것
resolvedTheme === "dark" // 실제로 보이는 것
// 사용자가 "다크모드" 직접 선택
theme === "dark" // 사용자가 선택한 것
resolvedTheme === "dark" // 실제로 보이는 것 (같음)
언제 뭘 써야 하나요?
resolvedTheme 사용 (실제 보이는 모드에 맞춰야 하니까)theme 사용 (사용자가 선택한 값 저장)// ThemeProvider가 알아서 <html class="dark"> 추가해줌
<ThemeProvider attribute="class">
{/* 다크모드일 때 자동으로 */}
<html class="dark">
{/* Tailwind의 dark: 클래스가 작동함 */}
<div className="bg-white dark:bg-gray-900">
...
</div>
</html>
</ThemeProvider>
설명:
attribute="class" 설정하면 자동으로 class="dark" 추가dark: 접두사가 자동으로 작동// next-themes가 알아서 해줌
// 사용자가 다크모드 선택 → localStorage에 저장
// 페이지 새로고침 → 자동으로 복원
// 코드 작성 불필요!
// 1. 항상 mounted 체크
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// 2. 아이콘 표시할 때는 resolvedTheme 사용
{resolvedTheme === "dark" ? <SunIcon /> : <MoonIcon />}
// 3. 접근성 고려 (스크린 리더용)
<Button aria-label="테마 변경" onClick={toggleTheme}>
// 1. mounted 체크 없이 theme 바로 사용
{theme === "dark" && <DarkComponent />} // ❌ 에러 발생 가능
// 2. 서버에서 theme 사용
// 서버에서는 localStorage 없어서 theme이 undefined
1. 앱 시작
↓
2. ThemeProvider가 localStorage 확인
↓
3. 저장된 테마 있으면 → 그걸로 적용
없으면 → 시스템 테마 확인
↓
4. <html class="dark"> 또는 <html> 추가
↓
5. Tailwind의 dark: 클래스 작동
↓
6. 사용자가 버튼 클릭 → setTheme() 호출
↓
7. localStorage에 저장 + 즉시 적용
// src/main.tsx
import { ThemeProvider } from "next-themes";
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<App />
</ThemeProvider>
// src/components/theme-toggle.tsx
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { SunIcon, MoonIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="icon" disabled>
<MoonIcon className="h-5 w-5" />
</Button>
);
}
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
aria-label="테마 변경"
>
{resolvedTheme === "dark" ? (
<SunIcon className="h-5 w-5" />
) : (
<MoonIcon className="h-5 w-5" />
)}
</Button>
);
}
dark: 클래스 그냥 쓰면 됨결론: 복잡해 보이지만 실제로는 ThemeProvider 감싸고, mounted 체크하고, 버튼 만들면 끝! 🎉