[React] 다크모드 적용하기 ( with. Styled-component, Context API)
Front-End/React

[React] 다크모드 적용하기 ( with. Styled-component, Context API)

 

 

 

다크 모드는 어두운 배경을 중심으로 전반적인 요소를 어두운 색상 체계로 구성한 low-light UI를 지칭한다.

스마트폰과 같은 디바이스의 사용량이 증가면서, 눈의 피로를 덜어주고 집중력을 높여준다는 이유로 다크모드가 등장하였다. 현재는 수많은 웹사이트들이 다크모드를 옵션으로 제공하고 있으며, 이제는 모든 서비스들의 필수 항목처럼 되어가고 있다.

 

 

이 글은 기존 프로젝트에 styled-componentcontext 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 속성으로 전달했다.

이제 선택한 테마에 따라 backgroundtext색상이 변경되도록 코드를 변경해주자.

// 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 를 사용해보자.

 

React Hook의 실행 순서를 나타낸 그림

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’ 라면 프로젝트 테마를 다크모드로 설정하는 코드를 추가하였다.