콘텐츠로 이동
Study Noteshadcn/ui

9. 테마 두 벌 — 다크 모드

다크 모드는 새 기능이 아니라 테마 한 벌을 더 갖는 것이다. 앞 장까지 제대로 했다면 값만 채우면 된다.

이 장에서 처음 나오는 말4개
`.dark` 클래스
shadcn/ui가 다크 모드를 켜는 방식. <html> 에 이 클래스가 붙으면 토큰 값이 다크 세트로 바뀐다.
`next-themes`
다크 모드 상태를 관리하는 작은 라이브러리. 시스템 설정 따라가기, 선택 기억하기, 화면 번쩍임 방지를 해 준다.
하이드레이션Hydration
서버가 만든 HTML에 브라우저가 나중에 JavaScript를 붙여 살아 움직이게 하는 과정. 이 사이의 시차가 다크 모드 번쩍임의 원인이다.
`prefers-color-scheme`
"사용자 기기가 다크 모드인가"를 알려주는 CSS 미디어 쿼리.

원리 — 이름은 그대로, 값만 바뀐다

섹션 제목: “원리 — 이름은 그대로, 값만 바뀐다”
:root {
--background: oklch(1 0 0); /* 흰색 */
--foreground: oklch(0.145 0 0); /* 거의 검정 */
}
.dark {
--background: oklch(0.145 0 0); /* 같은 이름, 어두운 값 */
--foreground: oklch(0.985 0 0);
}
{/* 컴포넌트는 하나도 안 바뀐다 */}
<div className="bg-background text-foreground">

<html class="dark">가 붙는 순간 CSS 변수 값이 전부 갈린다. 브라우저가 직접 하는 일이라 리렌더도, JavaScript도 필요 없다.

아래 둘은 같은 컴포넌트다 — 값만 다르다.

:root 값 (라이트)
로그인
계정으로 계속하기
.dark 값 — 마크업 동일
로그인
계정으로 계속하기
터미널 창
pnpm add next-themes
  1. Provider로 감싼다

    app/providers.tsx
    'use client'
    import { ThemeProvider } from 'next-themes'
    export function Providers({ children }) {
    return (
    <ThemeProvider
    attribute="class" // .dark 클래스를 붙인다
    defaultTheme="system" // 기본은 기기 설정 따라가기
    enableSystem
    disableTransitionOnChange
    >
    {children}
    </ThemeProvider>
    )
    }
  2. <html>에 suppressHydrationWarning을 준다

    app/layout.tsx
    <html lang="ko" suppressHydrationWarning>
    <body><Providers>{children}</Providers></body>
    </html>

    서버는 클래스가 없는 HTML을 만들고 브라우저가 나중에 붙이므로, React가 “서버와 다르다”고 경고한다. 이 한 군데만 경고를 끈다.

  3. 토글 버튼

    'use client'
    import { useTheme } from 'next-themes'
    import { Moon, Sun } from 'lucide-react'
    import { Button } from '@/components/ui/button'
    export function ThemeToggle() {
    const { setTheme, resolvedTheme } = useTheme()
    return (
    <Button
    variant="ghost"
    size="icon"
    aria-label="테마 전환"
    onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
    >
    <Sun className="size-4 dark:hidden" />
    <Moon className="hidden size-4 dark:block" />
    </Button>
    )
    }
  • defaultTheme="system" + enableSystem — 기기가 다크면 다크로 시작한다. 사용자가 직접 고르면 그 선택이 기억되고, 그때부터 기기 설정을 무시한다
  • disableTransitionOnChange — 전환 순간에 CSS transition을 잠깐 끈다. 이게 없으면 색이 있는 요소 수십 개가 제각각 다른 속도로 물드는 어지러운 장면이 나온다

가장 흔하게 겪는 증상이다. 다크 모드로 설정했는데 새로고침하면 흰 화면이 잠깐 보인다.

서버가 라이트로 렌더한 뒤 JS가 localStorage를 읽어 다크로 바꾸기까지 흰 화면이 한 번 번쩍이는 순서

이 구성에서 원인은 명확하다 — 선택이 localStorage에만 있어 서버가 읽을 수 없다. 쿠키로 저장해 서버가 클래스를 렌더하거나 prefers-color-scheme만 쓰는 설계도 가능하지만, next-themes 기본 흐름은 브라우저가 첫 페인트 전에 클래스를 붙이는 방식이다.

해결은 next-themes가 이미 해 준다. <head>에 아주 작은 스크립트를 넣어 React가 로드되기 전에 localStorage를 읽고 클래스를 붙인다.

토큰을 잘 썼어도 이 넷은 따로 챙겨야 한다.

어두운 배경 위의 검은 그림자는 보이지 않는다. 카드가 바탕에 붙어 보인다.

:root { --card: oklch(1 0 0); } /* 라이트: 그림자로 띄운다 */
.dark { --card: oklch(0.21 0 0); } /* 다크: --background(0.145)보다 밝게 */

다크에서는 밝기 차이로 고도를 표현한다. (5장)

새 화면을 만들 때 다크 모드에서 이 다섯 개만 확인하면 대부분 잡힌다.

확인흔한 원인
카드가 배경과 구분되는가--card를 --background와 같게 뒀다
글자가 다 읽히는가text-white, text-zinc-600 같은 하드코딩
테두리가 보이는가--border가 배경과 너무 비슷하다
로고·이미지가 멀쩡한가흰 배경 전제 PNG
새로고침해도 안 번쩍이는가next-themes 없이 직접 구현

두 번째가 압도적으로 많다. 다크 모드 버그의 대부분은 결국 토큰을 안 쓴 자리다.

  • 다크 모드는 테마 한 벌을 더 갖는 것이다. :root와 .dark에 같은 이름, 다른 값
  • 컴포넌트에 dark: 변형이 늘고 있다면 토큰을 안 쓰고 있다는 신호
  • next-themes + attribute="class" + suppressHydrationWarning이 표준 조합
  • disableTransitionOnChange를 켠다 — 없으면 전환이 어지럽다
  • localStorage를 useEffect에서만 읽으면 화면이 번쩍인다. 첫 페인트보다 늦기 때문이다
  • 다크에서 따로 챙길 것 넷 — 그림자 대신 밝기 차 · 순수 검정 금지 · 로고 교체 · 채도 낮추기
  • 다크 버그의 대부분은 결국 토큰을 안 쓴 자리다