본문 바로가기
C.W.K.
Stream
Lesson 02 of 07 · published

새로고침 뒤에도 유지되는 다크 모드

~13 min · dark-mode, system-preference, tailwind

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
다크 모드는 토글 하나가 아니야. 현재 모드, 선택 저장, 첫 렌더링 전 적용을 함께 풀어야 화면이 깜빡이지 않아.

세 가지 상태를 함께 다뤄

앱은 밝은 모드, 어두운 모드, 시스템 설정 따르기 중 하나를 선택해야 해. 사용자의 선택은 localStorage에 저장해 새로고침 뒤에도 유지하고, React가 렌더링되기 전에 html의 theme 속성에 반영해야 잘못된 색이 잠깐 보이지 않아.

React보다 먼저 실행하는 스크립트

index.html의 짧은 inline script에서 저장된 값을 읽고, 값이 없으면 prefers-color-scheme을 확인해. Vite 번들이 실행되기 전에 data-theme을 설정하면 브라우저가 처음부터 올바른 색으로 화면을 그려.

React 훅은 사용자 변경을 맡아

useTheme은 현재 속성을 읽고 모드를 바꾸는 함수를 제공해. 사용자가 토글하면 DOM 속성과 localStorage를 함께 갱신해. 초기 부팅은 inline script가, 이후 상호작용은 훅이 맡는 구조야.

시스템 모드도 변경될 수 있어

사용자가 직접 모드를 고르지 않았다면 matchMedia의 change 이벤트를 구독해 운영체제 설정을 따라가. 사용자가 명시적으로 고른 순간부터는 저장된 선택이 시스템 신호보다 우선해.

서버 렌더링에서는 사용자 선택을 미리 알 수 없을 수 있어. Next.js로 옮긴다면 쿠키에서 테마를 읽거나 hydration 전까지 테마에 따라 달라지는 UI를 늦춰야 해. Vite SPA에서는 초기 inline script가 이 문제를 맡아.

깜빡임을 직접 테스트해

개발 서버가 빠르면 잘못된 테마가 보이지 않을 수 있어. Network 속도를 낮추고 JavaScript 실행을 늦춘 뒤 새로고침해도 첫 paint부터 올바른 색인지 확인해. 저장된 값이 없을 때와 light·dark가 각각 저장됐을 때를 모두 시험해.

System 모드를 구독했다면 cleanup에서 media query listener를 제거해. 사용자가 직접 모드를 고른 뒤에는 시스템 변경이 저장된 선택을 덮지 않도록 분기를 분명히 둬.

Code

React보다 먼저 실행되는 index.html 부팅 script·html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
    <!-- Vite bundle보다 먼저 실행해서 잘못된 테마가 잠깐 보이지 않게 해. -->
    <script>
      (function () {
        try {
          var saved = localStorage.getItem("theme");
          var prefersLight = window.matchMedia("(prefers-color-scheme: light)").matches;
          var theme = saved || (prefersLight ? "light" : "dark");
          if (theme === "light") document.documentElement.setAttribute("data-theme", "light");
        } catch (e) { /* localStorage를 읽을 수 없으면 기본 다크 테마를 사용해. */ }
      })();
    </script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
useTheme 훅과 테마 전환 컴포넌트·tsx
import { useEffect, useState } from "react";

type Theme = "light" | "dark";

function getCurrentTheme(): Theme {
  return document.documentElement.getAttribute("data-theme") === "light"
    ? "light"
    : "dark";
}

export function useTheme() {
  const [theme, setThemeState] = useState<Theme>(getCurrentTheme);

  const setTheme = (next: Theme) => {
    setThemeState(next);
    if (next === "light") {
      document.documentElement.setAttribute("data-theme", "light");
    } else {
      document.documentElement.removeAttribute("data-theme");
    }
    try { localStorage.setItem("theme", next); } catch {}
  };

  // 사용자가 명시적 선택 안 했으면 system 따라.
  useEffect(() => {
    if (localStorage.getItem("theme")) return; // 사용자가 골랐음 — override 안 함
    const mql = window.matchMedia("(prefers-color-scheme: light)");
    const onChange = () => setTheme(mql.matches ? "light" : "dark");
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);

  return { theme, setTheme };
}

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      className="px-3 py-1 rounded border border-border text-fg hover:bg-bg-elevated"
      aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
    >
      {theme === "dark" ? "☀️" : "🌙"}
    </button>
  );
}

External links

Exercise

light, dark, system 세 모드를 지원하는 useTheme, 화면의 toggle, React bundle보다 먼저 실행되는 index.html 부팅 스크립트를 만들어. Light mode에서 새로고침해도 dark 화면이 잠깐 보이지 않는지, 직접 고른 mode가 새로고침 뒤에도 남는지, 저장 값을 지운 뒤 운영체제 테마를 바꾸면 system mode가 따라오는지 확인해. Media query listener도 컴포넌트가 사라질 때 정리해.
Hint
DevTools의 prefers-color-scheme emulation과 느린 Network 설정을 함께 사용해. 저장된 light, 저장된 dark, 저장 값 없음 세 cold reload에서 첫 paint와 최종 theme를 기록해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.