다크 모드는 어두운 배경을 중심으로 전반적인 요소를 어두운 색상 체계로 구성한 low-light UI를 지칭한다.
스마트폰과 같은 디바이스의 사용량이 증가면서, 눈의 피로를 덜어주고 집중력을 높여준다는 이유로 다크모드가 등장하였다. 현재는 수많은 웹사이트들이 다크모드를 옵션으로 제공하고 있으며, 이제는 모든 서비스들의 필수 항목처럼 되어가고 있다.
이 글은 기존 프로젝트에 styled-component와 context API를 사용하여 다크모드 옵션을 추가하는 과정을 담고 있다. 추가로 다크모드를 적용하기 전에, 지켜야 할 규칙과 가이드라인을 살펴본다.
다크모드 가이드라인
다크모드를 개발하기 전에, 먼저 가이드라인을 찾아보았다.
Material Deisgn의 다크모드 가이드라인을 참고하여 특히 주의해야 할 점들을 정리했다.
1. 채도 감소: low-light UI에서는 색상이 더 두드러져 보이기 때문에 보다 가볍고 채도가 낮은 색상을 사용하는 것이 좋다. 채도가 높은 색상은 어두운 바탕에서 강하게 충돌하는 느낌이 드는데, 이를 경계가 ‘진동하는 것처럼 보인다' 고 표현한다.
2. 깊이: 다이얼로그는 보통 메인 화면 위로 표시된다. 보통 다이얼로그가 메인 화면의 위에 있다는 것을 표현하기 위해 다이얼로그를 제외한 배경을 Dimmed 처리한다. 하지만 이 방식은 다크모드에서는 효과를 보기 힘들다.
다크모드에서 깊이감을 표현하기 위해서는 배경의 밝기에 변화를 준다. 상단의 레이어일수록 밝은 색을 적용하여 그 깊이를 표현할 수 있다.
3. 대비: 웹 콘텐츠 접근성 지침(이하 WCAG)을 위배하지 말아야 한다. WCAG에 따르면 텍스트를 시각적으로 표시할때의 대비율은 최소한 4.5 대 1이 되어야 한다.
배경색과 기본 텍스트 색 정하기
배경색에 대한 고찰
다크모드에서 가장 주의를 기울여야 하는 점은 명확한 대비를 통해 요소 간 구분이 쉽도록 하는 것이다.
그렇다면 배경을 #000, 글씨를 #fff 로 하면 되지 않을까? 라고 생각하기 쉬운데, 오히려 완전한 검은색 배경에 흰색 텍스트는 눈의 피로를 극대화시킬 수 있다고 한다.
개인적으로도 완전한 검은색 배경을 별로 좋아하지 않기 때문에 구글 다크모드 배경색인 #212426을 사용하기로 결정하였다.
기본 텍스트 색에 대한 고찰
다크모드에서는 상단의 레이어일수록 밝은 색을 적용하여 그 깊이를 표현한다고 했다.
높이의 단계가 4개나 5개인 경우 기본 텍스트(본문)와 기본 배경간에 최소 15.8:1의 대비 수준을 사용해야 가장 상단의 레이어와 기본 텍스트의 대비 수준이 4.5:1이 된다.
현재 프로젝트는 본문과 다이얼로그 총 2단계의 높이를 가지기 때문에 기본 텍스트와 기본 배경의 대비가 15.8: 1 수준까지 나타날 필요가 없었다. 따라서 글씨 색은 무난하게 #FEFEFE를 사용하기로 했다.
❗️ 한 가지 주의점은 어두운 배경의 밝은 텍스트는 밝은 곳에서 어두운 것보다 더 굵게 나타날 수 있다. 따라서 다크 모드에서는 font-weight값을 더 낮춰 사용하는 것이 좋다.
🎨 프로젝트 색상 팔레트 만들기
프로젝트 개발 초반에는 색상 팔레트를 만들지 않고 필요한 색을 그때그때 추가하여 사용했다.
다크모드 기능을 도입하기위해서는 라이트 테마에서 정의한 색상과 다크 테마에서 정의한 색상이 1:1로 대치되어야했고, 그러려면 프로젝트에서 어떤 색상을 사용하는지 알아야했다. 우선 라이트모드와 다크모드 각각 필요한 색상을 모아서 색상 팔레트를 만들어보았다.
위 이미지에서도 보이다시피 각 모드에 필요한 색상의 가짓수가 다르다. 이는 단순하게 #202124 → #fefefe 로 대치하듯 색상별로 1:1 대치하는 것이 불가능하다는 뜻이다.
이 문제를 해결하기 위해 ‘시맨틱 컬러’를 사용했다.
시맨틱 컬러란 컬러를 Hex값이 아닌 컬러가 사용되는 목적과 적용되는 UI에 따라 네이밍하고 시스템화하는 것 을 뜻하며, 간단하게 말해 ‘용도에 따른 색상 정의' 이다.
즉, #202124를 ‘gray-900’ 으로 정의하는 것이 아닌, ‘background’로 정의하여 그 목적을 이름에 담아주는 것이다.
화면을 둘러보면서 색상 정의가 필요한 UI들을 정리해보았다.
- background
- text( 본문 글씨, 로고, 아이콘 모두 같은 색 사용)
- 상태 3가지 ( correct, present, absent) + 텍스트는 #fefefe로 동일
- 보드 배경색, 텍스트 색, 테두리색(기본, 활성화 시)
- 키보드 배경색, 텍스트 색, hover시 배경색
- 다이얼로그 배경색
- 버튼 배경색, hover시 배경색, 텍스트색
정리한 UI를 토대로 용도별로 색상을 정의한 팔레트를 새로 만들어주었다.
이제 우리는 위의 용도별 팔레트를 토대로 색상을 정의하여 프로젝트에 사용할 것이다.
프로젝트에 다크모드를 적용해보자🔥
📁 기존 style 폴더 구성
다크모드를 도입하려는 프로젝트에서 style 폴더 하위에는 다음과 같이 두개의 파일이 존재한다.
GlobalStyle
파일에는 전역적으로 사용할 기본 스타일들을 설정해주었고,
import { createGlobalStyle } from "./theme";
const GlobalStyle = createGlobalStyle`
* {
font-size: 10px;
}
body{
font-family: 'Nunito','Pretendard', sans-serif;
margin: 0;
font-size: 12px;
}
h2{
font-size: 18px;
}
button{
cursor: pointer;
border: none;
}
div{
font-size: 12px;
}
`;
export default GlobalStyle;
theme
파일에는 공통으로 사용하는 색들을 정의해주었다.
import baseStyled, { ThemedStyledInterface } from "styled-components";
export const theme = {
color: {
correct: "#5babab",
present: "#fdb800",
absent: "#909090",
},
};
export type Theme = typeof theme;
export const styled = baseStyled as ThemedStyledInterface<Theme>;
두 파일 모두 Root 컴포넌트에서 App컴포넌트를 감싸는 형태로, 아래와 같이 사용한다.
import React from "react";
import App from "./App";
import PortalContainer from "./components/modals/PortalContainer";
import GlobalStyle from "./style/GlobalStyle";
import { RecoilRoot } from "recoil";
import { ThemeProvider } from "styled-components";
import { theme } from "./style/theme";
function Root() {
return (
<RecoilRoot>
<ThemeProvider theme={theme}>
<GlobalStyle />
<App />
<PortalContainer />
</ThemeProvider>
</RecoilRoot>
);
}
export default Root;
theme 색상 정의하기
theme.ts
안에 다크 테마와 라이트 테마의 색상을 정의해주었다.
// styles/theme.ts
import * as styledComponents from "styled-components";
import { ThemedStyledComponentsModule } from "styled-components";
const color = {
correct: "#5babab",
present: "#fdb800",
absent: "#908790",
};
export const light = {
background1: "#fefefe",
background2: "#fefefe", // 다이얼로그
text: "#202124",
keyBg1: "#e3e1e3",
KeyBg2: "#cfcbcf",
boardBg: "white",
boardBorder1: "#cfcbcf",
boardBorder2: "#202124", // 활성화시
button1: "#e3e1e3",
button2: "908790",
color: { ...color },
};
export const dark = {
background1: "#202124",
background2: "#38393e", // 다이얼로그
text: "#fefefe",
keyBg1: "#403c40",
KeyBg2: "#766c76",
boardBg: "#131213",
boardBorder1: "#766c76",
boardBorder2: "#e3e1e3", // 활성화시
button1: "#5c565c",
button2: "#908790",
color: { ...color },
};
export type Theme = typeof light;
export const { default: styled, createGlobalStyle } =
styledComponents as any as ThemedStyledComponentsModule<Theme>;
export const styled = baseStyled as ThemedStyledInterface<Theme>;
기존에는 ThemedStyledInterface
타입만을 가져와서 새롭게 선언해주었다면,
export const { default: styled, createGlobalStyle } =
styledComponents as any as ThemedStyledComponentsModule<Theme>;
다크모드를 추가하면서createGlobalStyle
에도 선언한 테마 색상 값을 사용하기 때문에 ThemedStyledComponentsModule
을 가져와 테마 타입과 함께 export 해주었다.
➕ 이런 설정과는 별개로 styled-components.d.ts
파일을 만들어 테마 타입을 확장하는 방식을 사용해도 된다.
styled 색상 정의 코드 변경하기
// Root.tsx
import React, {useState} from "react";
import App from "./App";
import PortalContainer from "./components/modals/PortalContainer";
import GlobalStyle from "./style/GlobalStyle";
import { RecoilRoot } from "recoil";
import { ThemeProvider } from "styled-components";
import { light, dark } from "./style/theme";
function Root() {
const [theme, setTheme] = useState("light");
return (
<RecoilRoot>
<ThemeProvider theme={theme === "light" ? light : dark}>
<GlobalStyle />
<App />
<PortalContainer />
</ThemeProvider>
</RecoilRoot>
);
}
export default Root;
Root
컴포넌트에서 현재 테마를 담을 상태를 선언해주고, 그 값을 theme 속성으로 전달했다.
이제 선택한 테마에 따라 background
와 text
색상이 변경되도록 코드를 변경해주자.
// style/GlobalStyle.ts
import { createGlobalStyle } from "./theme";
const GlobalStyle = createGlobalStyle`
...
body{
...
background: ${({ theme }) => theme.background1};
color: ${({ theme }) => theme.text};
transition: background 0.2s ease-in, color 0.2s ease-in;
}
...
`;
export default GlobalStyle;
❗️ 기존의 styled-component에서 createGlobalStyle
을 가져와 사용하는 것이 아닌, theme.ts
에서 export한 createGlobalStyle
을 import해서 사용해야 한다.
다른 컴포넌트에서도 색상을 정의해준 곳마다 동일하게 코드를 변경해주면 된다.
// components/main/keyboard/Key.tsx
import React from "react";
import { styled } from "../../../style/theme";
...
function Key({ value, status, width = 4, onClick }: Props) {
...
return(
<KeyBlock
width={width}
color={status}
...
>
{value}
</KeyBlock>
)
}
const KeyBlock = styled.div<StyleProps>`
background-color: ${({ theme }) => theme.keyBg1};
...
&:hover {
background-color: ${({ theme }) => theme.KeyBg2};
}
&.on {
background-color: ${({ theme, color }) => color && theme.color[color]};
border-color: ${({ theme, color }) => color && theme.color[color]};
color: white;
}
`;
export default Key;
❗️ 일반 컴포넌트 역시 theme.ts
에서 export한 styled
을 import해서 사용해야 한다.
변경을 끝냈다면 Root
컴포넌트에서 theme의 기본 상태 값을 "dark"
로 설정한 뒤 제대로 적용됐는지를 확인해보자.
토글 버튼 만들기
이제 테마 모드를 변경할 버튼을 만들어보자.
이 프로젝트에서 기능 버튼은 모두 Header 안에 있다. Header의 가장 왼쪽에 버튼을 추가해 주었다.
Header
컴포넌트는 Root의 자식 컴포넌트 App의 자식 컴포넌트이다. theme값은 Root
에서 사용하고, theme의 setter함수는 Root -> App을 거쳐 Header에서 사용해야 하는 상황이다. 아, 현재 테마 모드에 따라 아이콘을 바꿔줘야 하니 theme 값도 같이 넘겨줘야 한다.
깔끔한 코드를 위해 theme를 전역 상태로 만들 필요가 있었다. 이 프로젝트에서는 전역 상태관리 라이브러리로 recoil을 사용하고 있지만, theme는 새로운 Context를 만들어 관리하는 방식을 선택하였다.
useTheme 커스텀 Hook 만들기
먼저, 테마의 토글 기능 처리를 위해 커스텀 Hook을 만들어주었다.
// hooks/useTheme.ts
import { useCallback, useState } from "react";
function useTheme() {
const [theme, setTheme] = useState("light");
const onChangeTheme = useCallback(() => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
}, []);
return {
theme,
onChangeTheme,
};
}
export default useTheme;
새로운 ThemeProvider 만들기
theme에 대한 context를 새롭게 생성하는 것과 동시에 기존 styled-component의 ThemeProvider를 커스텀해주었다.
// context/ThemeProvider.tsx
import useTheme from "hooks/useTheme";
import React, { ReactNode } from "react";
import { light, dark } from "../style/theme";
import { ThemeProvider as StyledThemeProvider } from "styled-components";
type Props = {
children: ReactNode;
};
const defaultValue = {
theme: "light",
onChangeTheme: () => {},
};
export const ThemeContext = React.createContext(defaultValue);
function ThemeProvider({ children }: Props) {
const themeProps = useTheme();
return (
<ThemeContext.Provider value={themeProps}>
<StyledThemeProvider theme={themeProps.theme === "light" ? light : dark}>
{children}
</StyledThemeProvider>
</ThemeContext.Provider>
);
}
export default ThemeProvider;
Root 컴포넌트의 ThemeProvider를 새로 커스텀한 ThemeProvider로 교체해준다.
import React from "react";
import App from "./App";
import PortalContainer from "./components/modals/PortalContainer";
import GlobalStyle from "./style/GlobalStyle";
import { RecoilRoot } from "recoil";
import ThemeProvider from "context/ThemeProvider";
function Root() {
return (
<RecoilRoot>
<ThemeProvider>
<GlobalStyle />
<App />
<PortalContainer />
</ThemeProvider>
</RecoilRoot>
);
}
export default Root;
다크모드 버튼이 있는 Header
컴포넌트에 useContext
로 theme값과 setter함수를 가져와서 셋팅해주었다.
import React, { useContext } from "react";
import styled from "styled-components";
import cn from "classnames";
import { ThemeContext } from "context/ThemeProvider";
function Header() {
const { theme, onChangeTheme } = useContext(ThemeContext);
...
return (
<HeaderStyle>
<Button onClick={onChangeTheme}>
<i
className={cn(
"fa-solid",
{ "fa-sun": theme === "light" },
{ "fa-moon": theme === "dark" }
)}
></i>
</Button>
...
</HeaderStyle>
);
}
...
export default Header;
이제 버튼을 누르면 라이트모드 ↔️ 다크모드로 전환되는것을 확인할 수 있다.
다크모드 설정 유지하기
다크모드로 설정한 상태에서 새로고침을 하면, 다크모드가 유지되지 않고 새롭게 라이트모드로 적용된다.
사용자가 설정한 테마 값을 로컬스토리지에 저장하여 새로고침을 하더라도 이전에 설정한 테마 값이 유지되도록 변경해보자.
// hooks/useTheme.ts
import { useCallback, useLayoutEffect, useState } from "react";
function useTheme() {
const [theme, setTheme] = useState("light");
const onChangeTheme = useCallback(() => {
const updatedTheme = theme === "light" ? "dark" : "light";
setTheme(updatedTheme);
localStorage.setItem("theme", updatedTheme);
}, [theme]);
useLayoutEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme && ["dark", "light"].includes(savedTheme)) {
setTheme(savedTheme);
}
}, []);
return {
theme,
onChangeTheme,
};
}
export default useTheme;
이제 새로고침 후에도 이전에 설정한 테마값이 잘 유지된다.
하지만 보는 사람에 따라 라이트모드 → 다크모드로 변환되는 효과가 거슬릴 수 있다.
새로고침 시 불필요한 전환효과를 없애자
새로고침 시에 라이트모드에서 다크모드로 전환 효과가 보여지는 이유는 테마를 부드럽게 변경하기 위해 설정한 ‘transition’ 때문이다.
실제로 스타일에서 트랜지션을 제거하면 깜빡임 없이 다크모드가 유지되는 것을 볼 수 있다.
하지만 이 경우 테마의 전환 과정이 너무 부자연스럽게 느껴진다.
트랜지션을 적용하면서 새로고침 시 전환효과가 일어나지 않게 하는 방법을 찾아야 했다.
useTheme
에서 우리는 테마의 초기값을 “light”
로 설정해놨고, 렌더링 이후 로컬스토리지에 저장된 값을 가져와서 상태를 바꿔주었다. “light”
에서 “dark”
로 변하는 과정이 트렌지션으로 인하여 사용자의 눈에 포착된 것이다.
그렇다면, 로컬스토리지에 저장된 값을 가져와서 상태를 바꾸는 일을 화면에 그리기 전에 수행하도록 바꿔주면 어떨까?
useEffect
가 아닌 useLayoutEffect
를 사용해보자.
useEffect
가 컴포넌트가 렌더링되고 화면에 그려진 이후 비동기적으로 실행된다면,
useLayoutEffect
는 렌더링된 컴포넌트가 화면에 그려지기 전에 동기적으로 실행된다.
// hooks/useTheme.ts
import { useCallback, useLayoutEffect, useState } from "react";
function useTheme() {
const [theme, setTheme] = useState("light");
const onChangeTheme = useCallback(() => {
const updatedTheme = theme === "light" ? "dark" : "light";
setTheme(updatedTheme);
localStorage.setItem("theme", updatedTheme);
}, [theme]);
useLayoutEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme && ["dark", "light"].includes(savedTheme)) {
setTheme(savedTheme);
}
}, []);
return {
theme,
onChangeTheme,
};
}
export default useTheme;
body는 React가 제어하는 DOM node 밖의 요소이기 때문에 hook flow의 영향을 받지 않는다. App
컴포넌트에 <Background/>
요소를 추가하여 body에 배경을 넣었을 때와 동일하게 보이도록 해주었다.
// App.tsx
import Header from "./components/header";
import Main from "./components/main/Main";
import styled from "styled-components";
function App() {
return (
<Background>
<Wrapper>
<Header />
<Main />
</Wrapper>
</Background>
);
}
const Background = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${({ theme }) => theme.background1};
color: ${({ theme }) => theme.text};
transition: background 0.3s ease-in, color 0.3s ease-in;
`;
...
export default App;
transition 적용을 유지하면서, 새로고침 시에도 불필요한 테마 전환이 사라졌다.
➕ 브라우저에 설정한 테마 값 가져오기
만약 프로젝트에 처음 들어오는 사용자가 시스템 설정으로 다크모드를 사용하고 있다면, 초기 테마를 시스템 설정과 동일하게 적용하는 것이 사용자에게 더 좋은 UX를 제공할 것이다.
import { useCallback, useLayoutEffect, useState } from "react";
function useTheme() {
const [theme, setTheme] = useState("light");
...
useLayoutEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme && ["dark", "light"].includes(savedTheme)) {
setTheme(savedTheme);
return;
}
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
setTheme("dark");
}
}, []);
return {
theme,
onChangeTheme,
};
}
export default useTheme;
prefers-color-scheme
미디어 쿼리를 사용하여 prefers-color-scheme
값이 ‘dark’ 라면 프로젝트 테마를 다크모드로 설정하는 코드를 추가하였다.