다크모드를 켰더니 눈이 더 피로했습니다.
배경은 검은색인데 텍스트가 진한 회색이라 대비가 너무 낮았고, 링크는 기본 파란색(#0000ff) 그대로라 눈이 아팠어요. 이미지는 어두운 화면에서 홀로 눈부시게 빛났습니다. 버튼 하나는 배경과 같은 색이라 아예 보이지도 않았어요.
“다크모드 지원"이라고 쓰여 있었지만, 색만 반전시킨 다크모드였습니다.
어두운 배경에 어두운 글자. 이건 다크모드가 아니라 미스터리 테마입니다.

사진: Shiona Das / Unsplash
다크모드, 취향의 문제가 아닙니다#
다크모드를 “밤에 보기 편한 테마” 정도로 여기기 쉽지만, 누군가에겐 꼭 필요한 기능입니다. 빛에 민감해 밝은 화면을 오래 못 보는 편두통·빛 과민증 사용자, 저조도 환경에서 화면을 들여다보는 사람에게 어두운 화면은 눈의 부담을 확 덜어줘요. OLED 화면에선 배터리도 아끼고요. 그래서 다크모드는 있으면 좋은 장식이 아니라 — 라이트모드만큼 공들여야 하는 또 하나의 정식 디자인입니다. 이 글이 접근성 이야기와 맞닿는 지점이 여기예요.
이 글에서는 그 “제대로 된 다크모드"를 처음부터 끝까지 만들어봅니다. 큰 순서는 이렇습니다.
- CSS 변수로 색을 토큰화해 모드 전환의 기반을 깐다
- 시스템 설정을 존중한다 (
prefers-color-scheme) - 사용자가 직접 고르는 토글을 얹는다
- 다크에서도 색 대비를 다시 채점한다
- 첫 로딩의 깜빡임(FOUC)까지 제거한다
끝까지 따라오면, 색만 반전된 “미스터리 테마"가 아니라 어두운 곳에서도 모든 글자가 제 목소리를 내는 진짜 다크모드를 갖게 됩니다. 하나씩 가볼게요.
다크모드는 색 반전이 아닙니다#
가장 흔한 실수는 filter: invert(1)을 쓰거나 배경/텍스트 색을 그냥 바꾸는 것입니다.
/* ❌ 이렇게 하면 안 됩니다 */
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
color: #fff;
}
}라이트모드에서 신중하게 선택한 색들이 다크모드에서는 완전히 다른 맥락이 됩니다. 강조색, 경고색, 성공색… 각각의 의미를 유지하면서도 어두운 배경에서 읽히도록 조정해야 해요.
제대로 된 다크모드는 디자인 토큰에서 시작합니다. 디자인 토큰(design token)이란 색·간격 같은 디자인 값에 --color-bg처럼 이름을 붙여 한 곳에서 관리하는 것을 말해요. “흰색"을 코드 곳곳에 흩뿌리는 대신 “배경색"이라는 이름표를 붙여두면, 모드가 바뀔 때 그 이름표가 가리키는 값만 갈아끼우면 됩니다.
CSS 변수로 토큰 설계하기#
CSS 변수(Custom Properties)는 이 토큰을 담는 그릇입니다. 색상을 직접 쓰지 않고 변수를 통해 참조하면, 모드가 바뀔 때 변수값만 교체하면 돼요.
/* 라이트모드 기본값 */
:root {
/* 배경 */
--color-bg: #ffffff;
--color-bg-subtle: #f8f9fa;
--color-surface: #ffffff;
--color-surface-raised: #f1f3f5;
/* 텍스트 */
--color-text: #1a1a2e;
--color-text-subtle: #6c757d;
--color-text-on-primary: #ffffff;
/* 강조색 */
--color-primary: #6366f1; /* indigo */
--color-primary-hover: #4f46e5;
/* 시맨틱 색상 */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
/* 테두리 */
--color-border: #dee2e6;
--color-border-subtle: #f1f3f5;
}
/* 다크모드 */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-bg-subtle: #1e293b;
--color-surface: #1e293b;
--color-surface-raised: #334155;
--color-text: #e2e8f0;
--color-text-subtle: #94a3b8;
--color-text-on-primary: #ffffff;
/* 다크모드에서 강조색은 밝게 조정 */
--color-primary: #818cf8; /* 더 밝은 indigo */
--color-primary-hover: #a5b4fc;
/* 시맨틱 색상도 배경에 맞게 조정 */
--color-success: #4ade80;
--color-warning: #fcd34d;
--color-error: #f87171;
--color-border: #334155;
--color-border-subtle: #1e293b;
}
}이제 컴포넌트에서는 변수를 참조합니다.
/* 색상 값을 직접 쓰지 않음 */
body {
background-color: var(--color-bg);
color: var(--color-text);
}
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-text-on-primary);
}라이트/다크 어디서도 컴포넌트 코드는 바뀌지 않습니다.

