Front-End/Next

[Next.js] 정적 사이트에 다크 모드 구현하기

Voyage_dev 2022. 11. 6. 18:23

굳이 다크모드를??

대부분의 웹사이트는 다크모드를 지원하지는 않지만 요즘 들어 많은 사이트들이 다크모드를 지원하기 시작했다. 굳이 어두운 테마를 추가하는 이유가 뭘까?

 

우리가 어두운 곳에서 핸드폰을 볼 때 핸드폰 밝기를 낮추면서 보는 이유는 우리의 눈이 예민하기 때문이다. 계속해서 밝은 빛을 어두운 환경에서 비추게 되면 눈이 쉽게 피로해지며 눈 건강에도 좋지 않다. 또한, 나부터도 어두운 테마를 조금 더 선호하기 때문에 웹 접근성을 높이기 위해서 추가적인 리소스를 넣으면서까지 다크 모드를 설정할 수 있도록 구현해야 한다.

이 글은 Next.js에서 다크모드를 적용한 방법에 대해 정리할려고 한다.
Next.js + Typescript + Styled-Components를 사용하여 어떻게 구현했는지 보자.

_theme.ts 색상 지정

const color = {
  // bg
  bg_page1: "#F8F9FA",
  bg_page2: "#FFFFFF",
  bg_element: "#FFFFFF",
  bg_element2: "#F8F9FA",
  bg_element3: "#E9ECEF",
  bg_element4: "#DEE2E6",
  dark_bg_page1: "#121212",
  dark_bg_element1: "#1E1E1E",
  dark_bg_element2: "#252525",
  dark_bg_element3: "#2E2E2E",

  // text
  text1: "#212529",
  text2: "#495057",
  text3: "#868E96",
  text4: "#CED4DA",
  dark_text1: "#ECECEC",
  dark_text2: "#D9D9D9",
  dark_text3: "#ACACAC",
  dark_text4: "#595959",

  // border
  border1: "#343A40",
  border2: "#ADB5BD",
  border3: "#DEE2E6",
  border4: "#F1F3F5",
  dark_border1: "#E0E0E0",
  dark_border2: "#A0A0A0",
  dark_border3: "#4D4D4D",
  dark_border4: "#2A2A2A",
};

export const styles = {
  lightTheme: {
    BACKGROUND: color.bg_page1,
    PRIMARY_FONT: color.text1,
    FEED_BACKGROUND: color.bg_element3,
    FEED_TITLE: color.text1,
    FEED_CONTENT: color.text2,
    FEED_FOOTER: color.text3,
    PAGINATE_NUM_TEXT: color.text3,
    BLOG_LIST: color.dark_bg_element1,
    BLOG_LIST_FONT: color.text4,
  },

  darkTheme: {
    BACKGROUND: color.dark_bg_element1,
    PRIMARY_FONT: color.dark_text1,
    FEED_BACKGROUND: color.dark_bg_element3,
    FEED_TITLE: color.dark_text1,
    FEED_CONTENT: color.dark_text2,
    FEED_FOOTER: color.dark_text3,
    PAGINATE_NUM_TEXT: color.dark_text3,
    BLOG_LIST: color.bg_page1,
    BLOG_LIST_FONT: color.dark_text4,
  },
};
export type MainTheme = typeof styles.lightTheme;
// _app.tsx

