우리 채팅 서비스에서 드디어 다크 모드 대응을 한다. 사용자들 중 눈의 피로도 때문에 다크 모드를 사용하고 싶다는 요청이 많아 해당 기능을 만들자는 의견이 많아졌었다. 솔직히 말하면 애초에 다크 모드를 고려하고 개발을 진행하지 않았기 때문에 CSS 코드에 통일성이 없는 부분이 좀 있었고, 이에 반해 디자이너 분들은 애초부터 다크 모드를 생각해서 스타일을 만들어주고 계셨기 때문에 이번 기회에 이 간극을 좁힐 수 있다는 생각에 재밌게 작업할 수 있었다.
보통 다크 모드를 위시한 잘 짜여진 디자인 시스템을 위해서는 디자인 토큰이란 개념이 중요하다. 간단하게 이야기하면 디자인 토큰이란,
디자이너와 개발자가 공유할 수 있도록 반복되는 디자인 속성에 이름을 붙인 것
이다.
디자인 토큰을 사용하면 디자인과 코드에 일관성을 부여할 수 있고, 확장 가능한 상태로 관리할 수 있다. 예를 들어 우리 서비스에서 기본적으로 사용하는 primary 색상은 #1a1a1a
인데, 매번 색상을 헥스 코드로 표기하지 않고 primary
라는 특정한 디자인 토큰을 이름 붙여 사용한다. 이렇게 되면 “이 색상을 사용하는 요소는 기본 primary 색상을 사용하는구나”라고 바로 알아차릴 수 있으므로 헥스 코드보다 알아보기도 쉽고 관리하기도 쉽다. 또한 확장 가능성의 관점에서도, 디자인 상 라이트 모드에서의 기본 primary 색상은 #1a1a1a
이지만 다크 모드에서는 #f7f7f7
인데, primary
토큰을 적용하고 이 토큰이 의미하는 색상값만 교체하면 되므로 관리하기도 훨씬 용이하다.
디자인 토큰의 대상은 색상뿐만 아니라 폰트 설정이나 스페이싱 등 다른 디자인 요소들에도 활용할 수 있으나, 일단 다크 모드를 적용하는 입장에서 디자이너 분들이 정해준 색상 디자인 토큰을 위주로 잘 활용할 수 있는 방안을 고민해야 한다. 디자인 토큰에 대한 좀 더 자세한 내용은 잘 설명한 글이 있으므로 참고하면 좋다.
우리 회사의 디자이너 분들은 이미 다크 모드를 고려해서 각 색상 토큰별로 라이트와 다크 모드에 해당되는 색상을 정해 주셨고, 그것에 맞춰 색상을 사용하고 있다.
// base-color.scss
$light-BG-default-base: $white;
$dark-BG-default-base: $gray90;
$light-gray-systemGray2: $gray40;
$dark-gray-systemGray2: $gray50;
$light-tint-systemBlue: $blue60;
$dark-tint-systemBlue: $blue40;
문제는 현재 늘 light 색상만 사용하고 있다는 것이다.
<template>
<button class="button">Button!</button>
</template>
<style scoped lang="scss">
.button {
color: $light-BG-default-base; /* SCSS 변수 */
}
</style>
이렇게 되면 다크 모드일 때 이렇게 일일이 변경해주어야 한다.
<template>
<button :class="['button', { 'button--dark': isDark }]">
Button!
</button>
</template>
<style scoped lang="scss">
.button {
color: $light-BG-default-base;
&--dark {
color: $dark-BG-default-base;
}
}
</style>
다크 모드 디자인을 적용해야 하는 요소가 한 두가지도 아니고, 이렇게 귀찮은 방식으로 변경하기는 원하지 않아서 다른 회사는 어떤 방식을 사용하는지 알고 싶어졌다.
카카오 웹툰은 어떻게 다크 모드와 라이트 모드를 관리하고 있을까? <html>
에 theme-color
라는 <meta>
태그를 놓고, 디폴트 색상 값을 content 속성에 정해 두고 모드에 따라 변경하고 있다. 그리고 <body>
태그에도 data-theme
이라는 data 속성을 담았다.
<html lang="ko" class="lang-ko">
<head>
<meta charSet="utf-8"/>
<meta name="theme-color" content="#ffffff"/>
<!-- <meta name="theme-color" content="#121212"/> -->
<meta name="description" content="카카오웹툰에서 상상력을 자극하는 웹툰들을 만나보세요. 다양한 장르의 새로운 웹툰들을 즐기실 수 있습니다!"/>
<!-- ... -->
</head>
<body data-theme="light">
<!-- <body data-theme="dark"> -->
<!-- ... -->
</body>
카카오 웹툰의 페이지가 맨 처음 로드되었을 때 CSS 파일을 살펴보자. --grey-01
, --red
등과 같이 기본적으로 프로젝트 전체에서 사용하는 것으로 보이는 CSS 색상 변수들이 정의되어 있다. 각 색상 값으로 --dark
prefix가 들어간 또다른 CSS 변수들이 등록된 것을 보니 기본적으로는 다크 모드를 사용하는 듯하다.
:root {
/* 이런저런 색상 CSS variable 설정 */
--grey-01: var(--dark-grey-01);
--grey-02: var(--dark-grey-02);
--grey-03: var(--dark-grey-03);
--grey-04: var(--dark-grey-04);
--grey-05: var(--dark-grey-05);
--grey-06: var(--dark-grey-06);
--grey-07: var(--dark-grey-07);
--grey-08: var(--dark-grey-08);
--grey-09: var(--dark-grey-09);
--grey-10: var(--dark-grey-10);
--grey-11: var(--dark-grey-11);
--grey-12: var(--dark-grey-12);
--grey-13: var(--dark-grey-13);
--grey-14: var(--dark-grey-14);
--grey-15: var(--dark-grey-15);
--background: var(--dark-background);
--background-02: var(--dark-background-02);
--red: var(--dark-red);
--blue: var(--dark-blue);
--purple: var(--dark-purple);
--gold: var(--dark-gold);
--guide: var(--dark-guide);
--pink: var(--any-pink);
--black-white: var(--any-black);
--white-black: var(--any-white)
}
그 밑을 보면 data-theme
이라는 속성을 가지고 있고 그 값이 light
인 모든 요소들에 대해 CSS 색상 변수들의 값을 --light
prefix를 가진 변수들로 오버라이드하고 있다. 카카오 웹툰은 <body>
태그에 data-theme
속성을 담았으므로 해당 속성 값이 light
면, <body>
안의 모든 요소들은 아래의 색상 설정이 적용될 것이다.
[data-theme=light] {
--grey-01: var(--light-grey-01);
--grey-02: var(--light-grey-02);
--grey-03: var(--light-grey-03);
--grey-04: var(--light-grey-04);
--grey-05: var(--light-grey-05);
--grey-06: var(--light-grey-06);
--grey-07: var(--light-grey-07);
--grey-08: var(--light-grey-08);
--grey-09: var(--light-grey-09);
--grey-10: var(--light-grey-10);
--grey-11: var(--light-grey-11);
--grey-12: var(--light-grey-12);
--grey-13: var(--light-grey-13);
--grey-14: var(--light-grey-14);
--grey-15: var(--light-grey-15);
--background: var(--light-background);
--background-02: var(--light-background-02);
--red: var(--light-red);
--blue: var(--light-blue);
--purple: var(--light-purple);
--gold: var(--light-gold);
--guide: var(--light-guide);
--pink: var(--any-pink);
--black-white: var(--any-white);
--white-black: var(--any-black)
}
github.com의 경우에는 다음과 같이 일반적인 경우를 포함해 색맹(colorblind)과 색약(tritanopia) 대상자를 위한 테마에서도 다크 모드와 라이트 모드를 같이 제공해 주고 있다.
<html
lang="en"
data-color-mode="auto" data-light-theme="light" data-dark-theme="dark"
data-a11y-animated-images="system" data-a11y-link-underlines="true"
>
<head>
<link data-color-theme="dark_dimmed" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_dimmed-a7246d2d6733.css" />
<link data-color-theme="light_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_high_contrast-46de871e876c.css" />
<link data-color-theme="dark_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_high_contrast-f2ef05cef2f1.css" />
<link data-color-theme="light_colorblind" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_colorblind-1ab6fcc64845.css" />
<link data-color-theme="dark_colorblind" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_colorblind-daa1fe317131.css" />
<link data-color-theme="light_tritanopia" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_tritanopia-c9754fef2a31.css" />
<link data-color-theme="dark_tritanopia" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_tritanopia-dba748981a29.css" />
</head>
<!-- ... -->
</html>
기본적으로는 <html>
의 data 속성으로 이런 모드를 관리하는 것으로 보인다. data-color-mode
속성으로 다크 모드와 라이트 모드를 구분하고, data-light-theme
과 data-dark-theme
속성으로 기타 모드들을 구분하는 듯하다.
그리고 <link>
태그에도 data-color-theme
data 속성을 적용해 각 모드에 해당하는 CSS 구성을 구분하는 것으로 보인다. 실제로 각 <link>
에 data-href
로 첨부되어 있는 CSS 파일을 살펴보면, 같은 CSS variable에 대해 data-color-mode
와 data-light-theme
값에 따라 다른 색상을 매긴 것을 볼 수 있다.
/* https://github.githubassets.com/assets/light_high_contrast-46de871e876c.css */
[data-color-mode="light"][data-light-theme="light_high_contrast"],
[data-color-mode="light"][data-light-theme="light_high_contrast"] ::backdrop,
[data-color-mode="auto"][data-light-theme="light_high_contrast"],
[data-color-mode="auto"][data-light-theme="light_high_contrast"] ::backdrop {
--topicTag-borderColor: #0349b4;
--highlight-neutral-bgColor: #fcf7be;
--page-header-bgColor: #e7ecf0;
/* ... */
}
/* https://github.githubassets.com/assets/dark_colorblind-1ab6fcc64845.css */
[data-color-mode="dark"][data-dark-theme="dark_colorblind"],
[data-color-mode="dark"][data-dark-theme="dark_colorblind"] ::backdrop,
[data-color-mode="auto"][data-light-theme="dark_colorblind"],
[data-color-mode="auto"][data-light-theme="dark_colorblind"] ::backdrop {
--topicTag-borderColor: #00000000;
--highlight-neutral-bgColor: #d2992266;
/* ... */
}
/* https://github.githubassets.com/assets/light_colorblind-daa1fe317131.css */
[data-color-mode="light"][data-light-theme="light_colorblind"],
[data-color-mode="light"][data-light-theme="light_colorblind"] ::backdrop,
[data-color-mode="auto"][data-light-theme="light_colorblind"],
[data-color-mode="auto"][data-light-theme="light_colorblind"] ::backdrop {
--topicTag-borderColor: #ffffff00;
--highlight-neutral-bgColor: #fff8c5;
/* ... */
}
네트워크 요청 히스토리를 보면 맨 처음 페이지가 로드될 때 위의 각 CSS 파일 중 사용자가 설정한 테마의 CSS 파일만을 요청한다. <link>
요소에 href
대신 data-href
로 CSS 파일 URL이 저장되어 있다가, 조건이 맞으면 이 값이 href
속성에 동적으로 적용되는 것으로 보인다.
/* https://github.githubassets.com/assets/light_high_contrast-46de871e876c.css */
[data-color-mode="light"][data-light-theme="light_high_contrast"],
[data-color-mode="light"][data-light-theme="light_high_contrast"] ::backdrop,
[data-color-mode="auto"][data-light-theme="light_high_contrast"],
[data-color-mode="auto"][data-light-theme="light_high_contrast"] ::backdrop {
--topicTag-borderColor: #0349b4;
--highlight-neutral-bgColor: #fcf7be;
--page-header-bgColor: #e7ecf0;
/* ... */
}
@media (prefers-color-scheme: dark) {
[data-color-mode="auto"][data-dark-theme="light_high_contrast"],
[data-color-mode="auto"][data-dark-theme="light_high_contrast"] ::backdrop {
/* media query가 적용되지 않았을 때와 동일한 스타일 */
--topicTag-borderColor: #0349b4;
--highlight-neutral-bgColor: #fcf7be;
--page-header-bgColor: #e7ecf0;
/* ... */
}
흥미로운 것은 다크 모드 적용 시 media query의 prefers-color-scheme 값과는 상관 없이 동일한 스타일을 적용했다는 것이다. 이는 OS의 설정과는 무관하게 사용자가 선택한 모드에 맞는 스타일을 적용하고자 한 것으로 보인다.
결론은 여러 서비스에서 <html>
요소 혹은 <body>
요소의 data 속성을 통해 어떤 모드가 적용되었는지를 구분한다는 것이다. 세부적인 동작은 다를 수 있으나, 기본적으로 특성 선택자를 사용하여 data 속성의 값에 따른 각 모드별 색상 스타일을 선언하여 사용하고 있다.
카카오 웹툰이나 깃허브에서 보았던 예시를 적용해 보기로 했다. 특성 선택자를 사용하여 다크 모드와 라이트 모드에 적용될 SCSS 변수들의 값을 변경해 줄 수 있지 않을까?
// base-color.scss
$BG-default-base: $white;
$BG-default-elevated: $white;
$BG-grouped-base: $gray05;
$BG-grouped-upperbase: $white;
$BG-grouped-elevated: $white;
[data-theme=dark] {
$BG-default-base: $gray90;
$BG-default-elevated: $gray80;
$BG-grouped-base: $black;
$BG-grouped-upperbase: $gray90;
$BG-grouped-elevated: $gray80;
}
그리고 모드를 바꾸는 기능을 하는 버튼을 만들어 놓고 동적으로 body
요소에 data-theme
속성값을 변경하는 기능을 만들었다.
<template>
<button class="button" @click="switchMode">Dark Mode</button>
</template>
<script setup lang="ts">
const switchMode = () => {
const currentTheme = document.body.getAttribute('data-theme')
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
document.body.setAttribute('data-theme', newTheme)
}
</script>
<style scoped lang="scss">
.button {
color: $BG-default-base;
}
</style>
이렇게 하면 될 줄 알았는데, body
의 data-theme
속성의 값이 변경되는 것을 확인했지만 스타일은 바뀌지 않았다.
그 이유는 SCSS가 CSS 전처리기 스크립팅 언어라는 것에서 찾을 수 있다. SCSS를 이용하여 작성된 구문은 브라우저가 이해할 수 없으므로 CSS 구문으로 컴파일하여야 한다(우리 프로젝트 번들러인 Vite의 경우 매 SCSS 파일이 변경될 때마다 컴파일을 진행하는 것으로 보인다). 그렇기 때문에 SCSS 변수는 컴파일 타임에 미리 결정되어 버려 런타임에서 동적으로 값이 변경될 수 없다.
우리 프로젝트에서 SCSS를 사용했던 이유는, 믹스인 등 편리한 기능들이 다수 포함되어 있기도 하지만, 무엇보다도 SCSS 변수를 사용하여 스타일 코드에 통일성을 부여할 수 있기 때문이다. 색상 토큰들을 변수화해서 같은 색상에 같은 변수명을 사용한다면 해당 색상이 어떤 색인지 명확하게 알 수 있을 뿐더러 관리하기도 쉽다.
하지만 동적으로 스타일을 변경하고 싶을 때 SCSS 변수를 사용할 수 없으니 다른 방법을 찾아야 한다. 그 대안이 CSS 변수이다. CSS 변수는 SCSS와는 다르게 브라우저가 CSS를 해석하는 시점에 처리되므로 런타임 시에 동적으로 값이 변경될 수 있다. CSS 변수이므로 당연히 변수의 값이 Cascading 되어, 최상단의 요소에서 변수의 값을 지정하면 자식 요소들까지 같은 값을 공유할 수 있다. 이는 우리가 SCSS 변수를 사용하고자 했던 이유와 일맥상통한다.
SASS 공식 문서에서 CSS 변수와 SCSS 변수를 비교한 내용이 있으니 참고용으로 알아두면 좋을 듯 하다.
따라서 기본적인 색상 토큰(gray90
등)은 이미 등록되어 있는 SCSS 변수를 계속해서 사용하고, 실제로 의미를 가지고 사용하는 토큰(BackGround-default-base
등)은 CSS 변수로 변경하였다.
:root 의사 클래스를 통해 document의 루트 요소(html
)에 변수를 지정해 준다. 그리고 CSS에 SCSS 표현식을 사용하기 위해서는 Interpolation이 필요하므로 #{}
구문을 사용하여 SCSS 변수를 넣어 주었다.
:root {
--BG-default-base: #{$white};
--BG-default-elevated: #{$white};
--BG-grouped-base: #{$gray05};
--BG-grouped-upperbase: #{$white};
--BG-grouped-elevated: #{$white};
}
[data-theme=dark] {
--BG-default-base: #{$gray90};
--BG-default-elevated: #{$gray80};
--BG-grouped-base: #{$black};
--BG-grouped-upperbase: #{$gray90};
--BG-grouped-elevated: #{$gray80};
}
이제 CSS 변수를 사용하였으므로 동적으로 모드가 변경되었을 때 CSS 변수의 값 역시 특성 선택자에 맞추어 변화하게 된다. 이에 따라 다크 모드와 라이트 모드 각각에 따른 스타일 변화를 편리하게 핸들링할 수 있게 되었다.
다크 모드를 적용할 때는 폰트와 배경 색상뿐 아니라 아이콘 스타일 역시 변경하여야 한다. 다크 모드나 라이트 모드에서 사용하는 아이콘의 경우 동일한 아이콘이라도 스타일이 각각 다른 것이 보통이다. 카카오 웹툰에서는 SVG 파일의 경우 아예 라이트 모드와 다크 모드 각각을 따로 만들어 놓고, 모드에 따라 파일을 변경한다.
네트워크 탭을 살펴보면 모드를 변경 시마다 각 테마에 맞는 아이콘들을 요청하여 가져오고 있는데, 약 1kb의 작은 파일이고 CDN이 적용되어 캐싱의 이점을 누릴 수 있다 하더라도 매번 파일을 네트워크를 통해 받아오는 것은 비효율적이라는 생각이 들었다.
여기서 잠깐, SVG 코드는 어떻게 색상을 관리할까? 아래는 기본적인 SVG 코드이다. SVG는 svg
요소 하위의 path
, circle
, rect
요소 등을 통해 선과 면 등을 그린다. 그리고 이 요소들에 fill, stroke 속성을 적용하여 색상을 정의한다.
<!-- 아이콘 출처: SVG Repo, www.svgrepo.com -->
<section id="section">
<svg width="80px" height="80px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.25589 16C3.8899 15.0291 3 13.4422 3 11.6493C3 9.20008 4.8 6.9375 7.5 6.5C8.34694 4.48637 10.3514 3 12.6893 3C15.684 3 18.1317 5.32251 18.3 8.25C19.8893 8.94488 21 10.6503 21 12.4969C21 14.0582 20.206 15.4339 19 16.2417M12 21V11M12 21L9 18M12 21L15 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</section>
만약 다크 모드를 적용하고자 하면 각 SVG 요소들의 fill
이나 stroke
값을 테마에 맞추어 변경하여야 하니 공수가 꽤나 크게 들어간다. 하지만 만약 여러 가지 색상이 사용되지 않고, 해당 SVG 아이콘의 색깔이 오로지 텍스트 색상과 동일하다면(다크 모드에서는 하양, 라이트 모드에서는 검정) 이를 핸들링하기는 훨씬 쉬워진다.
보통 filll 속성에 해당 요소의 색상을 넣어주는데, 이 때 색이 아닌 currenColor
라는 값을 넣으면 SVG가 자신이 속한 요소의 텍스트 색상 값(color
속성의 값)을 따라가게 된다.
<section id="section">
<svg width="80px" height="80px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.25589 16C3.8899 15.0291 3 13.4422 3 11.6493C3 9.20008 4.8 6.9375 7.5 6.5C8.34694 4.48637 10.3514 3 12.6893 3C15.684 3 18.1317 5.32251 18.3 8.25C19.8893 8.94488 21 10.6503 21 12.4969C21 14.0582 20.206 15.4339 19 16.2417M12 21V11M12 21L9 18M12 21L15 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h1>
Hello, svg
</h1>
<button id="mode-switch-btn">Mode Switch</button>
</section>
section {
background-color: white;
color: black;
}
이제 text
의 색상은 글자 색상과 동일하게 적용된다. h1
의 color
값이 black이라면, svg
코드 내의 text
요소의 fill
값도 black이다. 따라서 color의 값만 변경해준다면, 굳이 새 테마에 맞는 SVG 파일을 요청하지 않고도 SVG 아이콘의 색도 조절할 수 있다.
이에 대한 간단한 예제를 만들어 두었다 - Playcode 링크
물론 여러 색상이 사용되어 텍스트 색상만 가지고 표현할 수 없는 아이콘이라면 이런 방법을 쓰기에는 어려울 수 있다(위의 카카오웹툰도 이런 이유로 인해 어쩔 수 없이 매번 SVG 파일을 스위칭하는 것으로 보인다). 하지만 간단한 SVG 아이콘의 경우 이런 식으로 간단하게 다크 모드 대응이 가능하다.