neato로 구현해보는 tailwind4 다크 모드
내가 만든 전역 상태 관리 도구인 caro-kann, 모달 처리 도구 grunfeld, 폼 관리 도구 sicilian은 이제 모두 @ilokesto라는 단일 네임스페이스 아래에서, 예를 들어 @ilokesto/caro-kann과 같은 형태로 제공된다. 이런 구조는 패키지 관리와 버전 통일성을 위해 매우 유용하며, 사용자 입장에서도 어떤 라이브러리가 같은 생태계에 속해 있는지 직관적으로 이해할 수 있다.
반면 neato는 내가 직접 만들고 배포하고 있음에도, @ilokesto 네임스페이스에 속하지 않는 조금은 특이한 케이스다. 다른 라이브러리들과 달리 핵심 기능 대부분은 clsx나 tailwind-merge 같은 검증된 외부 라이브러리에 의존하고 있으며, 나는 Tailwind 환경에서 필요한 보조 기능들, 예를 들어 다크모드 지원, FOUC 방지, 색상 변환과 같은 기능만을 추가했다. 이런 설계 덕분에 핵심 로직의 안정성을 그대로 유지하면서, 개발자가 Tailwind를 사용할 때 자주 마주치는 반복적인 문제를 효과적으로 해결할 수 있다.
이 포스트에서는 neato를 활용해 Tailwind CSS 환경에서 안정적이고 매끄러운 다크모드를 구현하는 방법을 단계별로 살펴본다. 단순히 클래스 토글이나 색상 변경에 그치지 않고, React 상태 관리, DOM 동기화, 시스템 테마 감지, FOUC 방지, CSS 트랜지션과 CLI를 통한 색상 변환까지 통합된 실전 사례를 중심으로 설명한다.
Tailwind CSS 4 다크모드 메커니즘
Tailwind CSS는 dark: variant를 통해 다크모드 스타일을 적용한다. 핵심은 상위 엘리먼트에 .dark 클래스가 있는지 여부이며, 이 클래스가 활성화되면 모든 하위 요소의 dark: 스타일이 적용된다.
<html>
<div class="bg-white text-black dark:bg-gray-800 dark:text-white">
라이트 모드에서는 흰 배경에 검은 텍스트
</div>
</html>
<html class="dark">
<div class="bg-white text-black dark:bg-gray-800 dark:text-white">
다크 모드에서는 어두운 배경에 흰 텍스트
</div>
</html>
Tailwind CSS 4에서는 custom variant를 추가해 .dark 클래스와 그 하위 요소 모두에서 dark: prefix를 활성화할 수 있다.
/* global.css */
@custom-variant dark (&:where(.dark, .dark *));
React 환경에서 다크모드를 구현한다는 것은 단순히 클래스 토글이 아니라, 사용자 설정(localStorage)과 시스템 테마(prefers-color-scheme)를 기반으로 <html> 클래스 상태를 동적으로 제어하는 것이다. 이 과정에서 DOM과 React 상태를 정확히 동기화해야 하며, 이를 위해 ThemeProvider가 핵심 역할을 수행한다.
ThemeProvider
ThemeProvider는 React 상태(theme, effectiveTheme)와 실제 DOM을 동기화하는 핵심 영역이다. 사용자가 선택한 테마와 실제 화면에 적용되는 테마를 명확히 구분하여 DOM 업데이트를 최소화하고 성능을 최적화하는 것이 목표다.
클라이언트 하이드레이션 이후, useEffect를 통해 현재 <html>에 dark 클래스가 있는지 확인하고, 실제 적용되어야 하는 테마와 비교한다. 필요할 때만 클래스를 토글하고, 사용자 선택을 localStorage와 동기화한다.
useEffect(() => {
if (!isHydrated) return;
const htmlElement = document.documentElement;
const currentlyDark = htmlElement.classList.contains('dark');
const shouldBeDark = effectiveTheme === 'dark';
if (shouldBeDark && !currentlyDark) {
htmlElement.classList.add('dark');
} else if (!shouldBeDark && currentlyDark) {
htmlElement.classList.remove('dark');
}
if (theme === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', theme);
}
}, [theme, effectiveTheme, isHydrated]);
isHydrated 플래그를 활용하면 SSR 환경에서도 클라이언트 전용 로직을 안전하게 실행할 수 있다. 이 구조를 통해 ThemeProvider 전체가 직관적이고 유지보수가 쉬워진다.
테마 전환 애니메이션 관리
단순히 dark 클래스를 토글하는 것만으로는 화면이 갑자기 바뀌어 시각적으로 어색할 수 있다. 이를 방지하기 위해 ThemeProvider는 임시 트랜지션 클래스를 적용하고, CSS에서 배경색, 글자색, 테두리, 그림자 등 핵심 속성만 전환함으로써, 테마 전환이 자연스럽게 보이고 UI가 갑작스럽게 바뀌는 문제를 예방할 수 있다.
const setTheme = (newTheme: Theme) => {
const html = document.documentElement;
html.classList.add("theme-transition");
if (transitionTimeoutRef.current) {
window.clearTimeout(transitionTimeoutRef.current);
}
transitionTimeoutRef.current = window.setTimeout(() => {
html.classList.remove("theme-transition");
transitionTimeoutRef.current = null;
}, 250);
setThemeState(newTheme);
};
시스템 테마 감지 및 동기화
사용자가 system 모드를 선택한 경우에는 브라우저의 prefers-color-scheme 미디어 쿼리를 이용해 시스템 테마와 동기화한다. 또한 사용자가 시스템 테마를 변경하면 애플리케이션도 자동으로 업데이트되도록 구현된다.
useEffect(() => {
if (!isHydrated) return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateEffectiveTheme = () => {
if (theme === "system") {
setEffectiveTheme(mediaQuery.matches ? "dark" : "light");
} else {
setEffectiveTheme(theme);
}
};
updateEffectiveTheme();
const handler = () => {
if (theme === "system") updateEffectiveTheme();
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [theme, isHydrated]);
이 로직은 사용자가 시스템 테마를 변경하면 즉시 화면에 반영되어 일관된 경험을 제공한다. 또한 isHydrated 체크를 통해 클라이언트 전용 코드를 SSR 환경과 충돌 없이 안전하게 실행할 수 있다. 시스템 테마 감지를 통합하면 다크모드 적용이 더욱 지능적이고 유연해진다.
FOUC 문제와 App Router 환경
SSR 환경에서는 페이지가 먼저 서버에서 렌더링되고, 이후 클라이언트에서 테마 로직이 적용되기 때문에 잠깐 동안 라이트 모드가 깜빡이는 FOUC 문제가 발생한다. 이를 해결하려면 React가 실행되기 전 인라인 스크립트를 삽입하여 초기 테마를 적용해야 한다. Next.js layout.tsx의 <head>에 이 스크립트를 삽입하면, 페이지 렌더링 시점부터 올바른 테마가 적용되어 FOUC를 완전히 방지할 수 있다.
export function createNeatoThemeScript(): string {
return `
(function () {
try {
var theme = localStorage.getItem('theme');
var isDark = false;
if (theme === 'dark') {
isDark = true;
} else if (theme === 'light') {
isDark = false;
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`.trim();
}
import { createNeatoThemeScript } from 'neato/theme-script';
export default function RootLayout({ children }) {
return (
<html>
<head>
<script
dangerouslySetInnerHTML={{
__html: createNeatoThemeScript()
}}
/>
</head>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
하지만 이렇게 초기 테마를 설정하더라도 Next.js의 App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트를 엄격히 분리해야 한다는 점에 주의해야 한다. 서버 컴포넌트는 브라우저 환경의 document나 window 객체에 접근할 수 없기 때문에, 클라이언트 전용 코드와 함께 번들되면 렌더링 오류가 발생할 수 있다.
따라서 ThemeProvider와 같은 클라이언트 전용 컴포넌트는 반드시 별도의 모듈로 분리하고, 서버 컴포넌트에서는 순수 함수 형태의 테마 초기화 스크립트(createThemeScript)만 import해야 한다. 특히 배럴 익스포트를 통해 모든 기능을 한 번에 export하면, 서버에서 필요 없는 클라이언트 코드까지 함께 번들되어 예기치 못한 에러를 초래할 수 있다.
서버와 클라이언트의 책임을 명확히 구분하고, 각 모듈을 목적에 맞게 import하는 구조를 유지하는 것이 안정적인 SSR 다크모드 구현의 핵심이다.
CSS 변수와 고급 색상 관리
다크모드를 구현할 때 매번 dark: prefix를 사용하는 것은 유지보수 측면에서 비효율적이다. 대신 CSS 변수를 활용해 테마별 색상을 추상화하면 코드가 훨씬 간결해진다.
:root {
--primary: #0cb3cb;
--text-primary: #333;
}
.dark {
--primary: #0cb3cb;
--text-primary: #ddd;
}
@theme {
--color-primary: var(--primary);
--color-text-primary: var(--text-primary);
}
이 방법을 사용하면 컴포넌트에서는 단순히 변수 이름만 참조하면 되며, 테마 전환 시 자동으로 색상이 반영된다.
<div className="bg-primary text-text-primary">
모드에 따라 자동으로 색상이 변경됩니다
</div>
SVG 다크모드 대응
웹 앱에서 사용하는 아이콘이나 단색 이미지는 일반 CSS로 색상을 바꾸기 어렵다. 특히 다크모드에서는 배경과 대비가 맞지 않으면 시각적 통일성이 깨진다.
이때 CSS 필터(filter)를 활용하면, 기존 SVG나 PNG 이미지를 별도의 다크모드 버전 없이도 다크모드 환경에 맞춰 자동으로 색상을 변환할 수 있다. 필터를 사용하면 밝기, 채도, 대비, 색상 반전 등을 실시간으로 조정할 수 있으며, 사용자 테마 변경 시 즉시 반영된다.
:root {
--filter-primary: invert(14%) sepia(85%) saturate(5043%) hue-rotate(2deg) brightness(94%) contrast(126%);
}
.dark {
--filter-primary: invert(90%) sepia(20%) saturate(150%) hue-rotate(10deg) brightness(110%) contrast(95%);
}
@layer utilities {
.filter-primary {
filter: var(--filter-primary);
}
}
이 접근법은 재사용성과 유지보수성이 뛰어나다. CSS 변수로 필터를 관리하면 새로운 아이콘이 추가되더라도 별도 이미지 제작 없이 다크모드 대응이 가능하며, CLI 도구를 활용하면 필터를 자동으로 계산해 효율성을 높일 수 있다.
CLI를 통한 색상 변환과 다크모드 대응
neato의 또 다른 핵심 기능 중 하나는 CLI를 통한 색상 변환 기능이다. Tailwind를 사용하면서 라이트모드 색상을 그대로 다크모드에 적용하면 시각적으로 너무 밝거나 어색해지는 문제가 종종 발생한다. 이를 해결하기 위해 neato는 단일 색상 또는 여러 색상을 한 번에 변환할 수 있는 CLI 명령어를 제공한다.
예를 들어, 라이트모드용 색상 코드를 다크모드용으로 변환하고 싶다면 다음과 같이 명령어를 실행할 수 있다.
npx neato toDark 3b82f6
# 출력: #4d5fb8
또한 여러 색상을 동시에 변환할 수도 있어, 디자인 시스템에서 사용하는 팔레트 전체를 한 번에 다크모드에 맞게 조정할 수 있다.
npx neato toDark 3b82f6 ef4444 10b981
# 출력:
# #4d5fb8
# #b83c3c
# #0d8a5f
이 CLI 기능은 단순히 색상 변환에 그치지 않고, SVG 필터 값 생성이나 다크모드용 CSS 변수까지 자동으로 계산할 수 있는 확장성을 제공한다. 개발자는 CLI만으로 라이트모드와 다크모드 색상 간 변환을 빠르게 수행할 수 있으며, 코드에 직접 계산 로직을 작성할 필요가 없어 생산성이 크게 향상된다.
결과적으로, neato의 CLI는 단순한 편의 기능이 아니라 Tailwind 환경에서 다크모드를 적용할 때 반복적이고 번거로운 작업을 자동화하고, 디자인 일관성을 유지하는 데 핵심적인 도구 역할을 한다. 이를 통해 개발자는 스타일링 관련 복잡한 고민 없이, 색상 관리와 다크모드 대응을 효율적으로 처리할 수 있다.
결론
이처럼 ThemeProvider를 기능별로 분리하면 블로그 글에서 각 파트에 충분한 설명과 코드 예제를 담아 독자가 이해하기 쉽게 전달할 수 있다. 테마 상태 관리, 트랜지션 관리, 시스템 테마 감지를 명확히 구분함으로써 구조가 직관적이고 유지보수성이 뛰어난 다크모드 시스템을 구현할 수 있다. 또한 CSS 트랜지션과 localStorage 연동, 시스템 테마 자동 동기화까지 통합하면, 사용자에게 매끄럽고 일관된 다크모드 경험을 제공할 수 있다.
블로그의 정보
Ayden's journal
Beard Weard Ayden