02 — Dark Mode Strategy

이 문서가 답하는 질문: 다크모드를 켜는 신호는 세 가지 — prefers-color-scheme 미디어 쿼리, [data-theme="dark"] 속성, .dark 클래스 — 가 있다. 어떤 것을 골라야 하고, SSR 환경에서 첫 페인트 시 라이트가 깜빡이는 FOUC는 어떻게 막는가? 한 줄 답 (Pyramid Top): 정답은 [data-theme] 속성 + blocking inline <script> + color-scheme CSS property” 의 3-콤보다 — prefers-color-scheme만 쓰면 사용자 토글이 불가능하고, .dark 클래스만 쓰면 시스템 자동 추적이 어렵다. 둘 다 하나의 <html data-theme>로 수렴시키고, blocking script로 FOUC를 막고, color-scheme으로 브라우저 UI(스크롤바·form)까지 다크로 끌고 가는 것이 사실상 표준이다.


Why — 왜 존재하는가

다크모드는 2018년 macOS Mojave 출시 후 디자인 시스템의 필수 기능이 됐다. 하지만 “어떻게 검출하느냐”는 세 진영으로 갈렸다.

풀려는 문제접근한계
시스템 설정 자동 추적@media (prefers-color-scheme: dark)사용자가 앱 안에서 토글할 수 없음
사용자 토글localStorage.dark 클래스SSR 첫 페인트에 라이트가 깜빡임 (FOUC)
라이브러리 호환 (Tailwind).dark class selectordata-attribute 기반 다른 시스템과 충돌
브라우저 UI(스크롤바)(해법 없음)다크 페이지에 흰 스크롤바 — 보기 흉함

2020년경 next-themes(Vercel 팀의 Paco Coursey)가 사실상 표준 패턴을 정리했다:

  1. 내부적으로[data-theme] 속성으로 통일.
  2. 사용자 선택 + 시스템 추적을 함께 지원 (light / dark / system).
  3. SSR 환경에서 첫 페인트 전에 data-theme을 박는 inline blocking script 주입.
  4. CSS에 color-scheme: light dark;를 박아 브라우저 UI도 다크로.

이 패턴이 React/Next 진영에서 굳어졌고, Tailwind v4·Panda CSS 모두 이를 전제로 한다.


How — 어떻게 동작하는가

핵심은 <head> 안 inline <script>body 렌더 전에 <html>data-theme을 박는 것. 이 script는:

  • 외부 파일이 아니라 inline (네트워크 대기 없음)
  • defer/async 없음 (parser-blocking이 맞는 선택)
  • 50~80 bytes 정도
{/* <head> 최상단 */}
<script>
  (function () {
    try {
      var t = localStorage.getItem('theme');
      var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
      var resolved = t === 'light' || t === 'dark' ? t : system;
      document.documentElement.setAttribute('data-theme', resolved);
    } catch (e) {}
  })();
</script>

What — 구체 사양 / 수치 / 예시

세 가지 검출 방식 비교

방식신호사용자 토글SSR FOUCTailwind v3 호환Panda 호환
@media (prefers-color-scheme: dark)OS 설정만불가능없음 (CSS)darkMode: 'media'_osDark
.dark classJS로 토글가능있음 — script 필요darkMode: 'class' (기본)[class*=dark] &
[data-theme] attrJS로 토글가능있음 — script 필요darkMode: ['class', '[data-theme="dark"]'][data-theme=dark] &

권장: [data-theme] + inline script + color-scheme property.

color-scheme CSS property — 자주 잊는 1줄

브라우저는 color-scheme을 보고 기본 form control, 스크롤바, focus ring 색을 결정한다.

