[NextJS] 다크 모드 구현하기

김학재·2023년 5월 18일
2

NextJS

목록 보기
1/3

다크 모드 gif

NextJS와 Typescript를 사용해서 개인용 블로그를 만드는 과정 중, 다크 모드를 구현한 내용을 정리한 글입니다.


요구 사항

  • 유저가 다크 모드 / 라이트 모드를 선택할 수 있다
  • 선택한 모드를 저장한 뒤 재사용할 수 있어야 한다
  • 유저가 접속 시 기존에 선택한 모드로 렌더링해야한다
  • 기존에 선택한 모드가 없다면 default 모드로 렌더링해야한다
  • 새로 고침해도 선택한 모드가 유지되어야 한다

hook을 사용해서 다크모드 구현하기

다크 모드를 구현하기 위해 useDarkmode 라는 이름의 hook을 만들어 사용합니다. 최초로 구현된 코드는 아래와 같습니다. (상태 관리 X)

// src/util/hoooks/useDarkmode.ts
import { useEffect, useState } from 'react'

type Theme = 'light' | 'dark'

export const useDarkMode = (): [Theme, () => void] => {
  const [theme, setTheme] = useState<Theme>('light')

  useEffect(() => {
    const localTheme = window.localStorage.getItem('theme') as Theme | null

    if (localTheme) {
      setTheme(localTheme)
      document.body.dataset.theme = localTheme
    } else {
      setTheme('light')
      document.body.dataset.theme = 'light'
    }
  }, [])

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light'
    setTheme(newTheme)
    window.localStorage.setItem('theme', newTheme)
    document.body.dataset.theme = newTheme
  }

  return [theme, toggleTheme]
};

유저가 다크 모드 / 라이트 모드를 선택할 수 있다

선택 가능한 theme 값은 'light', 'dark' 총 2가지이므로 type을 정의해서 사용합니다. useState hook을 사용해 theme 값을 변경할 수 있도록 하고, 기본 theme은 'light' 로 지정합니다.

유저가 접속 시 기존에 선택한 모드로 렌더링해야한다
새로 고침해도 선택한 모드가 유지되어야 한다
기존에 선택한 모드가 없다면 default 모드로 렌더링해야한다

유저가 선택한 모드를 localStorage에 저장합니다. 최초 접속 유저는 null 값을 가지므로 type 선언에 null을 추가합니다.

만약 localStorage에 저장된 값이 이미 있다면, theme값을 업데이트하고 body 태그의 theme 값도 변경합니다.

localStorage에 저장된 값이 없다면, 기본 값인 'light' 테마로 설정합니다.

또한, useDarkmode hook에서 toggleTheme 메소드를 return 함으로써, 실제 모드를 변경하는 컴포넌트에 전달해서 theme을 변경할 수 있도록 합니다.

// components/Darkmode.tsx
import React from 'react'
import styled from 'styled-components'
import { useDarkMode } from '@/util/hooks/useDarkmode'

// class 값에 따라 아이콘을 변경합니다
const DarkModeBtn = styled.div`
  width: 30px;
  height: 30px;
  background-size: 26px 26px;
  background-repeat: no-repeat;

  &.light {
    background-image: url('/icon/icon-sun.svg');
  }

  &.dark {
    background-image: url('/icon/icon-moon.svg');
  }
`

export default function DarkMode() {
  const [mode, toggleTheme] = useDarkMode()

  return (
    <DarkModeBtn onClick={toggleTheme} className={mode}/>
  )
}

Icon 컴포넌트화하기

최초 요구사항대로 다크 모드를 구현하고 테스트를 하다 보니 배경 색, 텍스트 색은 문제가 없었습니다. 그러나, svg로 나타낸 아이콘의 경우 라이트 모드에서는 이미지가 잘 보이는 반면, 다크 모드에서는 잘 보이지 않는 문제가 발생했습니다.

(Footer의 로고가 보이지 않는 현상)
다크 모드 아이콘 렌더링 문제

기존에는 svg 파일을 import해서 그대로 background-image 로 나타내다 보니 이런 현상이 발생했습니다.
이를 해결하기 위해서는 svg의 fill 속성을 변경해야 했기에 Icon을 컴포넌트화했습니다.

// components/Icon.tsx
import { ReactNode, useContext } from 'react'
import styled, { ThemeContext } from 'styled-components'

type IconProps = {
  icon: ReactNode
  width?: number
  height?: number
}

const IconWrapper = styled.span<{ width: number, height: number }>`
  display: inline-block;
  svg {
    width: ${(props) => props.width ? `${props.width}px` : 'auto'};
    height: ${(props) => props.height ? `${props.height}px` : 'auto'};
  }
`

export default function Icon({ icon, width = 30, height = 30 }: IconProps) {
  return (
    <IconWrapper width={width} height={height}>
      { icon }
    </IconWrapper>
  )
}

Icon 컴포넌트는 30 * 30 사이즈를 기본으로 하며, 나타내고 싶은 icon을 prop으로 전달받습니다.

