tinyhex

FOUC 없는 다크 모드

다크 모드를 붙일 때 가장 티 나는 실패는 기능이 안 되는 게 아니라, 새로고침할 때마다 흰 화면이 한 번 번쩍이는 것이다. 다크 테마를 쓰는 사람에게 이건 꽤 거슬린다.

왜 깜빡이는가

React 상태로 테마를 관리하면 순서가 이렇게 된다.

  1. 서버가 HTML 을 만든다 — 이 시점에 방문자의 테마를 알 수 없다
  2. 브라우저가 그 HTML 을 그린다 (여기서 잘못된 테마가 보인다)
  3. JS 가 로드되고 하이드레이션이 끝난다
  4. useEffect 가 실행되어 테마를 고친다

2번과 4번 사이가 깜빡임이다. 네트워크가 느릴수록 길어진다.

페인트 전에 끼어들기

해결은 단순하다. 2번보다 앞에 코드를 실행하면 된다. <head> 안의 동기 스크립트가 그 자리다.

(function () {
  var t = localStorage.getItem('theme');
  if (t !== 'light' && t !== 'dark') {
    t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
  document.documentElement.dataset.theme = t;
})();

이 스크립트는 번들러를 거치지 않는다. import 도 못 쓰고, 크기도 직접 관리해야 한다. 대신 첫 페인트가 이미 올바른 테마다.

하이드레이션 경고

스크립트가 하이드레이션 전에 DOM 을 바꾸므로 React 가 불일치를 경고한다. <html>suppressHydrationWarning 을 붙여서 이건 의도된 것이라고 알려준다.

아이콘도 같은 문제를 갖는다

토글 버튼의 해/달 아이콘을 useState 로 그리면 똑같은 깜빡임이 아이콘에서 반복된다. 서버는 여전히 테마를 모르기 때문이다.

아이콘 전환은 CSS 에 맡기는 편이 깔끔하다.

[data-theme='dark'] .light-icon { display: none; }

CSS 는 첫 페인트부터 정확하다. 버튼은 클릭 핸들러만 갖고 있으면 된다.

2단계로 충분하다

system / light / dark 3단계 순환을 만들고 싶어지지만, 대부분의 사람은 그걸 다 돌려보지 않는다. 첫 방문은 OS 설정을 따르고, 한 번이라도 토글하면 그 선택을 기억하는 2단계면 실제로 필요한 걸 다 덮는다.