깜빡임 없는 다크 모드 구현하기

글: beautifulwind · · 프런트엔드

다크 모드를 구현해 놓고 새로고침할 때마다 화면이 하얗게 번쩍였다가 어두워지는 현상을 겪었다면, 테마를 적용하는 시점이 문제입니다. 이 글에서는 CSS 변수와 아주 짧은 인라인 스크립트로 깜빡임 없는 다크 모드를 만드는 방법을 설명합니다.

왜 깜빡일까

브라우저는 HTML을 위에서부터 읽으며 화면을 그립니다. 테마를 정하는 스크립트를 <body> 끝이나 DOMContentLoaded 이벤트 안에 두면, 그 코드가 실행되기 전까지는 기본 테마(대개 밝은 테마)로 한 번 그려진 뒤에 어두운 테마로 다시 칠해집니다. 이 짧은 순간이 눈에는 "번쩍임"으로 보입니다. 해결책은 간단합니다. 첫 화면을 그리기 전에 테마를 결정하면 됩니다.

CSS 변수로 색상 정리하기

먼저 색상을 CSS 사용자 정의 속성(변수)으로 모읍니다. 요소마다 색을 직접 쓰지 않고 변수를 참조하게 해 두면, 테마 전환은 <html>의 속성 하나를 바꾸는 일로 끝납니다.

:root {
            --bg: #ffffff;
            --text: #1a1a1a;
            --border: #dcdcd8;
        }
        :root[data-theme="dark"] {
            --bg: #121212;
            --text: #f0f0f0;
            --border: #3a3a3a;
        }
        body {
            background: var(--bg);
            color: var(--text);
        }

다크 모드 색상은 순수한 검정(#000)과 순수한 흰색(#fff) 조합보다 약간 띄운 값을 쓰는 편이 눈이 덜 피로합니다. 또한 밝은 테마와 어두운 테마 모두에서 본문 글자와 배경의 명암비가 WCAG 권장 기준인 4.5:1 이상인지 확인해 두세요.

렌더링 전에 테마 정하기

다음 스크립트를 <head> 안, 스타일시트보다 앞쪽에 인라인으로 넣습니다. 외부 파일로 분리하면 파일을 내려받는 동안 렌더링이 진행될 수 있으므로 이 몇 줄만큼은 인라인이어야 합니다.

<script>
            (function () {
                var saved = null;
                try { saved = localStorage.getItem('theme'); } catch (e) {}
                var prefersDark = window.matchMedia &&
                    window.matchMedia('(prefers-color-scheme: dark)').matches;
                document.documentElement.setAttribute(
                    'data-theme', saved || (prefersDark ? 'dark' : 'light'));
            })();
        </script>

이 코드는 세 가지를 처리합니다.

전환 버튼 만들기

버튼은 현재 테마를 읽어 반대로 바꾸고, 선택을 저장하기만 하면 됩니다.

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

버튼에는 현재 상태가 아니라 눌렀을 때 일어날 일을 알려 주는 aria-label(예: "다크 모드로 전환")을 붙이면 화면 낭독기 사용자도 의미를 바로 이해할 수 있습니다. 아이콘만 있는 버튼이라면 이 레이블은 사실상 필수입니다.

외부 위젯과 함께 쓸 때

댓글 위젯처럼 iframe으로 삽입되는 외부 콘텐츠는 내 CSS 변수가 적용되지 않습니다. 예를 들어 Disqus는 불러오는 시점에 부모 페이지의 배경색을 읽어 밝은 테마와 어두운 테마 중 하나를 고릅니다. 그래서 사용자가 테마를 바꾼 뒤에도 댓글 영역만 이전 색으로 남는 일이 생깁니다.

이럴 때는 배경색 전환 애니메이션이 끝난 뒤 위젯을 다시 불러오면 됩니다. Disqus라면 DISQUS.reset({ reload: true, config: disqus_config })를 setTimeout으로 약간 늦춰 호출하는 방식이 실용적입니다. 전환 애니메이션이 0.2초라면 그보다 조금 긴 0.25초 정도가 적당합니다.

점검 목록

이 사이트의 다크 모드 역시 위 방식 그대로 구현되어 있습니다. 오른쪽 위의 버튼을 눌러 확인해 보세요.

댓글