다크 모드를 지원하는 CSS는 대개 이런 모양이다. 색마다 변수를 만들고, 미디어쿼리 안에서 같은 변수를 다시 선언한다.
:root {
--bg: #ffffff;
--ink: #1a1a1a;
--line: #e0e0e0;
--muted: #6b7280;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a;
--ink: #f2f2f2;
--line: #2c3136;
--muted: #9aa4b2;
}
}
동작은 한다. 대신 변수 목록을 두 벌 관리하게 된다. 색을 하나 추가하면 두 곳에 써야 하고, 한쪽만 고치면 어느 한 모드에서만 어긋난다. 변수가 스무 개쯤 되면 두 블록을 나란히 놓고 비교하는 일이 생긴다.
light-dark()는 두 값을 한 줄에 적는다.
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #14171a);
--ink: light-dark(#1a1a1a, #f2f2f2);
--line: light-dark(#e0e0e0, #2c3136);
--muted: light-dark(#6b7280, #9aa4b2);
}
블록이 하나로 줄고, 한 색의 두 값이 같은 줄에 붙는다. 대비를 확인할 때 눈을 위아래로 옮기지 않아도 된다.
2024년 5월 13일 사파리 17.5가 합류하면서 Baseline에 들어왔다. 크롬 123(2024년 3월), 파이어폭스 120(2023년 11월)이 먼저 지원했으니 2년 넘게 쓸 수 있었던 기능이다. Baseline 진입 후 30개월이 지나는 2026년 11월쯤이면 "widely available"로 올라간다.
먼저 알아야 할 것 — color-scheme 없이는 다크 값이 나오지 않는다
가장 많이 걸리는 자리다. light-dark()만 쓰고 color-scheme을 빠뜨리면 OS가 다크 모드여도 라이트 값만 나온다. 크롬 152와 사파리 26.5에서 똑같이 확인된다.
| 선언 | OS 라이트 | OS 다크 |
|---|---|---|
color-scheme 없음 | 라이트 값 | 라이트 값 |
color-scheme: normal | 라이트 값 | 라이트 값 |
color-scheme: light | 라이트 값 | 라이트 값 |
color-scheme: dark | 다크 값 | 다크 값 |
color-scheme: light dark | 라이트 값 | 다크 값 |
헷갈리기 쉬운 점은 이때 prefers-color-scheme: dark 미디어쿼리는 참이라는 것이다. 브라우저는 OS가 다크라는 것을 알고 있고, 미디어쿼리로 물어보면 그렇다고 답한다. 그런데 light-dark()는 라이트 값을 준다. 이 둘이 보는 것이 다르기 때문이다.
prefers-color-scheme은 사용자가 무엇을 선호하는지를 본다.light-dark()는 이 요소가 실제로 어느 스킴으로 그려지는지를 본다. 그것을 정하는 것이color-scheme이고, 기본값normal은 "라이트로 그린다"는 뜻이다.
MDN도 이 점을 전제로 적는다. "light-dark() 색 함수를 쓰려면 color-scheme이 light dark 값을 가져야 하며, 보통 :root에 설정한다."
그러니 light-dark()를 쓰는 문서에는 이 한 줄이 반드시 함께 간다.
:root { color-scheme: light dark; }
color-scheme 은 색 함수만을 위한 것이 아니다
이 한 줄은 light-dark()를 켜는 스위치이기만 한 것이 아니다. 브라우저가 직접 그리는 부분도 함께 바뀐다. 아래는 color-scheme: light dark만 선언하고 배경색·글자색을 CSS로 한 줄도 지정하지 않은 페이지다.
color-scheme: light dark 한 줄뿐이다문서 배경, 기본 글자색, 입력란, 체크박스, 선택 상자, 버튼이 전부 따라 바뀐다. 스크롤바도 마찬가지다. 다크 모드에서 입력란만 하얗게 남아 눈을 찌르는 현상은 대개 이 선언이 없어서 생긴다. 색을 전부 직접 칠하는 사이트라도 이 한 줄은 넣어 두는 편이 낫다.
색 자리라면 거의 어디든 들어간다
크롬 152에서 실제로 계산값을 확인한 것들이다. 색이 오는 자리면 대체로 그대로 동작한다.
color: light-dark(#1a1a1a, #f2f2f2);
border-color: light-dark(#e0e0e0, #2c3136);
box-shadow: 0 0 0 2px light-dark(#ddd, #333); /* 색 자리에만 */
background: linear-gradient(light-dark(#fff, #111), #888);
accent-color: light-dark(#2563eb, #60a5fa);
color: color-mix(in srgb, light-dark(#000, #fff) 50%, #800000);
color: light-dark(var(--l), var(--d)); /* 안에 var() 도 된다 */
currentColor와도 자연스럽게 섞인다. color를 light-dark()로 정하고 테두리를 currentColor로 잡으면 테두리도 같이 따라온다.
반대로 색이 아닌 값은 받지 않는다. 다음은 선언 자체가 무효가 되어 그 속성이 적용되지 않는다.
width: light-dark(100px, 200px); /* 무효 — 길이는 안 된다 */
box-shadow: light-dark(0 0 0 2px red, 0 0 0 8px blue); /* 무효 — 그림자 전체는 안 된다 */
두 번째가 특히 헷갈린다. 그림자의 색은 바꿀 수 있지만 번짐 크기까지 모드별로 다르게 하려면 여전히 미디어쿼리가 필요하다. 다크 모드에서 그림자를 더 넓게 퍼뜨리는 디자인이 흔한데, 그건 light-dark()로 안 된다.
이미지 형태는 아직 크롬만 된다
명세는 light-dark()에 이미지도 허용한다. MDN의 문법 정의에 이렇게 실려 있다.
light-dark( <color> , <color> )
light-dark( [<image> | none] , [<image> | none] )
되면 모드별 로고 교체가 CSS 한 줄이 된다. 실제로 크롬 152에서는 동작한다. 그런데 사파리 26.5에서는 무효다.
| 선언 | 크롬 152 | 사파리 26.5 |
|---|---|---|
color: light-dark(…, …) | 동작 | 동작 |
background-image: light-dark(url(a), url(b)) | 동작 | 무효 (none) |
사파리에서는 배경 이미지가 아예 없는 것이 된다. 폴백도 남지 않는다. Baseline 2024-05-13이라는 판정은 색 형태에 대한 것이고, 나중에 붙은 이미지 형태는 자기 시계로 따로 간다. 모드별 이미지 교체는 당분간 미디어쿼리나 <picture>로 하는 편이 안전하다.
변수에 담아도 쓰는 쪽 기준으로 해석된다
이 성질이 light-dark()를 쓸모 있게 만든다. :root에서 변수를 정의해도, 그 변수가 실제로 사용되는 요소의 color-scheme을 기준으로 값이 정해진다.
:root {
color-scheme: light dark;
--ink: light-dark(#1a1a1a, #f2f2f2);
}
.panel--light { color-scheme: light; }
.panel--dark { color-scheme: dark; }
.panel p { color: var(--ink); } /* 같은 변수, 다른 결과 */
실측하면 .panel--light 안에서는 #1a1a1a, .panel--dark 안에서는 #f2f2f2가 나온다. 변수를 두 벌 만들지 않고도 구역 단위로 스킴을 뒤집을 수 있다.
미디어쿼리 방식에서 같은 일을 하려면 어떤가. @media (prefers-color-scheme: dark)는 문서 전체에 걸리는 조건이라, "이 패널만 반대로"를 표현할 수 없다. 클래스를 따로 만들어 변수를 다시 선언하는 수밖에 없다. 다크 테마 위에 밝은 카드를 하나 얹거나, 코드 블록만 항상 어둡게 두는 식의 요구가 이 방식에서 특히 번거로웠다.
color-scheme은 상속되므로 그 구역 안의 폼 컨트롤과 스크롤바까지 함께 뒤집힌다. 색 변수만 바꿨을 때 남던 어색함이 사라진다.
미디어쿼리를 완전히 대체하지는 않는다
다음은 여전히 @media (prefers-color-scheme: dark)가 필요하다.
- 색이 아닌 값 — 그림자 번짐 크기, 테두리 두께, 투명도 값 자체
- 모드별 이미지 교체 — 사파리가 아직 안 되므로
- 규칙 자체를 켜고 끄는 것 — 다크에서만 특정 요소를 감추거나 다른 레이아웃을 쓰는 경우
- 필터·혼합 모드 — 다크에서 이미지 밝기를 낮추는
filter: brightness()같은 것
정리하면 색은 light-dark()로, 색이 아닌 것은 미디어쿼리로 나누는 그림이 된다. 대개 색이 압도적으로 많으므로 미디어쿼리 블록은 남더라도 훨씬 짧아진다.
정리
light-dark(라이트값, 다크값)으로 두 모드의 색을 한 줄에 적는다. Baseline 2024-05-13(사파리 17.5)부터라 이미 쓸 수 있다.:root { color-scheme: light dark; }가 반드시 함께 가야 한다. 없으면 OS가 다크여도 라이트 값만 나온다.prefers-color-scheme이 참인 것과는 별개다.- 그 한 줄은 브라우저가 그리는 부분—문서 배경, 입력란, 버튼, 스크롤바—도 함께 바꾼다. 색을 직접 다 칠하는 사이트에도 넣어 둘 값어치가 있다.
- 색이 오는 자리면 대체로 동작한다.
box-shadow는 색 부분만이고, 길이나 그림자 전체는 받지 않는다. - 이미지 형태는 크롬만 된다. 사파리 26.5에서는 무효가 되어 배경이 사라진다.
- 변수에 담아도 쓰는 요소의
color-scheme기준으로 해석되므로, 구역 단위로 스킴을 뒤집기 쉽다. 미디어쿼리로는 하기 번거롭던 일이다.
기능 자체는 2년 전에 나왔고 문법도 한 줄이라 새로 배울 것이 거의 없다. 실제로 막히는 지점은 color-scheme을 빠뜨려 "왜 다크가 안 먹지"로 시간을 쓰는 자리 하나다. 그 한 줄만 기억하면 된다.