byminseok.com

블로그에 다크모드 토글 붙이기

이 블로그에는 이미 다크모드가 있었다. 2주 전 리팩토링할 때 _variables.scss@media (prefers-color-scheme: dark) 블록을 넣어뒀다. macOS에서 다크모드를 켜면 블로그도 따라서 어두워지는 구조. 문제는 두 가지였다.

첫째, 색이 대충이었다. 배경 #1a1a1a, 보더 #333, 액센트 #D4735A. 라이트 모드의 Terracotta 팔레트가 디자인 시스템 문서까지 정리해둔 것에 비하면, 다크 쪽은 “일단 어둡게”만 해둔 상태였다. 따뜻한 톤이 빠져서 라이트와 다크가 다른 블로그처럼 보였다.

둘째, 수동 전환이 안 됐다. OS 설정에만 의존하니까, 라이트 모드 OS를 쓰면서 이 블로그만 다크로 보고 싶은 사람은 방법이 없었다.

Atelier Dark 팔레트

다크모드 디자인을 따로 잡았다. 이름은 Atelier Dark. 기존 라이트⟨Terracotta⟩와 짝이 되는 팔레트다.

핵심은 따뜻한 언더톤 유지. 순수 검정 #000000 대신 #111009⟨거의 검정이지만 약간 따뜻한⟩, 순수 흰색 #ffffff 대신 #ddd5c0⟨크림톤⟩. 액센트는 테라코타 대신 웜 골드 #c8a97a.

라이트 ⟨Terracotta⟩ 다크 ⟨Atelier Dark⟩ 역할
#F4F3EE #111009 배경
#E2E0D8 #2a2820 보더
#1A1A1A #ddd5c0 텍스트
#B1ADA1 #8a7f6e 비활성 텍스트
#C15F3C #c8a97a 액센트
#A84E30 #7a6a50 액센트 호버

이 대응 관계를 dotfiles/_design/atelier-dark-theme.md에 정리해뒀다. 라이트 쪽 문서⟨terracotta-light-theme.md⟩와 나란히. 나중에 다른 프로젝트에서도 같은 톤을 쓸 수 있도록.

prefers-color-scheme에서 data-theme으로

기존 방식의 한계는 명확했다. CSS @media (prefers-color-scheme: dark)는 OS 설정만 따라간다. JS로 오버라이드가 안 된다. 사용자가 토글 버튼을 눌러도 미디어 쿼리를 바꿀 수는 없으니까.

대안은 <html> 태그에 data-theme 속성을 붙이는 것이다.

// 라이트 (기본)
:root {
  --color-bg: #F4F3EE;
  --color-text: #1A1A1A;
  --color-accent: #C15F3C;
}

// 다크
[data-theme="dark"] {
  --color-bg: #111009;
  --color-text: #ddd5c0;
  --color-accent: #c8a97a;
}

[data-theme="dark"]는 일반 CSS attribute selector다. JS에서 document.documentElement.setAttribute('data-theme', 'dark') 한 줄이면 모든 CSS 변수가 바뀐다. 간단하고 예측 가능하다.

FOUC 방지

심전도 타임라인 만들 때도 FOUC에 한번 당한 적이 있는데, 다크모드에서 이건 더 눈에 띈다. 새로고침하면 하얀 화면이 번쩍하고 어두워지는 현상. localStorage에 ‘dark’가 저장되어 있어도, JS가 </body> 직전에 실행되면 CSS가 먼저 :root⟨라이트⟩로 렌더링을 시작하고, JS가 뒤늦게 data-theme="dark"를 붙이면서 화면이 깜빡인다.

해결은 <head> 안에 인라인 스크립트를 넣는 것이다. CSS 파일보다 먼저 실행되도록.

<head>
  <meta charset="utf-8">
  <script>
    (function() {
      var s = localStorage.getItem('theme');
      var t = (s === 'dark' || s === 'light') ? s : 'dark';
      document.documentElement.setAttribute('data-theme', t);
    })();
  </script>
  <!-- CSS는 이 아래에서 로드 -->
  <link rel="stylesheet" href="/assets/css/main.css">
</head>

이 스크립트는 CSS보다 먼저 실행되어 <html>data-theme을 세팅한다. CSS가 로드될 때는 이미 올바른 테마 변수가 적용된 상태이므로 FOUC가 없다. 기본값은 'dark'로 설정해서 첫 방문자에게 다크 모드를 보여준다.

95%는 이미 준비되어 있었다