prefers-color-scheme: 시스템 설정 존중하기#
시스템이 다크모드라면 사이트도 다크모드로 보여주는 게 기본입니다. prefers-color-scheme 미디어 쿼리로 시스템 설정을 감지할 수 있어요.
/* CSS만으로도 시스템 설정에 반응 가능 */
@media (prefers-color-scheme: dark) {
:root { /* 다크모드 변수 */ }
}여기에 한 줄을 더 얹으면 완성도가 확 올라갑니다 — color-scheme 속성입니다.
:root {
color-scheme: light dark; /* 이 페이지는 두 모드를 다 지원한다고 브라우저에 알림 */
}이 한 줄은 브라우저가 직접 그리는 UI까지 현재 모드에 맞춰줍니다. 스크롤바, 기본 폼 입력창, 체크박스, 텍스트 커서 같은 것들이요. 이게 없으면 본문은 다크인데 스크롤바만 하얗게 튀는 어색한 조합이 나오기 쉬워요. 내가 칠하지 못하는 영역을 브라우저에게 맡기는 셈입니다.
JavaScript로 감지하려면:
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
// 현재 설정 확인
console.log(prefersDark.matches); // true | false
// 변경 감지
prefersDark.addEventListener('change', (e) => {
console.log(e.matches ? '다크모드로 전환' : '라이트모드로 전환');
});사용자 토글 구현하기#
시스템 설정만 따르면 사용자가 선택할 수 없습니다. 토글 버튼으로 직접 모드를 바꿀 수 있게 해주는 게 좋습니다.
3가지 상태를 관리합니다: system (시스템 따름), light, dark.
const STORAGE_KEY = 'color-scheme';
function getStoredScheme() {
return localStorage.getItem(STORAGE_KEY) || 'system';
}
function applyScheme(scheme) {
const isDark =
scheme === 'dark' ||
(scheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-color-scheme', isDark ? 'dark' : 'light');
}
function setScheme(scheme) {
localStorage.setItem(STORAGE_KEY, scheme);
applyScheme(scheme);
updateToggleUI(scheme);
}
// 페이지 로드 시 적용 (버튼 상태도 현재 모드에 맞춰 동기화)
const initial = getStoredScheme();
applyScheme(initial);
updateToggleUI(initial);
// 버튼을 누르면 라이트 ↔ 다크를 오간다
document.getElementById('theme-toggle').addEventListener('click', () => {
const current = getStoredScheme();
const isDark = current === 'dark' ||
(current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
setScheme(isDark ? 'light' : 'dark'); // 지금 다크면 라이트로, 아니면 다크로
});그런데 여기서 앞서 만든 토큰 CSS를 손봐야 합니다. 우리는 다크 토큰을 @media (prefers-color-scheme: dark) 안에만 정의했는데, 미디어 쿼리는 시스템 설정만 봅니다. 라이트 시스템을 쓰는 사용자가 토글로 다크를 켜도 미디어 쿼리는 여전히 거짓이라, 다크 값이 적용되지 않아요. 토글이 먹통이 되는 거죠.
그래서 다크 토큰이 두 입구 모두에서 적용되도록 선택자를 맞춰줍니다.
/* ① 시스템이 다크이고, 사용자가 라이트로 끄지 않았을 때 */
@media (prefers-color-scheme: dark) {
:root:not([data-color-scheme="light"]) {
--color-bg: #0f172a;
--color-text: #e2e8f0;
/* …앞서 정의한 나머지 다크 토큰… */
}
}
/* ② 사용자가 직접 다크를 켰을 때 (시스템과 무관) */
[data-color-scheme="dark"] {
--color-bg: #0f172a;
--color-text: #e2e8f0;
/* …앞서 정의한 나머지 다크 토큰… */
}두 규칙의 값은 똑같습니다. 다른 건 적용 조건이에요.
- ①의
:not([data-color-scheme="light"])는 “시스템은 다크지만 사용자가 라이트로 끄지 않았다면“이라는 뜻 — 사용자의 명시적 선택이 시스템 기본을 이기게 하는 장치입니다. - ②는 시스템이 뭐든 사용자가 다크를 골랐으면 무조건 다크.
- 라이트는 따로 규칙이 필요 없어요. 기본값
:root가 이미 라이트니까요.

값을 두 번 쓰는 게 번거롭다면 SCSS 같은 도구로 다크 토큰을 한 번만 정의해 두 선택자에 뿌리면 됩니다. 원리는 같아요 — 시스템 기본과 사용자 선택, 두 입구 모두에 같은 다크 값을 연결하는 것.
토글 버튼 접근성#
토글 버튼은 현재 상태를 명확히 전달해야 합니다.
<button
id="theme-toggle"
aria-label="다크모드 켜기"
aria-pressed="false"
>
🌙
</button>function updateToggleUI(scheme) {
const btn = document.getElementById('theme-toggle');
const isDark = scheme === 'dark' ||
(scheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
btn.setAttribute('aria-label', isDark ? '라이트모드로 전환' : '다크모드로 전환');
}aria-pressed는 토글 버튼의 활성/비활성 상태를 스크린 리더에 전달합니다. 아이콘만 있는 버튼이라면 aria-label로 동작을 설명해주는 것도 잊지 마세요.
다크모드에서 색 대비 확인하기#
여기서 많은 다크모드 구현이 실패합니다.
잠깐 용어 하나 짚고 갈게요. 대비비(contrast ratio)는 글자색과 배경색의 밝기 차이를 숫자로 나타낸 것입니다. 1:1이면 두 색이 똑같아 안 보이고, 21:1이 검정과 흰색의 최대 대비예요. 웹 접근성 표준인 WCAG(Web Content Accessibility Guidelines)는 본문 글자에 최소 4.5:1(AA 등급)을 요구합니다 — 저시력 사용자도 읽을 수 있는 최소선이죠.
문제는, 라이트모드에서 이 기준을 맞췄어도 다크모드에서는 다를 수 있다는 겁니다. 특히 강조색이 문제입니다. 라이트 배경에서 충분한 대비를 보이던 색이 다크 배경에서는 너무 밝아서 눈이 부시거나, 반대로 대비가 낮아질 수 있어요.
직접 확인하는 방법:
- 브라우저 DevTools: Chrome DevTools의 색상 선택기에서 대비비를 바로 확인할 수 있습니다
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Polypane, Stark 플러그인: 라이트/다크 동시 확인 가능
일반적인 원칙:
- 다크 배경 위의 텍스트는 밝고 채도가 낮은 색을 씁니다 (
#e2e8f0같은) - 다크 배경 위의 강조색은 라이트모드보다 더 밝게 조정합니다
- 순수 흰색(
#ffffff)보다 살짝 낮은 밝기(#e2e8f0)가 눈에 더 편합니다 - 순수 검은색(
#000000) 배경도 피합니다.#0f172a같은 어두운 네이비가 눈에 더 좋아요

같은 색인데 배경이 바뀌니 성적이 뒤집힙니다 — 대비비는 색의 속성이 아니라 짝의 속성이거든요. 그래서 다크모드 대비는 따로 채점해야 합니다.
다크모드는 고대비 모드가 아닙니다#
여기서 흔한 오해 하나를 끊고 갈게요. “다크모드 잘 만들면 저시력 사용자 대비 문제도 해결되겠지” — 아쉽지만 아닙니다. 다크모드는 내가 팔레트를 고르는 이야기지만, 고대비 모드는 사용자(정확히는 OS)가 색을 강제하는 이야기거든요. 윈도우 고대비 모드가 켜지면 forced-colors라는 스위치가 눌리는데, 이때는 우리가 공들여 만든 다크 토큰마저 OS가 통째로 덮어씁니다. 방향이 반대인 셈이죠. 이 forced-colors에 어떻게 응답하는지, 내 앱에 고대비 토글을 직접 붙였다가 왜 멈췄는지는 다음 글에서 따로 풀어보겠습니다.
이미지와 미디어 처리#
다크모드에서 이미지는 갑자기 너무 밝아보일 수 있습니다. 약간의 밝기/대비 조정이 도움이 됩니다.
/* 다크모드에서 이미지 밝기 조금 낮춤 */
@media (prefers-color-scheme: dark) {
img:not([src$=".svg"]) {
filter: brightness(0.9) contrast(1.05);
}
}
/* data-color-scheme 방식 */
[data-color-scheme="dark"] img:not([src$=".svg"]) {
filter: brightness(0.9) contrast(1.05);
}SVG는 CSS로 색상을 제어할 수 있으니 제외합니다.
로고나 브랜드 이미지는 다크 전용 버전을 제공하는 게 가장 깔끔합니다.
<picture>
<source
srcset="logo-dark.svg"
media="(prefers-color-scheme: dark)"
/>
<img src="logo-light.svg" alt="회사 로고" />
</picture>FOUC 방지: 깜빡임 없는 초기화#
페이지가 로드될 때 잠깐 라이트모드로 보이다가 다크모드로 바뀌는 깜빡임(Flash of Unstyled Content, FOUC)을 방지하려면, <head> 안에서 스크립트를 실행해야 합니다.
<head>
<!-- CSS 로드 전에 실행 -->
<script>
(function() {
const scheme = localStorage.getItem('color-scheme') || 'system';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDark);
if (isDark) {
document.documentElement.setAttribute('data-color-scheme', 'dark');
}
})();
</script>
<link rel="stylesheet" href="styles.css" />
</head>이 스크립트는 CSS가 로드되기 전에 실행되어, 처음부터 올바른 모드로 렌더링됩니다.
정리#
다크모드는 단순히 “어두운 화면"이 아닙니다. 어두운 환경에서도 모든 정보를 명확하게 전달하는 별도의 디자인입니다.
핵심을 정리하면:
- CSS 변수로 토큰 설계: 색상을 직접 쓰지 말고 변수로 참조
- 다크모드 전용 값 정의: 반전이 아닌 재설계
- 시스템 설정 존중:
prefers-color-scheme으로 기본값 설정 - 사용자 선택권 제공: localStorage에 저장, 토글 버튼 접근성 처리
- 색 대비 재확인: 라이트모드 대비비가 다크에서도 유효한지 검증
- FOUC 방지:
<head>에 인라인 스크립트로 초기화
라이트모드를 공들여 만든 만큼, 다크모드도 똑같은 공이 들어가야 합니다. 두 배 일하는 게 억울하다면… 처음부터 토큰으로 설계해두세요. 적어도 유지보수는 훨씬 편해집니다.
그리고 그 “미스터리 테마"는 — 토큰 재설계와 대비 재채점을 거치면, 어두운 곳에서도 모든 글자가 제 목소리를 내는 진짜 다크모드가 됩니다.
질문으로 다시 보기#
다크모드는 색을 반전시키면 안 되나요?
다크모드 첫 로딩 때 화면이 깜빡이는 건 왜인가요?
이 시리즈의 다른 글#
프론트엔드 × 접근성 시리즈 전체 보기 — SPA 포커스 관리, 폼 UX, 모달까지 “일반 프론트엔드 주제에 접근성 시각을 연결"하는 연재입니다.
