다크/라이트 모드란?
화면을 어둡게 혹은 밝게 표시할 수 있는 인터페이스 옵션이다.
사용자마다 모드 선호도가 다를 수 있기 때문에,
사용자가 자신에게 적합한 환경을 직접 선택할 수 있도록 자유를 제공하는 것이 이상적이다.
다크/라이트 각 모드의 특징과 장점은 다음과 같다.
-
Dark Mode
- 배경을 어둡게, 텍스트와 다른 요소를 밝게 표시
- 눈의 피로를 줄일 수 있고, 특히 야간에 눈을 편안하게 유지할 수 있다.
- OLED 화면 기기에서 검은 배경은 픽셀을 off하므로, 에너지 소모를 줄여 배터리 수명을 연장할 수 있다.
-
Light Mode
- 밝은 배경과 어두운 텍스트 및 요소 사용
- 일반적인 웹사이트 및 앱의 기본테마로, 더 익숙한 UI 스타일
- 디자인이 더 다양한 콘텐츠 유형에 적합
목표
이 블로그에 다크/라이트 모드를 적용하는 것
단순 전환 뿐만 아니라, system 옵션도 추가하려고 한다.
(사용자 기기(핸드폰, PC, 웹 브라우저) 자체의 다크/라이트 설정을 따라가는 것)
맥북 테마 설정 화면
system 설정을 따라가는 option이 최근 들어 자주 보이는 것 같다. (요새 트렌드인가..?)
아래 예시 이미지처럼,
tailwind와 shadcn/ui 공식 웹페이지에도
세가지 옵션이 모두 적용되어 있는 것을 볼 수 있다.
3 theme options (dark/light/system) - tailwind css 공식 홈페이지
3 theme options (dark/light/system) - shadcn/ui 공식 홈페이지
과거 작업 내용
다른 웹사이트(Next.js)에 다크모드를 적용했던 경험이 있다.
라이브러리 없이 직접 작업했었는데, 주요 부분은 다음과 같다.
- 다크/라이트 모드에서 사용할 color palette 사전 정의
- 마지막으로 설정한 테마 기억
- local storage에 해당 데이터 저장
- SSR 과정에서 테마 주입 (flickering 방지)
1번의 경우는 팀 내 디자이너분이 정해주셔서, 큰 어려움이 없었다.
2번 과정이 생각보다 번거롭고, 예외 case가 많았다.
그 때 적용했던 script 예시는 아래와 같다.
export default function DarkThemeHandler() {
// local storage theme을 기반으로 최초 theme setting (SSR)
const darkThemeScript = `(
function (){
const localStorageTheme = window.localStorage.getItem("themeMode")
const initialTheme = localStorageTheme === "Light" ? "Light" : "Dark"
document.body.setAttribute("data-theme", initialTheme)
})()`
return <script dangerouslySetInnerHTML={{ __html: darkThemeScript }} />
}
Next-Themes 소개
이번에도 직접 작업하려고 했으나, 엄청난 라이브러리를 발견하게 되었다.
이름 그대로 Next.js 프로젝트에 손쉽게 theme를 적용해주는 라이브러리다.
Next-Themes
라이브러리의 주요 특징은 다음과 같다. (공식 github 발췌)
- 코드 단 2줄로 다크모드 완벽 적용
-
system 옵션 추가 (
prefers-color-scheme
변수 활용) - 최초 load시 깜빡임 없음 (SSR, SSG 모두)
- 탭, 윈도우 전환시 테마 유지
- 테마 전환시 번쩍임 없음
- 페이지별로 테마 구분 지정 가능
- class, data-theme 환경에서 모두 사용 가능
useTheme
hook 사용 가능
나에게는 2, 3번이 중요했다.
사용해보도록 하자!
라이브러리 적용
Next.js 14 버전 (app router), Tailwind CSS 를 사용하고 있습니다.
설치
먼저, nexth-themes 라이브러리를 설치하자.
$ npm install next-themes
# or
$ yarn add next-themes
Theme Provider 생성
그 다음, ThemeProvider
component를 만들어준다.
layout.tsx
에 바로 적용해도 되지만,
use client
를 통해 client component로 사용하기 위해 분리했다.
layout.tsx
에 use client
선언하기는 좀 찝찝해서,,
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider attribute='class' defaultTheme='system' {...props}>
{children}
</NextThemesProvider>
);
}
Theme Provider 적용
만든 ThemeProvider
를 app/layout.tsx
에 적용하자.
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout({ children }: RootLayoutProps) {
return (
<>
<html lang="en" suppressHydrationWarning>
<head />
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
</>
)
}
tailwind config 수정
tailwind css config도 수정해주어야 한다.
// tailwind.config.ts
const config = {
darkMode : ['class'],
...
}
dark/light에서 사용할 color varible도 구분해서 정의하자.
/* globals.css */
:root {
--white: hsl(210 40% 98%);
--black: #121212;
--background: white;
--foreground: var(--black);
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
}
.dark {
--background: var(--black);
--foreground: var(--white);
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
}
게시글 본문에 적용
PostDetail
페이지에도 테마를 적용해주어야 한다.
이 블로그는 현재 Tailwind Typography Plugin이 적용되어 있다.
heading, paragraph, div, img 등 html 요소에 대해
tailwind에서 기본적으로 만들어둔 스타일링이 적용된다.
적용할 문서 최상위 부모 태그에 prose
classname을 부여하면 된다.
자세한 내용은 위 링크를 참고하자.
아무튼, 이 블로그에는 prose가 적용되어 있기 때문에,
다크/라이트 모드를 추가 설정해주어야 한다.
어렵지 않다!
dark:prose-invert
classname을 추가해주면 된다.
const PostDetail = async ({ params: { category, slug } }: Props) => {
const post = await getPostDetail(category, slug);
return (
<div className='prose dark:prose-invert'>
<PostHeader post={post} />
<PostBody post={post} />
</div>
);
};
Theme Switch 버튼 생성
테마 적용은 끝났다.
이제 테마를 전환할 수 있는 스위치 버튼 컴포넌트만 만들어주면 된다.
shadcn/ui의 Dropdown Component를 설치해서 사용했다.
$ npx shadcn-ui@latest add dropdown-menu
버튼 컴포넌트는 다음과 같이 만들었다.
원하는 파일에서 import해와서 사용하면 된다.
const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => { setMounted(true) }, []);
if (!mounted) return null;
const Item = ({ t, Icon, label }) => (
<DropdownMenuItem onClick={() => setTheme(t)} >
<div className='flex items-center gap-2'>
<Icon width={14} /> {label}
</div>
{theme === t && <Dot />}
</DropdownMenuItem>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon'>
<Sun />
<Moon />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<Item t='light' label='Light' Icon={Sun} />
<Item t='dark' label='Dark' Icon={Moon} />
<Item t='system' label='System' Icon={Monitor} />
</DropdownMenuContent>
</DropdownMenu>
);
};
export default ThemeSwitch;