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';