:root { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
 
/* 또는 — 사용자가 OS 설정을 따르도록 */
:root { color-scheme: light dark; }

이 한 줄이 없으면 다크 페이지에 흰 스크롤바, 흰 form control이 그대로 남는다.

Next.js + next-themes 권장 셋업

// app/layout.tsx
import { ThemeProvider } from 'next-themes';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="data-theme"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
  • attribute="data-theme".dark class 대신 attribute 사용.
  • suppressHydrationWarning — hydration 시 서버/클라이언트 attr 차이는 의도된 것임을 React에 알림.
  • disableTransitionOnChange — 토글 순간 모든 transition 일시 정지 (전환 잔상 방지).

Vanilla 셋업 (프레임워크 없이)

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <script>
      (function () {
        var t = null;
        try { t = localStorage.getItem('theme'); } catch (e) {}
        var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        document.documentElement.setAttribute(
          'data-theme',
          t === 'light' || t === 'dark' ? t : system
        );
      })();
    </script>
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <button id="toggle">Toggle</button>
    <script>
      document.getElementById('toggle').addEventListener('click', () => {
        var cur = document.documentElement.getAttribute('data-theme');
        var next = cur === 'dark' ? 'light' : 'dark';
        document.documentElement.setAttribute('data-theme', next);
        localStorage.setItem('theme', next);
      });
    </script>
  </body>
</html>

CSS 토큰 셋업

:root {
  color-scheme: light;
  --color-bg: #ffffff;
  --color-fg: #18181b;
}
 
[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: #0a0a0a;
  --color-fg: #fafafa;
}
 
/* 사용자가 system을 골랐을 때 OS 설정 추적 */
@media (prefers-color-scheme: dark) {
  [data-theme="system"] {
    color-scheme: dark;
    --color-bg: #0a0a0a;
    --color-fg: #fafafa;
  }
}
 
body {
  background: var(--color-bg);
  color: var(--color-fg);
}

토글 시 transition 깜빡임 방지

.disable-transitions * {
  transition: none !important;
}
function setTheme(next: 'light' | 'dark') {
  document.documentElement.classList.add('disable-transitions');
  document.documentElement.setAttribute('data-theme', next);
  // 한 프레임 뒤 transition 복구
  requestAnimationFrame(() => {
    document.documentElement.classList.remove('disable-transitions');
  });
}

What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — FOUC (Flash of Unstyled Content): blocking script를 안 박고 React가 mount된 뒤 테마를 설정. 결과: 첫 0.3초 라이트, 그 다음 다크로 깜빡임. Render-blocking inline script가 정답 — async/defer 절대 금지.
  • 함정 2 — color-scheme 누락: 모든 컴포넌트는 다크인데 스크롤바·<input type="date">·focus ring만 흰색. CSS 한 줄(color-scheme: dark)이면 해결.
  • 함정 3 — 토글 시 모든 transition이 한꺼번에 발동: 1000개 요소가 색을 0.3초 전환 — 잔상·성능 저하. 토글 직전에 transition: none !important 임시 부착.
  • 함정 4 — localStorage 접근 try/catch 없이: Safari private mode·iframe sandbox에서 localStorage 접근만으로 throw. 반드시 try/catch.
  • 함정 5 — Tailwind v3 darkMode: 'media'로만 쓰기: 사용자 토글 불가. v3는 반드시 darkMode: 'class' 또는 ['class', '[data-theme="dark"]'].
  • 함정 6 — suppressHydrationWarning 잊기: Next.js가 hydration mismatch 경고를 콘솔에 쏟아냄. <html>에 박는다.
  • 함정 7 — img/svg가 다크에서 그대로: 로고가 검은색이라 다크 배경에 안 보임. <picture> + prefers-color-scheme 미디어 source, 또는 SVG inline + currentColor.
<picture>
  <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/logo-light.svg" alt="Logo" />
</picture>

단, <picture>시스템 설정만 추적한다. 사용자 토글에도 따라가게 하려면 JS로 <img src>를 swap하거나, CSS background-image를 토큰으로 받는다.


Insight — 흥미로운 이야기

다크모드 FOUC를 처음 체계적으로 해결한 사람은 GitHub의 Mu-An Chiou다 — 2019년 GitHub에 다크모드를 도입할 때 inline blocking script 패턴을 표준화했고, 이후 next-themes가 이를 React 진영에 가져왔다.

흥미로운 디테일: GitHub은 단순 light/dark가 아니라 light / dark / dark_dimmed / dark_high_contrast / light_high_contrast / light_colorblind / dark_colorblind 7가지 테마를 같은 data-color-mode + data-light-theme + data-dark-theme 3개 attribute 조합으로 표현한다. 즉, data-theme 단일 값이 아니라 (현재 모드, 라이트일 때 테마, 다크일 때 테마) 세 축. 사용자는 “라이트는 colorblind 친화, 다크는 dimmed로”처럼 각 모드별로 테마를 고를 수 있다.

또 하나의 반전: Chrome의 “Force Dark Mode” 는 사이트가 color-scheme: light darkprefers-color-scheme명시적으로 지원한다고 선언하면 자동 변환을 건너뛴다. 즉, 다크모드를 제대로 만들면 브라우저의 어색한 자동 색 반전을 피할 수 있다. color-scheme 메타 정보가 “우리는 다크를 직접 처리한다”는 신호로 작동하는 셈.

마지막으로 — prefers-color-scheme은 사실 1996년 CSS2 초안의 media="screen, print" 의 후예다. CSS는 처음부터 사용자 환경에 맞춰 다르게 그릴 수 있는 매체로 설계됐고, “color-scheme”은 그 철학의 30년 만의 부활이다.


요약

  • 다크모드 검출은 3가지 방법이 있지만 사실상 [data-theme] + inline blocking script + color-scheme property 3-콤보가 표준.
  • FOUC는 render-blocking inline script로 막는다 — async/defer 금지.
  • color-scheme CSS property가 없으면 스크롤바·form control이 라이트로 남는다 — 한 줄이지만 자주 잊힘.
  • next-themes는 이 패턴을 React에 표준화했고, Tailwind v3는 darkMode: 'class', Panda는 _dark condition으로 같은 메커니즘을 표현한다.