탐색해보니 블로그의 거의 모든 색상이 이미 CSS 변수를 쓰고 있었다. 리팩토링 때 변수화해둔 덕이다. JS 파일⟨heartbeat.js, spread.js, typing-intro.js 등⟩에서도 하드코딩된 색상이 하나도 없었다. 전부 CSS class에 의존하고, class의 색은 변수가 결정하는 구조.

하드코딩이 남아있던 곳은 딱 세 군데였다.

_base.scss의 코드 블록. codepre의 배경이 rgba(0,0,0,0.04)로 직접 들어가 있고, 다크모드용으로 @media (prefers-color-scheme: dark) 블록이 따로 있었다. --color-code-bg, --color-pre-bg 변수를 만들어서 교체했다.

_retreat.scss의 시즌 배지. 리트릿 페이지의 봄/여름 배지가 #5a7a3a, #eef4e5 같은 hex로 직접 들어가 있었다. 이건 semantic한 색이라 --color-season-spring, --color-season-summer 변수를 따로 만들었다.

_retreat.scss의 모달 오버레이. rgba(0, 0, 0, 0.5) 하드코딩. --color-overlay 변수로 빼고, 다크에서는 rgba(17, 16, 9, 0.7)로 약간 더 짙게.

나머지는 _variables.scss[data-theme="dark"] 블록에 Atelier Dark 컬러를 넣는 것만으로 끝이었다.

토글 버튼

헤더에 해/달 아이콘 버튼을 넣었다. SVG 인라인으로 넣어서 currentColor를 따라가게 했다.

<header class="header">
  <h1><a href="/">byminseok.com</a></h1>
  <button class="theme-toggle" id="theme-toggle">
    <svg class="theme-toggle__icon--light"><!-- sun --></svg>
    <svg class="theme-toggle__icon--dark"><!-- moon --></svg>
  </button>
</header>

라이트 모드에서는 해 아이콘, 다크 모드에서는 달 아이콘. display: none/inline으로 전환하는데, [data-theme="dark"] 셀렉터로 제어한다. CSS만으로 끝나니까 JS에서 아이콘 교체 로직을 쓸 필요가 없다.

색은 --color-muted, 호버시 --color-text. 사이트의 다른 보조 요소들과 톤이 맞게. 배경 없고 보더 없는 ghost 버튼이라 헤더가 깔끔하게 유지된다.

theme-toggle.js

토글 JS는 짧다.

(function() {
  var toggle = document.getElementById('theme-toggle');
  if (!toggle) return;

  toggle.addEventListener('click', function() {
    var current = document.documentElement.getAttribute('data-theme');
    var next = current === 'dark' ? 'light' : 'dark';
    document.documentElement.setAttribute('data-theme', next);
    localStorage.setItem('theme', next);
  });

  window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', function(e) {
      if (!localStorage.getItem('theme')) {
        var t = e.matches ? 'dark' : 'light';
        document.documentElement.setAttribute('data-theme', t);
      }
    });
})();

클릭하면 data-theme을 토글하고 localStorage에 저장한다. OS 설정 변경 리스너도 달아뒀는데, 사용자가 수동으로 테마를 선택한 적이 없을 때⟨localStorage에 값이 없을 때⟩만 반응한다. 한번 토글 버튼을 누르면 그 선택이 우선.

수정 파일

파일 작업
_includes/head.html FOUC 방지 인라인 스크립트 + color-scheme meta
_sass/_variables.scss @media 블록 삭제 → [data-theme="dark"] 블록 + Atelier Dark 컬러
_sass/_base.scss prefers-color-scheme 블록 2개 제거, CSS 변수로 교체
_sass/_retreat.scss 시즌 배지 hex + 모달 오버레이 rgba → CSS 변수
_layouts/default.html 토글 버튼 HTML + JS 로드
_sass/_layout.scss 헤더 flex + 토글 버튼 스타일
assets/js/theme-toggle.js 새 파일

7개 파일, 127줄 추가, 36줄 삭제. Jekyll 로컬 빌드 정상 통과.


2주 전 리팩토링에서 색상을 전부 CSS 변수로 뺀 게 여기서 효과를 봤다. 변수 시스템이 없었으면 다크모드 추가가 “모든 SCSS 파일에서 색상값 찾아서 조건 분기 넣기”가 됐을 거다. 변수가 있으니 _variables.scss 한 파일에 다크 팔레트를 선언하는 것만으로 사이트 전체가 바뀌었다. 리팩토링할 때는 “나중에 쓸 일이 있으려나” 싶었는데, 2주 만에 쓸 일이 생겼다.