실제 페이지에서 사용 방법은 아래와 같습니다

// components/Footer.tsx
import { IconGithub } from './icon/IconGithub'

...

export default function Footer() {
  ...
  <Icon icon={<IconGithub />} />
  ...
}

위 코드에는 아직 fill 속성이 포함되어 있지 않은데, 이 부분은 아래 상태 저장 파트에서 후술하도록 하겠습니다.

상태 저장

localStorage를 사용하는 것으로 다크 모드 구현을 완성할 수 있습니다.

하지만 서버 사이드에서 동작하는 NextJS의 특성 상 컴포넌트, 페이지 단에서 localStorage에 접근할 수 없기에, 아이콘의 색깔 문제와 같은 현상을 해결하기 위해서는 theme 값을 별도의 상태에 저장해서 사용해야 하므로 대표적인 상태 관리 라이브러리인 'recoil'을 사용하기로 결정했습니다.

recoil의 사용을 위해 루트 컴포넌트에 recoilRoot 를 선언합니다.
(보다 자세한 내용은 공식 문서 참고)

선택한 모드를 저장한 뒤 재사용할 수 있어야 한다

// src/store/theme.tsx
import { atom } from 'recoil'

export type ThemeState = {
  value: string
}

export const initialThemeState: ThemeState = {
  value: 'light'
}

export const themeState = atom({
  key: 'themeState',
  default: initialThemeState
})

'light' 를 기본 값으로 갖는 theme 상태를 만듭니다.
이제부터 store/theme 값을 사용해서 현재 모드를 저장하고 사용하도록 합니다.
따라서, 변경된 useDarkmode hook은 다음과 같습니다.

// src/util/hoooks/useDarkmode.ts
import { useEffect } from 'react'
import { useRecoilState } from 'recoil'
import { themeState } from '@/store/theme'

type Theme = 'light' | 'dark'

export const useDarkMode = (): [string, () => void] => {
  const [theme, setTheme] = useRecoilState(themeState)

  useEffect(() => {
    const localTheme = window.localStorage.getItem('theme') as Theme | null
    
    if (localTheme) {
      setTheme({
        value: localTheme
      })
      document.body.dataset.theme = localTheme
    } else {
      setTheme({
        value: 'light'
      })
      document.body.dataset.theme = 'light'
    }
  }, [setTheme])

  const toggleTheme = () => {
    const newTheme = theme.value === 'light' ? 'dark' : 'light'
    window.localStorage.setItem('theme', newTheme)
    document.body.dataset.theme = newTheme
    setTheme({
      value: newTheme
    })
  }

  return [theme.value, toggleTheme]
}

로직은 그대로이며, 상태를 저장할 때 useState hook이 아닌 useRecoilState 를 사용합니다.
이제 저장된 theme 상태 값을 사용해서 다크 모드 구현을 완성합니다.

// components/Darkmode.tsx
import styled from 'styled-components'
import { useDarkMode } from '@/util/hooks/useDarkmode'

import { useRecoilState } from 'recoil'
import { themeState } from '@/store/theme'

const DarkModeBtn = styled.div`
  width: 30px;
  height: 30px;
  background-size: 26px 26px;
  background-repeat: no-repeat;

  &.light {
    background-image: url('/icon/icon-sun.svg');
  }

  &.dark {
    background-image: url('/icon/icon-moon.svg');
  }
`

export default function DarkMode() {
  const [_, toggleTheme] = useDarkMode()
  const [theme] = useRecoilState(themeState)

  return (
    <DarkModeBtn onClick={toggleTheme} className={theme.value}/>
  )
}

상술한 Icon 배경색 문제를 해결하기 위해 다음과 같이 수정합니다

// components/Footer.tsx
import { IconGithub } from './icon/IconGithub'
import { themeState } from '@/store/theme'
import { useRecoilState } from 'recoil'

...

export default function Footer() {
  const [theme] = useRecoilState(themeState)
  ...
  <Icon icon={<IconGithub isDark = { theme.value === 'dark' } />} />
  ...
}

Icon~ 컴포넌트는 각각의 svg 파일을 컴포넌트화한 뒤, isDark prop을 전달받아 다크 모드인 경우 fill 값을 #ffffff로 변경해 다크 모드에서도 이미지가 잘 보이도록 구현했습니다.

최종 완성된 모습은 아래와 같습니다.

다크 모드 완성 gif

후기

  • Next로 내가 만들고 싶은 것을 만들다 보니 배울 내용이 정말 많지만, 오랜만에 재미있다!
  • 모드가 변경될 때 아이콘에 애니메이션을 넣으려면 어떻게 해야 할까? (velog처럼)
  • vue, nuxt, vuex에 비해서 Next, recoil은 확실히 직관적이지 않고 learning-curve가 있다. 이 때문에, react에 회의를 느끼는 사람이 생기는 걸까? (feat.svelte)

참고 자료


(recoil에 대해서 공부한 내용은 따로 정리하기로..)

profile
YOU ARE BREATHTAKING

0개의 댓글