import {
  type DehydratedState,
  Hydrate,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import type { AppProps } from "next/app";
import { ThemeProvider } from "styled-components";
import { createContext } from "react";
import { MainTheme, mixins, styles } from "@styles/_theme";
import GlobalStyles from "@styles/_GlobalStyles";

function MyApp({
  Component,
  pageProps,
}: AppProps<{ dehydratedState: DehydratedState }>) {

  return (
    <>
      <Head>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Rss Feed</title>
      </Head>
        <ThemeProvider theme={{ ...mixins, ...colorTheme }}>
          <QueryClientProvider client={queryClient}>
            <Hydrate state={pageProps.dehydratedState}>
              <GlobalStyles />
              <Layout>
                <Component {...pageProps} />
              </Layout>
            </Hydrate>
          </QueryClientProvider>
        </ThemeProvider>
    </>
  );
}

export default MyApp;
  • const color 라는 객체에다 라이트 모드와 다크 모드에 들어갈 색상을 정리한다. (더 필요시 추가하면 된다)
  • export const styles 객체에서는 스타일이 라이트일때와 다크일때 속성을 다르게 주려고 만들고 typeof를 통해 MainTheme type을 export 해준다.
  • ThemeProvider로 감싸서 theme에다가 믹스인 처럼 활용할 수 있게 만든다.

useDarkMode()

다크모드를 설정하기 위해서는 localStorage와 window 객체가 필요하지만. Next.js에서는 React처럼 사용한다면 ReferenceError: window is not defined 라는 에러가 난다. Next.js는 기본적으로 SSG를 지원하기 때문에 렌더링이 일어나기전에 브라우저 환경에서 사용할 수 있는 localStorage, window 객체 등에 접근하면 사용 불가능하다.

 

그렇기 때문에 브라우저 환경에서 해당 코드가 실행되게 하기 위해 useEffect() 를 활요해서 마운트 되었을때 window를 사용해 주어야 한다.

// hooks/utils/useDarkMode.ts

import { useEffect, useState } from "react";
import { MainTheme, styles } from "@styles/_theme";

export const useDarkMode = () => {
  const [colorTheme, setTheme] = useState<MainTheme | null>(null);

  const setMode = (mode: MainTheme) => {
    if (mode === styles.lightTheme) {
      document.body.dataset.theme = "light";
      window.localStorage.setItem("theme", "light");
    } else {
      document.body.dataset.theme = "dark";
      window.localStorage.setItem("theme", "dark");
    }
    setTheme(mode);
  };

  const toggleTheme = () => {
    colorTheme === styles.lightTheme
      ? setMode(styles.darkTheme)
      : setMode(styles.lightTheme);
  };

  useEffect(() => {
    const localTheme = window.localStorage.getItem("theme");

    // 이용자가 다크모드를 선호하면 다크 모드로 보여주는 로직
    // 위에 localTheme을 받아서 !localTheme 처리 이유는
    // 처음에는 로컬에 저장된게 없으니 false로 나오기 때문에 true로 바꿔주고
    // 둘다 true일 때만 처리해주기
    window.matchMedia("(prefers-color-scheme:dark)").matches && !localTheme
      ? setMode(styles.darkTheme)
      : localTheme === "dark"
      ? setMode(styles.darkTheme)
      : setMode(styles.lightTheme);
  }, []);
  return { colorTheme, toggleTheme };
};
  • 초기 colorTheme은 초기값으로 null을 가진다. 즉, 이 값이 정해지지 않았을 경우 시스템 테마를 사용한다.
  • 마운트가 되고나서 useEffect()가 실행된다.
  • toggleTheme이라는 함수를 만들어서 토글 버튼이 눌렸을때 테마를 변경햐 주면서 사용자가 지정한 테마는 localStorage를 통해 브라우저에 저장되기 때문에 새로고침이나 페이지 이동했을때도 테마를 유지한다.

사용자 테마 감지

평소에 다크 테마를 사용하는 사용자들을 위해 첫 방문시부터 다크 모드를 적용해서 보여준다면 더 나은 사용자 경험을 제공할 수 있다.

window.matchMedia("(prefers-color-scheme:dark)") 로 사용자의 시스템 테마를 확인할 수 있다.

다크모드 상태관리

페이지마다 props로 넘겨주는 방식은 비효율적이다 보니 Context API를 사용 ThemeContext.provider를 통해서 하위 컴포넌트들이 테마를 접근할 수 있도록 한다.

// _app.tsx

export interface ContextProps {
  colorTheme: MainTheme | null;
  toggleTheme: () => void;
}

export const ThemeContext = createContext<ContextProps>({
  colorTheme: styles.lightTheme,
  toggleTheme: () => {
    return null;
  },
});

const queryClient = new QueryClient();

function MyApp({
  Component,
  pageProps,
}: AppProps<{ dehydratedState: DehydratedState }>) {
  const { colorTheme, toggleTheme } = useDarkMode();

  return (
    <>
      <ThemeContext.Provider value={{ colorTheme, toggleTheme }}>
        <ThemeProvider theme={{ ...mixins, ...colorTheme }}>
          <QueryClientProvider client={queryClient}>
            <Hydrate state={pageProps.dehydratedState}>
              <GlobalStyles />
              <Layout>
                <Component {...pageProps} />
              </Layout>
            </Hydrate>
          </QueryClientProvider>
        </ThemeProvider>
      </ThemeContext.Provider>
    </>
  );
  • Context 초기 값으로 lightTheme을 넣어준다.
  • useDarkMode 훅을 통해 colorTheme과 toggleTheme을 리턴 받는다.
  • ThemeContext.Provider로 감싸서 context의 변화를 알리는 역할을 한다. 즉, toggleTheme을 통해 theme이 변경되면 하위 컴포넌트들은 모두 리렌더링인 된다.

다크모드 토글 버튼

// Gnb/index.tsx

const Gnb = () => {
  const { colorTheme, toggleTheme } = useContext(ThemeContext);

  return (
			// 생략
          <FontAwesomeIcon
            className="icon"
            icon={faMoon}
            spin
            size="xl"
            width={20}
            color="black"
            onClick={toggleTheme}
          />
        
  );
};

export default Gnb;
  • useContext로 ThemeContext를 가져와 토글 버튼을 누르면 toggleTheme이 작동되면서 테마가 바뀐다.

다크 모드 새로고침 시 깜빡이는 문제

다크 모드 개발은 마쳤지만 다크 모드에서 새로고침 시 흰 화면이 보였다가 어두운 화면으로 바뀌어서 깜빡거리는 현상이 생겼다.

 

why?

Next.js는 SSR 방식을 사용하기 때문에 서버단에서는 다크 모드로 보여줘야 할지 말지를 알 수 없다. 그렇기 때문에 시스템 테마가 다크 모드로 설정되어 있는 사용자는 처음 배경인 흰 화면을 보여줬다가 JS가 로딩된 후 배경이 다시 검정색으로 보여주는 현상을 겪게된다.

 

이러한 문제는 _document.tsx에서 dangerouslySetInnerHTML 을 사용해 서버단에 data-theme을 넣어주는 작업으로 해결할 수 있다.

// pages/_document.tsx

import Document, {
  Html,
  Head,
  Main,
  NextScript,
  DocumentContext,
  DocumentInitialProps,
} from "next/document";
import { ServerStyleSheet } from "styled-components";

// style 늦게 호출되는 현상 방지용
class MyDocument extends Document {
  static async getInitialProps(
    ctx: DocumentContext
  ): Promise<DocumentInitialProps> {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: App => props => sheet.collectStyles(<App {...props} />),
        });

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: (
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>
        ),
      };
    } finally {
      sheet.seal();
    }
  }

  render(): JSX.Element {
    return (
      <Html>
        <Head />
        <body>
          <script
            dangerouslySetInnerHTML={{
              __html: `
            const theme = localStorage.getItem("theme");
            const getUserTheme = () => {
             if(theme){
              return theme
             } 
             return window.matchMedia('(prefers-color-scheme: dark)').matches
             ? 'dark'
             : 'light'
          }
          document.body.dataset.theme = getUserTheme();

          `,
            }}
          />

          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

구현 화면