Tailwind 다크 모드: class와 data-theme 방식 비교

화면에서 dark:bg-gray-900 한 줄이 깜박이는 순간, 이런 의문이 듭니다. Tailwind 다크 모드는 class와 data-theme 중 어떤 방식을 써야 할까요?
이 두 방식을 한동안 직접 다뤄 봤습니다. 문서를 검색할 때마다 조각난 설명만 나와서 한데 모아도 어딘가 부족하게 느껴졌습니다. 결국 공식 문서와 GitHub 토론, 인기 컴포넌트 라이브러리 몇 곳의 소스 코드까지 직접 살펴본 뒤에야 생각이 정리됐습니다. 이 글에서는 그동안 겪은 시행착오와 고민 끝에 내린 판단을 모두 풀어보겠습니다.
Tailwind 다크 모드의 세 가지 전략
먼저 한 가지를 분명히 해두겠습니다. Tailwind가 기본으로 제공하는 다크 모드 전략은 두 가지가 아니라 세 가지입니다.
Media 전략: 시스템 설정 자동 추종
Media 전략은 Tailwind의 기본 설정입니다. 솔직히 이것이 기본값이라는 사실을 모르는 사람도 많을 겁니다. prefers-color-scheme CSS 미디어 쿼리로 사용자의 시스템 다크 모드 설정을 자동 감지합니다.
<!-- 별도 설정 없이 시스템 설정에 자동 대응 -->
<div class="bg-white dark:bg-gray-900">
콘텐츠가 시스템 설정에 따라 자동으로 전환됩니다
</div>
장점은 분명합니다. 설정이 전혀 필요 없고 사용자가 따로 조작하지 않아도 익숙한 화면을 볼 수 있습니다. 하지만 단점도 뼈아픕니다. 사용자가 직접 선택할 수 없습니다. 밝은 환경에서도 다크 모드를 쓰고 싶은 사람에게는 만족스럽지 않은 경험입니다.
Class 전략: 수동 전환 제어
Class 전략은 상위 요소(보통 <html>)에 .dark 클래스를 추가해 다크 모드를 활성화합니다. 개발자가 충분한 제어권을 가지므로 사용자의 수동 전환과 설정 저장을 모두 구현할 수 있습니다.
<!-- JavaScript로 클래스 이름 제어 -->
<html class="dark">
<body class="bg-white dark:bg-gray-900">
다크 모드가 적용됩니다
</body>
</html>
현재 가장 널리 쓰이는 방식입니다. 커뮤니티 문서가 풍부하고 여러 서드파티 라이브러리와도 매끄럽게 통합됩니다.
Data-theme 전략: 의미가 명확한 속성 선택자
Data-theme 전략은 클래스 이름 대신 data-theme="dark" 속성을 사용합니다. 의미가 더 명확하고 여러 테마로 자연스럽게 확장할 수 있습니다.
<html data-theme="dark">
<body class="bg-white dark:bg-gray-900">
다크 모드가 적용됩니다
</body>
</html>
테마를 더 추가하기도 매우 쉽습니다. data-theme="oled"나 data-theme="sepia"처럼 원하는 대로 정의하면 됩니다. 여러 표시 모드를 지원해야 할 때 특히 유용합니다.
Class 전략 자세히 알아보기
구현 원리
Class 전략의 핵심 원리는 간단합니다. DOM 트리의 어떤 상위 요소에 .dark 클래스가 있으면 모든 dark:* 수정자 스타일이 적용됩니다.
Tailwind v3에서는 설정 파일에서 활성화합니다.
// tailwind.config.js
module.exports = {
darkMode: 'class',
// ...
}
생성되는 CSS 선택자 구조는 다음과 같습니다.
.dark .dark:bg-gray-900 {
background-color: #111827;
}
Tailwind v4에서는 완전히 새로운 CSS-first 설정 방식을 도입해 @custom-variant 지시어를 사용합니다.
/* global.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
여기서 :where() 의사 클래스를 눈여겨보세요. specificity를 0으로 낮춰 다른 스타일의 우선순위 계산을 방해하지 않습니다. 꽤 중요한 세부 사항입니다.
JavaScript 전환 로직
사용자 전환 기능은 짧은 JavaScript 코드만으로 충분합니다.
// 현재 테마 가져오기
function getTheme() {
return localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
// 테마 설정
function setTheme(theme) {
localStorage.setItem('theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
}
// 초기화
setTheme(getTheme());
이 코드는 세 가지 일을 합니다. localStorage에서 사용자 설정을 읽고, 설정이 없으면 시스템을 따르며, 테마를 전환한 뒤 저장합니다. 이것이면 충분합니다.
흰 화면 깜박임 방지
페이지를 불러올 때 잠깐 흰 화면이 깜박이는 문제는 저도 겪었습니다. 원인은 단순합니다. JavaScript가 실행되기 전에 HTML이 기본 라이트 모드로 이미 렌더링되기 때문입니다.
해결 방법은 <head> 안에 동기 실행 스크립트를 넣어 DOM이 렌더링되기 전에 테마를 설정하는 것입니다.
<head>
<script>
// 동기 실행으로 깜박임 방지
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
</head>
이 스크립트는 반드시 동기식이어야 합니다. defer나 async를 사용하면 안 됩니다.
장단점
장점:
- 구현이 단순하고 직관적이어서 빠르게 익힐 수 있습니다.
- 커뮤니티 자료가 많고 프레임워크별로 검증된 방식이 있습니다.
- next-themes 같은 도구 라이브러리와 잘 연동됩니다.
- specificity가 조금 더 높아 스타일 재정의가 확실합니다.
단점:
.dark클래스 이름만으로는 의미가 명확하지 않아 코드를 볼 때 다크 모드인지 한 번 생각해야 합니다.- 여러 테마로 확장하려면 클래스 이름도 여러 개 필요해 관리가 다소 복잡해집니다.
- CSS 변수 방식과 결합할 때는 추가 대응이 필요합니다.
Data-theme 전략 자세히 알아보기
구현 원리
Data-theme 전략의 핵심은 클래스 선택자 대신 속성 선택자를 사용하는 것입니다. Tailwind v4에서는 다음과 같이 설정합니다.
@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
생성되는 CSS 선택자는 다음과 같습니다.
[data-theme='dark'] .dark:bg-gray-900 {
background-color: #111827;
}
Tailwind v3에서도 지원하지만 배열로 설정해야 합니다.
// tailwind.config.js
module.exports = {
darkMode: ['selector', '[data-theme="dark"]'],
}
CSS 변수 방식과 결합하기
솔직히 data-theme 전략과 CSS 변수 방식은 찰떡궁합입니다. 각 data-theme 아래에서 서로 다른 변수 값을 정의할 수 있습니다.
/* globals.css */
:root {
--background: 0 0% 100%;
--foreground: 222 84% 5%;
}
[data-theme='dark'] {
--background: 222 84% 5%;
--foreground: 210 40% 98%;
}
[data-theme='oled'] {
--background: 0 0% 0%; /* 완전한 검은색 */
--foreground: 0 0% 100%;
}
그런 다음 Tailwind 설정에서 이 변수들을 참조합니다.
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
}
}
}
}
이제 data-theme 속성만 바꾸면 이 변수들을 사용하는 모든 스타일이 자동으로 전환됩니다. 각 컴포넌트에 dark: 수정자를 쓸 필요가 없습니다. 실제로 써보면 무척 편합니다.
shadcn/ui의 실전 방식
shadcn/ui 컴포넌트 라이브러리는 기본적으로 data-theme과 CSS 변수 조합을 사용합니다. 스타일 파일을 살펴보면 다음과 같은 정의가 많이 보입니다.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* ... 더 많은 변수 */
}
.dark,
[data-theme='dark'] {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... 더 많은 변수 */
}
}
흥미로운 점은 .dark 클래스와 [data-theme='dark'] 속성을 동시에 지원한다는 사실입니다. 서로 다른 사용자의 습관을 모두 호환하기 위한 것입니다. shadcn/ui를 쓴다면 어떤 방식으로 다크 모드를 활성화해도 괜찮습니다.
여러 테마로 확장하는 능력
Data-theme 방식의 가장 큰 장점은 바로 여러 테마를 지원하기 쉽다는 것입니다. OLED 모드나 눈 보호 모드도 간단히 정의할 수 있습니다.
<html data-theme="oled">
<!-- OLED 화면에 적합한 완전한 검은색 배경 -->
</html>
<html data-theme="sepia">
<!-- 읽기에 적합한 연한 노란색 배경 -->
</html>
전환 로직에서는 속성값만 바꾸면 됩니다.
function setTheme(theme) {
localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
}
이런 유연성은 class 전략으로 구현하기가 쉽지 않습니다.
장단점
장점:
- 의미가 명확합니다.
data-theme="dark"만 봐도 다크 모드임을 바로 알 수 있습니다. - 여러 테마로 자연스럽게 확장할 수 있습니다.
- CSS 변수 방식과 특히 매끄럽게 결합됩니다.
- shadcn/ui, daisyUI 같은 라이브러리가 기본으로 호환됩니다.
단점:
- Tailwind v3에서는 selector를 직접 설정해야 합니다.
- 일부 서드파티 라이브러리는 별도 대응이 필요할 수 있습니다.
- 커뮤니티 문서가 상대적으로 적지만 점차 나아지고 있습니다.
두 방식 비교표
핵심 기준을 한눈에 볼 수 있도록 비교표로 정리했습니다.
| 비교 기준 | Class 전략 | Data-theme 전략 |
|---|---|---|
| 구현 복잡도 | 낮음, 설정이 간단함 | 보통, 속성 선택자를 이해해야 함 |
| 의미의 명확성 | 보통, .dark의 의미를 생각해야 함 | 높음, data-theme이 직관적임 |
| 다중 테마 확장 | 어려움, 여러 클래스 이름이 필요함 | 쉬움, 속성값만 바꾸면 됨 |
| 커뮤니티 지원 | 높음, 문서가 풍부함 | 보통, 보급되는 중 |
| CSS 변수 통합 | 추가 대응 필요 | 자연스럽게 잘 맞음 |
| Tailwind v3 | darkMode: 'class' | darkMode: ['selector', '...'] |
| Tailwind v4 | @custom-variant | @custom-variant |
| 서드파티 라이브러리 호환 | 호환성 확인 필요 | shadcn/ui 등과 기본 호환 |
| Specificity | 약간 높음(클래스 선택자) | 같음(속성 선택자) |
Class 전략은 언제 선택할까요?
- 라이트·다크 두 가지 모드만 필요한 간단한 프로젝트
- Next.js와 next-themes 조합을 사용하는 경우
- 팀이 Tailwind v3 설정에 익숙한 경우
- 커뮤니티 사례를 많이 참고해야 하는 경우
Data-theme 전략은 언제 선택할까요?
- OLED, 눈 보호 모드 등 여러 테마를 지원해야 하는 경우
- shadcn/ui나 비슷한 컴포넌트 라이브러리를 사용하는 경우
- CSS 변수 방식과 깊이 결합하려는 경우
- 프로젝트에서 의미가 명확한 마크업을 중요하게 여기는 경우
프레임워크 통합 실전
Astro 통합 방식
Astro와 Tailwind의 통합 자체는 간단하지만 View Transitions 처리라는 함정이 하나 있습니다.
기본 설정:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()]
}
});
다크 모드 스크립트:
<!-- BaseLayout.astro의 head 안에 배치 -->
<script is:inline>
// 흰 화면 깜박임을 막는 동기 스크립트
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
// 또는 data-theme 사용
// document.documentElement.dataset.theme = 'dark';
}
</script>
View Transitions 처리:
Astro의 View Transitions는 페이지 전환 시 DOM을 다시 렌더링하므로 다크 모드 상태가 쉽게 사라집니다. astro:after-swap 이벤트를 수신해 테마를 다시 설정해야 합니다.
<script>
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
</script>
이 단계는 상당히 중요합니다. 많은 개발자가 놓치기 쉽고 저 역시 같은 문제를 겪었습니다.
Next.js + next-themes 통합
Next.js 프로젝트에는 next-themes 라이브러리를 권합니다. 테마 전환에 필요한 전체 로직이 이미 들어 있어 SSR 호환성과 hydration 처리까지 신경 쓰지 않아도 됩니다.
설치:
npm install next-themes
Provider 설정:
// components/ThemeProvider.tsx
import { ThemeProvider } from 'next-themes';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class" // class 전략 사용
defaultTheme="system" // 기본값은 시스템 설정 추종
enableSystem={true} // 시스템 감지 활성화
disableTransitionOnChange // 전환 시 깜박임 방지
>
{children}
</ThemeProvider>
);
}
data-theme 전략으로 바꾸고 싶나요? attribute 속성만 바꾸면 됩니다.
<ThemeProvider attribute="data-theme" defaultTheme="system">
layout에서 사용하기:
// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
전환 버튼 컴포넌트:
// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
next-themes는 localStorage 저장, 시스템 설정 감지, hydration 문제를 자동으로 처리합니다. 무척 편합니다.
Tailwind v4의 새로운 기능
Tailwind v4에는 완전히 새로운 CSS-first 설정 방식이 도입됐으며 다크 모드 설정도 달라졌습니다.
@custom-variant 지시어
이전에는 JavaScript 설정 파일에 정의하던 variant를 이제 CSS에서 바로 선언할 수 있습니다.
@import 'tailwindcss';
/* Class 전략 */
@custom-variant dark (&:where(.dark, .dark *));
/* Data-theme 전략 */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
설정 변경을 위해 JavaScript를 다시 빌드하지 않아도 되므로 더 직관적입니다.
@theme 지시어로 변수 정의하기
data-theme 전략과 함께 @theme 지시어로 테마 변수를 정의합니다.
@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
@theme {
--color-primary: oklch(0.65 0.2 150);
--color-muted: oklch(0.9 0.02 200);
}
/* 다크 모드에서 변수 재정의 */
[data-theme='dark'] {
--color-primary: oklch(0.7 0.15 180);
--color-muted: oklch(0.3 0.02 200);
}
이제 이 색상을 바로 사용하면 됩니다.
<button class="bg-primary text-white">버튼</button>
data-theme을 바꾸면 색상도 자동으로 바뀝니다. dark:bg-primary-dark처럼 장황한 스타일을 쓸 필요가 없습니다.
3단계 전환 구현
light/dark/system의 3단계 전환은 window.matchMedia API와 함께 구현해야 합니다.
function setTheme(theme) {
if (theme === 'system') {
localStorage.removeItem('theme');
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
} else {
localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
}
}
// 시스템 설정 변경 감지
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
}
});
이렇게 하면 사용자가 특정 테마를 고정하거나 항상 시스템 설정을 따르도록 선택할 수 있습니다.
모범 사례 정리
추천 방식 선택
대부분의 프로젝트에는 다음과 같은 선택을 권합니다.
- 간단한 프로젝트: class 전략과 간단한 전환 스크립트면 충분합니다.
- shadcn/ui 사용: data-theme과 CSS 변수 조합을 그대로 사용합니다.
- 여러 테마 필요: 반드시 data-theme 전략을 사용합니다.
- Next.js 프로젝트: next-themes를 사용하고 필요에 따라 attribute를 선택합니다.
- Astro 프로젝트: View Transitions 처리를 반드시 주의합니다.
실전 팁
흰 화면 깜박임을 막는 전체 방식:
<head>
<script is:inline>
// 렌더링 전에 실행되는 동기 스크립트
(function() {
const theme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && systemDark)) {
document.documentElement.classList.add('dark');
// 또는
document.documentElement.dataset.theme = 'dark';
}
})();
</script>
</head>
SSR 프로젝트 처리 방법:
Next.js 같은 SSR 프로젝트에서는 hydration mismatch를 피해야 합니다. next-themes는 이미 이 문제를 처리합니다. 직접 구현하고 싶다면 다음 사항을 유의하세요.
// useEffect로 SSR 불일치 방지
import { useEffect, useState } from 'react';
function useTheme() {
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
setTheme(saved || 'light');
}, []);
return theme;
}
CSS 변수의 의미 중심 이름 짓기:
색상 이름 대신 의미가 드러나는 변수 이름을 사용하세요.
/* 권장 */
:root {
--background: ...;
--foreground: ...;
--primary: ...;
--muted: ...;
}
/* 비권장 */
:root {
--white: ...;
--black: ...;
--gray-900: ...;
}
의미 중심의 이름을 사용하면 테마를 전환할 때 더 직관적이며 나중에 새 테마를 추가하기도 쉽습니다.
마무리
결국 핵심은 한 문장으로 정리됩니다. class 전략은 단순하고 성숙해 대부분의 프로젝트에 적합하며, data-theme 전략은 의미가 명확해 여러 테마와 CSS 변수를 깊이 결합하는 경우에 더 적합합니다.
Tailwind v4의 @custom-variant 지시어 덕분에 두 방식의 설정이 모두 간결하고 직관적으로 바뀌었습니다. 어떤 방식을 선택할지는 결국 요구사항에 달려 있습니다. shadcn/ui를 쓴다면 data-theme 방식이 더 자연스럽고, 간단한 다크 모드 전환만 필요하다면 class 전략도 여전히 믿을 만한 선택입니다.
한 가지 세부 사항은 놓치지 마세요. 프레임워크와 통합할 때 Astro의 View Transitions, Next.js의 SSR hydration 같은 함정을 제대로 처리해야 합니다. 이런 부분이 빠지면 사용자 경험이 나빠집니다.
참고 자료
FAQ
Tailwind v4의 @custom-variant는 v3 설정과 무엇이 다른가요?
class와 data-theme을 동시에 사용할 수 있나요?
dark: 수정자가 너무 많아 코드가 장황해지면 어떻게 하나요?
구체적인 방법은 다음과 같습니다.
1. globals.css에서 @theme으로 변수를 정의합니다.
2. 각 [data-theme]에서 변수 값을 재정의합니다.
3. tailwind.config.js에서 해당 변수를 참조합니다.
이렇게 하면 bg-primary가 테마 전환에 자동으로 대응합니다.
Astro 프로젝트에서 다크 모드 상태가 사라지면 어떻게 하나요?
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
많은 개발자가 놓치기 쉬운 단계입니다.
페이지를 불러올 때 흰 화면이 깜박이는 문제는 어떻게 해결하나요?
<script>
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
주의: 스크립트는 반드시 동기식이어야 하며 defer나 async를 사용하면 안 됩니다.
4분 읽기 · 게시일: 2026년 3월 28일 · 수정일: 2026년 9월 4일
Tailwind & shadcn/ui 실전
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Tailwind 반응형 레이아웃 실전: 컨테이너 쿼리와 브레이크포인트 전략
Tailwind CSS 컨테이너 쿼리와 브레이크포인트 전략을 깊이 있게 살펴보고, 뷰포트 중심에서 컨테이너 중심으로 진화한 반응형 설계를 익혀 컴포넌트 단위의 반응형 레이아웃을 구현합니다.
14편 중 6편
다음
shadcn/ui 컴포지션 패턴: 여러 컴포넌트를 함께 사용하는 모범 사례
shadcn/ui 컴포지션 패턴의 모범 사례를 배우고 Dialog+Form, DataTable+DropdownMenu 같은 대표 조합과 Context 패턴, 상태 관리, 성능 최적화 등 고급 주제를 익혀 봅니다.
14편 중 8편



댓글
GitHub로 로그인하여 댓글을 남기세요