다크 모드를 제대로 구현하려면 생각할 게 많아요. 사용자 시스템 설정을 감지하고, 깜빡임 없이 테마를 전환하고, 접근성까지 고려해야 하죠. 하지만 차근차근 따라하다 보면 생각보다 어렵지 않답니다!
이 가이드에서는 실무에서 바로 사용할 수 있는 완성도 높은 다크 모드 구현 방법을 다룹니다. 복잡한 이론보다는 실제 동작하는 코드에 집중해서 설명할게요.
시스템 테마 설정 감지하기
사용자가 OS에서 다크 모드로 설정해 놓았다면, 우리 웹앱도 다크 모드로 표시되는 게 자연스럽겠죠? prefers-color-scheme
미디어 쿼리로 사용자의 시스템 테마 설정을 감지할 수 있어요.
핵심은 useSyncExternalStore
훅을 사용하는 거예요. 이 훅이 하는 일을 간단히 설명하면:
- 외부 저장소(여기서는 시스템 테마 설정)의 변경사항을 실시간으로 감지
- React 18의 동시성 렌더링에서 UI 일관성 보장 (Tearing 방지)
const MEDIA = '(prefers-color-scheme: dark)';
const subscribeSystemDark = (onStoreChange: () => void) => {
const mediaQuery = window.matchMedia(MEDIA);
mediaQuery.addEventListener('change', onStoreChange);
return () => mediaQuery.removeEventListener('change', onStoreChange);
};
const getSystemDarkSnapshot = () => {
return window.matchMedia(MEDIA).matches;
};
const getServerSnapshot = () => {
return undefined;
};
export const useSystemDark = () => {
return useSyncExternalStore(
subscribeSystemDark,
getSystemDarkSnapshot,
getServerSnapshot
);
};
useSyncExternalStore
에 대해 더 자세히 알고 싶다면 JSer.dev의 블로그를 참고해보세요!
다크 모드 설정 관리하기
이제 시스템 테마를 감지했으니, 사용자가 직접 테마를 선택할 수 있는 기능도 만들어야겠죠. 사용자는 다음 세 가지 중 하나를 선택할 수 있어야 해요:
- 시스템 설정 따르기 (기본값)
- 라이트 모드 고정
- 다크 모드 고정
import {
isDarkMode,
mergeDefaultOptions,
type Options,
type Theme,
} from '~/entities/theme/lib';
import { useSystemDark } from '~/entities/theme/model/use-system-dark';
export const useDark = (options?: Options) => {
const { storageKey } = mergeDefaultOptions(options);
const [theme, setTheme] = useLocalStorageState<Theme>(storageKey, {
defaultValue: 'system',
});
const isSystemDark = useSystemDark();
const isDark = useMemo(
() => isDarkMode(theme, isSystemDark),
[theme, isSystemDark]
);
const toggleDark = () => {
theme === 'system'
? setTheme(isSystemDark ? 'light' : 'dark')
: setTheme('system');
};
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
if (
(theme === 'dark' && isSystemDark) ||
(theme === 'light' && !isSystemDark)
) {
setTheme('system');
}
}, [isDark, isSystemDark, setTheme, theme]);
return { isDark, toggleDark };
};
이 훅은 한 가지 문제가 있어요. 여러 컴포넌트에서 동시에 사용하면 상태가 동기화되지 않는 현상이 발생할 수 있거든요. 이 문제는 zustand 같은 전역 상태 관리 라이브러리로 해결할 수 있어요!
테마 커스터마이징
다크/라이트 모드 외에도 사용자가 원하는 대로 테마를 꾸밀 수 있다면 더 좋겠죠? 색상 팔레트나 모서리 둥긂 정도 같은 것들을 커스터마이징할 수 있게 해봅시다.
export const ThemeCustomizer = () => {
const [config, setConfig] = useLocalStorageState<Config>('config', {
defaultValue: DEFAULT_CONFIG,
});
const handleConfigChange = (key: keyof Config, value: Config[keyof Config]) => {
if (typeof value === 'string' && key === 'theme') {
document.body.classList.remove(`theme-${config?.theme}`);
document.body.classList.add(`theme-${value}`);
} else if (typeof value === 'number' && key === 'radius') {
document.body.style.setProperty('--radius', `${value}rem`);
}
if (config === undefined) {
return setConfig(DEFAULT_CONFIG);
}
setConfig({ ...config, [key]: value });
};
// ... 나머지 렌더링 로직
};
설정이 변경될 때마다 DOM에 직접 클래스나 스타일을 적용해서 즉시 반영되도록 했어요. 사용자가 실시간으로 변화를 볼 수 있어서 훨씬 좋은 경험을 제공할 수 있답니다!
깜빡임 없는 테마 로딩
다크 모드를 구현할 때 가장 신경 쓰이는 부분이 바로 페이지 로딩 시 깜빡임 문제예요. 사용자가 다크 모드로 설정해 놓았는데, 페이지가 처음엔 라이트 모드로 잠깐 보였다가 다크 모드로 바뀐다면… 상당히 어색하겠죠? 😅
이 문제를 해결하는 핵심은 React가 렌더링되기 전에 미리 테마를 설정하는 것이에요.
1단계: 기본 HTML 구조
아직 깜빡임 문제가 있는 상태
핵심 아이디어: 브라우저가 스크립트 태그를 만나면 실행이 완료될 때까지 렌더링을 중단해요. 이 특성을 이용해서 테마를 미리 설정하고 나서 렌더링하도록 하는 거죠!
이제 사용자는 깜빡임 없이 처음부터 올바른 테마로 설정된 화면을 볼 수 있어요! 🎉
테마 전환 애니메이션과 접근성
테마가 바뀔 때 부드러운 애니메이션이 있으면 더 좋은 사용자 경험을 만들 수 있어요. 하지만 접근성도 함께 고려해야 합니다!
<Button className="transition-colors">
테마 변경 버튼
</Button>
<button type="button" onClick={toggleTheme} className="flex">
<div className="i-lucide-sun scale-100 dark:scale-0 transition-transform duration-300 rotate-0 dark:-rotate-90" />
<div className="i-lucide-moon absolute scale-0 dark:scale-100 transition-transform duration-300 rotate-90 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</button>
접근성 주의사항: 애니메이션은 광과민성 간질이나 전정기관 장애가 있는 사용자에게 문제가 될 수 있어요. prefers-reduced-motion
미디어 쿼리로 사용자의 “애니메이션 줄이기” 설정을 꼭 확인해주세요!
애니메이션 줄이기 설정 감지
사용자의 OS 설정을 존중하는 것이 중요해요. 다행히 prefers-reduced-motion
미디어 쿼리로 쉽게 확인할 수 있습니다.
const QUERY = '(prefers-reduced-motion: reduce)';
const subscribePrefersReducedMotion = (onStoreChange: () => void) => {
const mediaQuery = window.matchMedia(QUERY);
mediaQuery.addEventListener('change', onStoreChange);
return () => mediaQuery.removeEventListener('change', onStoreChange);
};
const getPrefersReducedMotionSnapshot = () => {
return window.matchMedia(QUERY).matches;
};
const getServerSnapshot = () => {
return undefined;
};
export const usePrefersReducedMotion = () => {
return useSyncExternalStore(
subscribePrefersReducedMotion,
getPrefersReducedMotionSnapshot,
getServerSnapshot
);
};
이제 JS 애니메이션도 쉽게 제어할 수 있어요:
export const Sidebar = ({ isOpen }) => {
const shouldReduceMotion = usePrefersReducedMotion();
const closedX = shouldReduceMotion ? 0 : '-100%';
return (
<motion.div
animate={{
opacity: isOpen ? 1 : 0,
x: isOpen ? 0 : closedX,
}}
/>
);
};
다크모드 설정 훅 리팩토링
앞서 만든 useDark
훅에는 한 가지 문제가 있어요. 여러 컴포넌트에서 동시에 사용하면 상태가 동기화되지 않는 현상이 발생할 수 있거든요.
문제 상황
// theme-customizer.tsx
export const ThemeCustomizer = () => {
const { isDark } = useDark();
// 이 컴포넌트의 isDark 상태
};
// appearance-switch.tsx
export const AppearanceSwitch = () => {
const { toggleTheme } = useDark();
// 이 컴포넌트에서 toggleTheme을 호출해도
// ThemeCustomizer의 isDark가 업데이트되지 않을 수 있음!
};
Zustand로 해결하기
이런 문제는 전역 상태 관리로 해결할 수 있어요. zustand를 사용하면 간단하면서도 효과적으로 해결할 수 있습니다.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ThemeState {
theme: Theme;
isSystemDark: boolean;
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
isSystemDark: false,
toggleTheme: () => {
const { theme, isSystemDark } = get();
if (theme === 'system') {
set({ theme: isSystemDark ? 'light' : 'dark' });
} else {
set({ theme: 'system' });
}
},
}),
{
name: 'theme',
}
)
);
이제 개선된 useDark
훅을 만들어보세요:
export const useDark = () => {
const theme = useThemeStore(state => state.theme);
const toggleTheme = useThemeStore(state => state.toggleTheme);
const isSystemDark = useSystemDark();
useEffect(() => {
useThemeStore.setState({ isSystemDark });
}, [isSystemDark]);
const isDark = useMemo(
() => isDarkMode(theme, isSystemDark),
[theme, isSystemDark]
);
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
if (
(theme === 'dark' && isSystemDark) ||
(theme === 'light' && !isSystemDark)
) {
toggleTheme();
}
}, [isDark, theme, isSystemDark, toggleTheme]);
return { isDark, toggleTheme };
};
왜 zustand? React Context API보다 더 간결한 코드로 작성할 수 있어요. Context API의 불필요한 리렌더링 문제도 해결됩니다. 성능 비교와 올바른 사용법이 궁금하다면 Zustand vs Context API: 올바른 선택과 사용법을 확인해보세요!
마무리하며
React 다크 모드 구현, 생각보다 신경 쓸 게 많았죠? 하지만 이렇게 차근차근 구현하면 사용자가 정말 만족할 만한 다크 모드를 만들 수 있어요!
구현한 기능들
- ✅ 시스템 테마 설정 자동 감지
- ✅ 깜빡임 없는 테마 로딩
- ✅ 부드러운 테마 전환 애니메이션
- ✅ 접근성 고려 (prefers-reduced-motion)
- ✅ 전역 상태 관리로 안정적인 상태 동기화
- ✅ 테마 커스터마이징 지원
추가로 알아보면 좋은 내용
실무에서 다크 모드를 더 효율적으로 구현하고 싶다면 이런 내용들도 살펴보세요:
- 성능 최적화: 스크립트 로딩 최적화 전략에서 조건부 스크립트 로딩 방법을 알아보세요
- 상태 관리: Zustand vs Context API: 올바른 선택과 사용법에서 더 자세한 가이드를 확인하세요
사이드 프로젝트에서 이런저런 시도를 해보면서 많은 걸 배우는 것 같아요. 막히더라도 이미 잘 구현되어 있는 라이브러리 코드를 참고하면 되니까 부담 없이 도전할 수 있기도 하니까요! 그런 점이 사이드 프로젝트를 끊을 수 없는 이유인 것 같습니다.