ํ”„๋กœ๊ทธ๋ž˜๋ฐ/Next.js

[Next.js] vanilla-extract๋กœ ๋‹คํฌ๋ชจ๋“œ(dark mode) ๊ตฌํ˜„ํ•˜๊ธฐ (feat.next-themes)

์šฉ๋‡ฝ 2024. 2. 27. 00:08
๋ฐ˜์‘ํ˜•

๐Ÿ“– ๋“ค์–ด๊ฐ€๋ฉฐ

Next.js 14๋ฒ„์ „ app router ํ”„๋กœ์ ํŠธ์˜ styled-comoponents์—์„œ vanilla-extarct๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ง„ํ–‰ํ•จ์— ์žˆ์–ด์„œ ๋‹คํฌ๋ชจ๋“œ ๊ธฐ๋Šฅ๋„ ๊ฐ™์ด ์ง„ํ–‰ํ•˜๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

 

๊ทธ๋ฆฌ๊ณ  ์ด๋ฒˆ ๋‹คํฌ๋ชจ๋“œ๋ฅผ ์ ์šฉํ•˜๋ฉด์„œ next-themes๋ฅผ ํ™œ์šฉํ•ด ๋ดค๋Š” ๋ฐ ์‚ฌ์šฉ ๊ฒฝํ—˜์ด ๋งค์šฐ ์ข‹์•˜์Šต๋‹ˆ๋‹ค.

 

๋˜ํ•œ, app router์—์„œ vanilla-extract๋กœ ๋‹คํฌ๋ชจ๋“œ๋ฅผ ๊ตฌํ˜„ํ•จ์— ์žˆ์–ด์„œ ์ฐธ๊ณ ์ž๋ฃŒ๊ฐ€ ๋งŽ์ง€ ์•Š๋‹ค๋Š” ๊ฒƒ์„ ๊นจ๋‹ฌ์•˜๊ธฐ ๋•Œ๋ฌธ์— ๊ธ€์„ ์ž‘์„ฑํ•˜๋ ค ํ•ฉ๋‹ˆ๋‹ค.

 

๊ทธ๋Ÿผ ์ด์ œ Next.js app router์—์„œ vanilla-extract + next-themes๋ฅผ ํ™œ์šฉํ•œ ๋‹คํฌ๋ชจ๋“œ๋ฅผ ํ•œ๋ฒˆ ๊ตฌํ˜„ํ•ด ๋ด…์‹œ๋‹ค.

๐Ÿง next-themes ์†Œ๊ฐœ

https://github.com/pacocoursey/next-themes

 

GitHub - pacocoursey/next-themes: Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme wi

Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing - pacocoursey/next-themes

github.com

๋จผ์ € ๊ฐ„๋‹จํ•˜๊ฒŒ next-themes๊ฐ€ ๋ฌด์—‡์ธ์ง€ ์†Œ๊ฐœํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

 

next-theme๋Š” ์ œ๋ชฉ์—์„œ ์œ ์ถ”ํ•  ์ˆ˜ ์žˆ๋“ฏ์ด Next.js ํ™˜๊ฒฝ์—์„œ ํ…Œ๋งˆ๋ฅผ ์‰ฝ๊ฒŒ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค๋‹ˆ๋‹ค.

 

๋˜ํ•œ, localStorage๋ฅผ ํ™œ์šฉํ•ด์„œ ํ…Œ๋งˆ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

 

ํ•ด๋‹น Github์—์„œ next-themes ์žฅ์ ์„ ๋ฐœ์ทŒํ•ด ๋ณด์ž๋ฉด ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  • โœ… ๋‹จ ๋‘ ์ค„์˜ ์ฝ”๋“œ๋กœ ์™„๋ฒฝํ•œ ๋‹คํฌ ๋ชจ๋“œ
  • โœ… prefers-color-scheme๋ฅผ ํ†ตํ•œ ์‹œ์Šคํ…œ ํ…Œ๋งˆ ๊ธฐ๋ฐ˜ ์„ค์ •
  • โœ… color-scheme๋ฅผ ์‚ฌ์šฉํ•œ ํ…Œ๋งˆ ๋ธŒ๋ผ์šฐ์ € UI
  • โœ… Next.js 13 ์ง€์› appDir
  • โœ… ๋กœ๋“œ ์‹œ ํ”Œ๋ž˜์‹œ ์—†์Œ(SSR ๋ฐ SSG ๋ชจ๋‘)
  • โœ… ํƒญ๊ณผ ์ฐฝ์—์„œ ํ…Œ๋งˆ ๋™๊ธฐํ™”
  • โœ… ํ…Œ๋งˆ ๋ณ€๊ฒฝ ์‹œ ๊นœ๋ฐ•์ž„ ๋น„ํ™œ์„ฑํ™”
  • โœ… ํŽ˜์ด์ง€๋ฅผ ํŠน์ • ํ…Œ๋งˆ๋กœ ๊ฐ•์ œ ์ ์šฉ
  • โœ… ํด๋ž˜์Šค ๋˜๋Š” ๋ฐ์ดํ„ฐ ์†์„ฑ ์„ ํƒ๊ธฐ
  • โœ… useTheme Hook

์ œ๊ฐ€ ํฌ๊ฒŒ ๋งค๋ ฅ์„ ๋Š๋‚€ ๋ถ€๋ถ„์—๋Š” ๊ฐ•์กฐ๋ฅผ ํ–ˆ์Šต๋‹ˆ๋‹ค.

 

์‹ค์ œ๋กœ next-themes๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์ด์ „์—๋Š” Context API์™€ ์ปค์Šคํ…€ ํ›…์„ ์ƒ์„ฑํ•ด์„œ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ง„ํ–‰ํ–ˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

์ด๋Ÿฌํ•œ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜๋‹ค๊ฐ€ next-themes๋ฅผ ์‚ฌ์šฉํ•ด ๋ณธ ๊ฒฐ๊ณผ๋กœ next.js์—์„œ ๋‹คํฌ๋ชจ๋“œ์˜ ๊นœ๋นก์ž„ ํ˜„์ƒ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ DOM ๋ Œ๋”๋ง ์ด์ „์— script์˜ dangerouslySetInnerHTML๋ฅผ ํ™œ์šฉํ•ด์„œ ๋ฐฉ์ง€ํ•˜๋Š” ๋ฐฉ๋ฒ•์˜ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ์•ˆ์„ ๋งŽ์ด ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

ํ•˜์ง€๋งŒ next-themes๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด์„œ ์ด๋Ÿฌํ•œ ์ถ”๊ฐ€์ ์ธ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— ์‚ฌ์šฉํ•˜์ง€ ์•Š์„ ์ด์œ ๊ฐ€ ์—†๋‹ค๊ณ  ๋Š๊ผˆ์Šต๋‹ˆ๋‹ค.

 

๊ทธ๋Ÿผ ์ด์ œ ์„ค์น˜๋ถ€ํ„ฐ ํ•ด์ฃผ๊ณ  ์‹œ์ž‘ํ•ฉ์‹œ๋‹ค!

$ npm install next-themes
# or
$ yarn add next-themes

โœ”๏ธ ๋ผ์ดํŠธ ๋ชจ๋“œ ๋ฐ ๋‹คํฌ ๋ชจ๋“œ theme ์ƒ์„ฑ

์ ์šฉํ•˜๊ธฐ์— ์•ž์„œ ๋ผ์ดํŠธ ๋ชจ๋“œ์™€ ๋‹คํฌ ๋ชจ๋“œ์— ๋งž๋Š” ์ƒ‰์ƒ์„ ์ •์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

 

vanilla-extract์—์„œ๋Š” createThemeContract์™€ createTheme์„ ํ™œ์šฉํ•ด์„œ ๊ฐ๊ฐ ์ •์˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// globalThemes.css.ts

import {
  globalStyle,
  createThemeContract,
  createGlobalTheme,
  createTheme,
} from '@vanilla-extract/css';

export const global = createGlobalTheme(':root', {
  fontFamily: {
    notoSansKR: `var(--font-noto-sans-kr)`,
  },
  fontSize: {
    xLarge: '48px',
    large: '36px',
    medium: '28px',
    regular: '18px',
    small: '16px',
    micro: '14px',
  },
  fontWeight: {
    normal: '400',
    medium: '500',
    large: '700',
  },
  ...
  color: {
   ...
  },
});

const themeColor = createThemeContract({
  color: {
    mainBackground: null,
    contentBackground: null,
    mainFontColor: null,
    borderColor: null,
    gradient: null,
  },
});

export const lightTheme = createTheme(themeColor, {
  color: {
    mainBackground: '#f7f9fa',
    contentBackground: '#ffffff',
    mainBackground: '0 0% 100%',
    contentBackground: '0 0% 100%',
    mainFontColor: '#2c2c2c',
    borderColor: '#cbc9f9',
    gradient: 'linear-gradient(#39598A, #79D7ED)',
  },
});

export const darkTheme = createTheme(themeColor, {
  color: {
    mainBackground: '#1d1d1d',
    contentBackground: '#2c2c2c',
    mainBackground: '222.2 84% 4.9%',
    contentBackground: '222.2 84% 8.9%',
    mainFontColor: '#ffffff',
    borderColor: '#b1b1b3',
    gradient: 'linear-gradient(#091236, #1E215D)',
  },
});

export const vars = { ...global, themeColor };

...

globalStyle('body', {
  fontSize: global.fontSize.small,
  backgroundColor: `hsl(${vars.themeColor.color.mainBackground})`,
  userSelect: 'none',
  transition: 'all 0.25s linear',
});

 

์œ„ ์ฝ”๋“œ๋ฅผ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

 

๋จผ์ €, ํ…Œ๋งˆ ๊ฐ„์— ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š” ๋ชจ๋“  ํ…Œ๋งˆ ๋ณ€์ˆ˜๋ฅผ ํฌํ•จํ•˜๋Š” global theme์„ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

 

createThemeContract๋ฅผ ํ†ตํ•ด์„œ ๋ชจ๋“œ ๊ฐ„์— ์–ด๋– ํ•œ ์†์„ฑ๋“ค์ด ์‚ฌ์šฉ๋  ๊ฒƒ์ธ์ง€ 'ํ‹€'์„ ์žก์•„์ฃผ์—ˆ์Šต๋‹ˆ๋‹ค.

const themeColor = createThemeContract({
  color: {
    mainBackground: null,
    contentBackground: null,
    mainFontColor: null,
    borderColor: null,
    gradient: null,
  },
});

createThemeContract์˜ value๋“ค์—๊ฒŒ ๊ฐ’์„ ๋„ฃ์ง€ ์•Š์€ ์ด์œ ๋Š” ์†์„ฑ์— ๋งž๋Š” ๊ฐ’๋“ค์„ ์žฌ์‚ฌ์šฉํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

 

์ฆ‰, createThemeContract๋Š” css๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ •์  ํƒ€์ดํ•‘์„ ์œ„ํ•œ contract๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

 

๋˜ํ•œ, ๋ผ์ดํŠธ ๋ชจ๋“œ์™€ ๋‹คํฌ ๋ชจ๋“œ์˜ ํ…Œ๋งˆ๋Š” ๋™์‹œ์— ๋ถˆ๋Ÿฌ์˜ฌ ์ƒํ™ฉ์ด ์—†๊ธฐ ๊ฐ๊ฐ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ์–ด์„œ ์ฝ”๋“œ ์Šคํ”Œ๋ฆฌํŒ… ์ด์ ๋„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

์ดํ›„ createTheme์„ ํ™œ์šฉํ•ด์„œ ์ฒซ ๋ฒˆ์งธ ์ธ์ž๋กœ ์œ„์—์„œ ์ •์˜ํ•œ themColor๋ฅผ ๋„ฃ๊ณ  ๋‘ ๋ฒˆ์งธ ์ธ์ž๋ฅผ ๊ฐ์ฒด์— theme contract์— ๋งž๊ฒŒ ๊ฐ’์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

export const lightTheme = createTheme(themeColor, {
  color: {
    mainBackground: '#f7f9fa',
    contentBackground: '#ffffff',
    mainBackground: '0 0% 100%',
    contentBackground: '0 0% 100%',
    mainFontColor: '#2c2c2c',
    borderColor: '#cbc9f9',
    gradient: 'linear-gradient(#39598A, #79D7ED)',
  },
});

export const darkTheme = createTheme(themeColor, {
  color: {
    mainBackground: '#1d1d1d',
    contentBackground: '#2c2c2c',
    mainBackground: '222.2 84% 4.9%',
    contentBackground: '222.2 84% 8.9%',
    mainFontColor: '#ffffff',
    borderColor: '#b1b1b3',
    gradient: 'linear-gradient(#091236, #1E215D)',
  },
});

 

๊ทธ๋‹ค์Œ, ๊ธ€๋กœ๋ฒŒ ์Šคํƒ€์ผ๊ณผ theme contract์ธ themeColor๋ฅผ ํ•˜๋‚˜์˜ ๊ฐ์ฒด๋กœ ์‚ฌ์šฉ๋  ์ˆ˜ ์žˆ๋„๋ก export ํ•ฉ๋‹ˆ๋‹ค.

export const vars = { ...global, themeColor };

 


๊ผญ body์— ๋Œ€ํ•ด์„œ themeColor์— ๋Œ€ํ•œ ์†์„ฑ์„ ์ง€์ •ํ•ด ์ฃผ์„ธ์š”.
default๋กœ ์„ค์ •๋œ body์˜ theme ์ƒ‰์ƒ์ด ์žˆ์–ด์•ผ ํ•˜๋Š” ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.
์ง€์ •ํ•˜์ง€ ์•Š์œผ๋ฉด next-themes์— ์˜ํ•ด์„œ ๋‹คํฌ๋ชจ๋“œ ์„ค์ • ์‹œ ๋ธŒ๋ผ์šฐ์ € ๊ธฐ๋ณธ ์ƒ‰์ƒ(white)์œผ๋กœ ์ž ์‹œ ๋ณด์˜€๋‹ค๊ฐ€ ์ ์šฉ์ด ๋˜๋Š” ํ˜„์ƒ(๊นœ๋นก์ด๋Š” ํ˜„์ƒ)์„ ๊ฒช์—ˆ์Šต๋‹ˆ๋‹ค.

 

globalStyle('body', {
  ...
  backgroundColor: `hsl(${vars.themeColor.color.mainBackground})`,
  ...
});

 

theme ์ •์˜์— ๋Œ€ํ•œ ๋ถ€๋ถ„์€ ์ด์ œ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค!

โœ”๏ธ next-themes์˜ ThemeProvider ์ •์˜

next-themse๋ฅผ ์ ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” next-themse๊ฐ€ ์ œ๊ณตํ•˜๋Š” ThemeProvider๋ฅผ ์ƒ์„ฑ ํ›„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// context/ThemeProvider.tsx
'use client';

import { darkTheme, lightTheme } from 'src/styles/globalTheme.css';
import { ThemeProvider } from 'next-themes';

const Provider = ({ children }: { children: React.ReactNode }) => {
  return (
    <ThemeProvider
      attribute="class"
      defaultTheme="system"
      value={{
        light: lightTheme,
        dark: darkTheme,
      }}
    >
      {children}
    </ThemeProvider>
  );
};

export default Provider;

ThemeProvider๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ContextAPI๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ๋Š” ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

 

์ฆ‰, 'use client'๋ฅผ ๋ช…์‹œํ•ด ์ค๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์„œ ๋งŽ์€ ๋ถ„๋“ค์ด ์˜๋ฌธ์„ ๊ฐ€์ง€๊ณ  ์ € ๋˜ํ•œ ์˜๋ฌธ์„ ๊ฐ€์ง„ ๋ถ€๋ถ„์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

ThemeProvider๋Š” ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์ธ๋ฐ, ์ด๊ฑธ๋กœ ๊ฐ์‹ธ๊ฒŒ ๋˜๋ฉด ๊ฒฐ๊ตญ ์ž์‹๋“ค์€ ๋ชจ๋‘ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋˜๋Š” ๊ฒƒ์ด 
์•„๋‹Œ๊ฐ€? ์— ๋Œ€ํ•œ ์˜๋ฌธ์ด์—ˆ์Šต๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์˜ props๋กœ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๋กœ ์ „๋‹ฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ๊ทธ๋ ‡์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์•„๋ž˜ ๊ณต์‹๋ฌธ์„œ์˜ ํ•ด๋‹น ์„น์…˜์„ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”

 

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#interleaving-server-and-client-components

 

Rendering: Composition Patterns | Next.js

Recommended patterns for using Server and Client Components.

nextjs.org

 

๋‹ค์‹œ ๋ณธ๋ก ์œผ๋กœ ๋Œ์•„๊ฐ€๊ฒ ์Šต๋‹ˆ๋‹ค.

 

vanilla-extract๋Š” ๊ฐ ํ…Œ๋งˆ์— ๋Œ€ํ•œ className์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ attribute๋ฅผ 'class'๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

 

์ดํ›„ defaultTheme์œผ๋กœ ๊ธฐ๋ณธ์œผ๋กœ ์„ค์ •ํ•  theme ๋ชจ๋“œ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

value๋ฅผ ํ†ตํ•ด ์šฐ๋ฆฌ๊ฐ€ ์ •์˜ํ–ˆ๋˜ ๋ผ์ดํŠธ ๋ชจ๋“œ์™€ ๋‹คํฌ ๋ชจ๋“œ theme์„ ๋ถˆ๋Ÿฌ์™€์„œ ๊ฐ’์— ๋„ฃ์–ด์ค๋‹ˆ๋‹ค.

 <ThemeProvider
      attribute="class"
      defaultTheme="system"
      value={{
        light: lightTheme,
        dark: darkTheme,
      }}
    >

๋งŒ์•ฝ ์‹œ์Šคํ…œ์— ์ •์˜๋œ ์ปฌ๋Ÿฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์‹ถ์ง€ ์•Š๋‹ค๋ฉด ์•„๋ž˜ ์ฝ”๋“œ์ฒ˜๋Ÿผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

enableSystem์˜ ๊ธฐ๋ณธ๊ฐ’์€ true์ž…๋‹ˆ๋‹ค.

 <ThemeProvider
      attribute="class"
      enableSystem={false}
      value={{
        light: lightTheme,
        dark: darkTheme,
      }}
    >

โœ”๏ธ next-themes์˜ ThemeProvider ์‚ฌ์šฉ

// layout.tsx
...
import 'src/styles/reset.css';
import 'src/styles/globalTheme.css';
...
import ThemeProvider from 'src/context/ThemeProvider';
import DarkModeBtn from './_components/Buttons/DarkModeBtn';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <body className={`${notoSansKr.className}`}>
        <ThemeProvider>
          <GoogleAnalytics />
          <div className={style.container}>
            <DarkModeBtn />
            {children}
            <Footer />
          </div>
        </ThemeProvider>
      </body>
      <KakaoScript />
    </html>
  );
}

์ด์ „์— ์ •์˜ํ•œ ThemeProvider๋ฅผ body ์•„๋ž˜์— ๊ฐ์‹ธ์ค๋‹ˆ๋‹ค.

 

body ์œ„์— ๊ฐ์‹ธ๋ฒ„๋ฆฌ๋ฉด ์•„๋ž˜ ์—๋Ÿฌ๋ฅผ ๋งž์ดํ•  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฃผ์˜ํ•˜์„ธ์š”.

Hydration failed because the initial UI does not match what was rendered on the server.

https://react.dev/errors/418?invariant=418

 

Minified React error #418 – React

The library for web and native user interfaces

react.dev

๊ทธ๋ฆฌ๊ณ  html ์†์„ฑ์œผ๋กœ suppressHydrationWarning์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

 <html lang="ko" suppressHydrationWarning>

์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ ์‚ฌ์šฉ ์‹œ ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ Œ๋”๋ง ํ•œ ์ปจํ…์ธ ๊ฐ€ ๋‹ค๋ฅผ ๊ฒฝ์šฐ ๊ฒฝ๊ณ ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

https://react.dev/reference/react-dom/components/common#common-props

์ฆ‰, ์„œ๋ฒ„๋Š” ๋ผ์ดํŠธ ๋ชจ๋“œ์ธ์ง€ ๋‹คํฌ ๋ชจ๋“œ์ธ์ง€ ๋‚ด๊ฐ€ ์„ค์ •ํ•œ ํ…Œ๋งˆ์— ๋Œ€ํ•ด์„œ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

 

์ง€๊ธˆ ๊ฐ™์€ ๊ฒฝ์šฐ์—๋Š” ํ…Œ๋งˆ๋ฅผ ์„ค์ •ํ•˜๋Š” ๋ฐ์— ์žˆ์–ด์„œ ํ”ผํ•  ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— ์ ์ ˆํ•œ ์‚ฌ์šฉ์ž…๋‹ˆ๋‹ค.

 

suppressHydrationWarning๋ฅผ ์‚ฌ์šฉํ•จ์œผ๋กœ์จ ๊ฒฝ๊ณ ๋ฅผ ๋ง‰์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

โœ”๏ธ useTheme ๋ฐ ๋‹คํฌ๋ชจ๋“œ ๋ฒ„ํŠผ ์ ์šฉ

// DarkModBtn.tsx
'use client';

import SunnyIcon from 'public/images/sunny.svg';
import MoonIcon from 'public/images/moon.svg';
import * as style from './darkModeBtn.css';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

const DarkModeBtn = () => {
  const [mounted, setMounted] = useState(false);
  const { resolvedTheme, setTheme } = useTheme();

  useEffect(() => setMounted(true), []);

  if (!mounted) return <div className={style.container} />;

  return (
    <div className={style.container}>
      <button
        className={style.button}
        onClick={() => setTheme(resolvedTheme === 'light' ? 'dark' : 'light')}
        aria-label="DarkModeBtn"
      >
        <SunnyIcon
          className={style.svg}
          style={assignInlineVars({
            [style.lightMode]: resolvedTheme === 'light' ? '0' : '100px',
          })}
        />
        <MoonIcon
          className={style.svg}
          style={assignInlineVars({
            [style.darkMode]: resolvedTheme === 'light' ? '-100px' : '0',
          })}
        />
      </button>
    </div>
  );
};

export default DarkModeBtn;

์ฝ”๋“œ์—์„œ mounted๊ฐ€ true๋ฉด ๋ฒ„ํŠผ์„ ์ •์ƒ์ ์œผ๋กœ ๋ Œ๋”๋ง ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

์ด ๋ถ€๋ถ„ ๋˜ํ•œ useTheme์€ localStorage๋ฅผ ํ†ตํ•ด ํ˜„์žฌ ํ…Œ๋งˆ๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ ๋•Œ๋ฌธ์—  Hydration Missmatch๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด ํด๋ผ์ด์–ธํŠธ์— ๋งˆ์šดํŠธ ๋˜์—ˆ์„ ๋•Œ useTheme์œผ๋กœ ํ˜„์žฌ theme์„ ์ •์ƒ์ ์œผ๋กœ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

 

๊ทธ๋ž˜์„œ ๋งˆ์šดํŠธ๊ฐ€ ๋˜์ง€ ์•Š์•˜์„ ๋•Œ๋Š” ๊ฐ™์€ ํฌ๊ธฐ์ธ ๋นˆ div๋ฅผ ๋ณด์ผ ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

if (!mounted) return <div className={style.container} />;

๊ฐ™์€ ํฌ๊ธฐ์˜ ๋นˆ div๋ฅผ ๋„ฃ์€ ์ด์œ ๋Š” Layout Shift ํ˜„์ƒ์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด์„œ์ž…๋‹ˆ๋‹ค.
Layout Shift๋Š” reflow๊ฐ€ ๋ฐœ์ƒํ•˜๊ณ  ์ฃผ๋ณ€ ์ฝ˜ํ…์ธ ๊ฐ€ ๋ฐ€๋ฆฌ๋Š” ํ˜„์ƒ์ด ๋ฐœ์ƒํ•˜๊ธฐ ๋•Œ๋ฌธ์— UX(์‚ฌ์šฉ์ž ๊ฒฝํ—˜) ์ธก๋ฉด์—์„œ ์ข‹์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์ดํ›„ ์‹ค์งˆ์ ์ธ button์— ๋Œ€ํ•ด์„œ useTheme ํ›…์˜ resovedTheme๊ณผ setTheme์„ ์ด์šฉํ•ด์„œ ํ…Œ๋งˆ๋ฅผ ์ „ํ™˜ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

...
import { useTheme } from 'next-themes';
...
const { resolvedTheme, setTheme } = useTheme();
...
<div className={style.container}>
      <button
        className={style.button}
        onClick={() => setTheme(resolvedTheme === 'light' ? 'dark' : 'light')}
        aria-label="DarkModeBtn"
      >
        <SunnyIcon
          className={style.svg}
          style={assignInlineVars({
            [style.lightMode]: resolvedTheme === 'light' ? '0' : '100px',
          })}
        />
        <MoonIcon
          className={style.svg}
          style={assignInlineVars({
            [style.darkMode]: resolvedTheme === 'light' ? '-100px' : '0',
          })}
        />
      </button>
</div>

 

reslovedTheme์€ enableSystem์ด true์ด๊ณ  ํ™œ์„ฑ ํ…Œ๋งˆ๊ฐ€ "system"์ธ ๊ฒฝ์šฐ ์‹œ์Šคํ…œ ๊ธฐ๋ณธ ์„ค์ •์ด "dark" ๋˜๋Š” "light"๋กœ ํ™•์ธ๋˜์—ˆ๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ๊ทธ๋ƒฅ ํ˜„์žฌ ํ…Œ๋งˆ ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

 

setTheme์€ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ theme ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ์—ญํ• ์ž…๋‹ˆ๋‹ค.

 

๋งค์šฐ ์ง๊ด€์ ์ด์ง€ ์•Š๋‚˜์š”?

 

๊ทธ๋Ÿผ ์ด ๋ฒ„ํŠผ์„ ๊ฐ€์ง€๊ณ  ์ด์ œ ๋ผ์ดํŠธ ๋ชจ๋“œ์™€ ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์ „ํ™˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค!

โœ”๏ธ body ์™ธ์— ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ์— theme ๋ชจ๋“œ์— ๋”ฐ๋ฅธ ์ƒ‰์ƒ ์ ์šฉ

์ด์ „์— body๋Š” globalStyle๋กœ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

์ถ”๊ฐ€์ ์œผ๋กœ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ์—์„œ theme์— ๋”ฐ๋ฅธ ์ƒ‰์ƒ์„ ๋ณด์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

import { style } from '@vanilla-extract/css';
import { vars } from 'src/styles/globalTheme.css';

export const container = style({
  ...
  color: vars.themeColor.color.mainFontColor,
  ...
});

์ด์ „์— export ํ•œ vars์—์„œ theme contract๋ฅผ ํ†ตํ•ด์„œ theme์„ ์ •์˜ํ•œ css ๋ณ€์ˆ˜๋ฅผ ๊ฐ€์ ธ์™€ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

โœ… ๊ฒฐ๊ณผ

๊ทธ๋Ÿผ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์–ด๋–ป๊ฒŒ ์ ์šฉ๋˜๋Š”์ง€ ํ™•์ธํ•ด ๋ณผ๊นŒ์š”?

๊ฒฐ๊ณผ ํ™”๋ฉด

์šฐ๋ฆฌ๊ฐ€ ์ •์˜ํ•œ ThemeProvider์— attribute๋ฅผ 'class'๋กœ ์‚ฌ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— class์— ๋”ฐ๋ฅธ className์ด ๊ฐ ํ…Œ๋งˆ์˜ ์ƒํƒœ์— ๋งž๊ฒŒ ๋ณ€๊ฒฝ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

 

๋˜ํ•œ, ์šฐ์ธก์— ๋ผ์ดํŠธ ๋ชจ๋“œ์™€ ๋‹คํฌ ๋ชจ๋“œ์— ๋”ฐ๋ฅธ CSS๊ฐ€ ๊ฐ๊ฐ ์‚ฌ์šฉ๋˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๊ฑด ์šฐ๋ฆฌ๊ฐ€ ThemeProvider์—์„œ value๋กœ lightTheme๊ณผ darkTheme์„ ๋„ฃ์–ด์คฌ๊ธฐ ๋•Œ๋ฌธ์— ๊ทธ๊ฒƒ์„ ๊ฐ€์ง€๊ณ  ๋ณด์—ฌ์ฃผ๊ฒŒ ๋˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.


๊ฐœ๋ฐœ ๋ชจ๋“œ์—์„œ๋Š” ๋‹คํฌ๋ชจ๋“œ ์„ค์ • ํ›„ ์ƒˆ๋กœ ๊ณ ์นจ ์‹œ ์ž ์‹œ ๊นœ๋นก์ด๋Š” ํ˜„์ƒ์ด ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
build ํ›„ production ํ™˜๊ฒฝ์—์„œ ํ™•์ธํ•˜๋ฉด ๊นœ๋นก์ด๋Š” ํ˜„์ƒ์ด ์—†๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿง ๋ถ€๋ก: next-themse ์ฝ”๋“œ ๋“ค์—ฌ๋‹ค๋ณด๊ธฐ

localStorage๋ฅผ ํ†ตํ•ด์„œ ํ…Œ๋งˆ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š”๋ฐ ์ƒˆ๋กœ๊ณ ์นจ ์‹œ ๊นœ๋นก์ž„๋„ ์—†๊ณ , ์‹œ์Šคํ…œ ํ…Œ๋งˆ๋ฅผ ํ™•์ธํ•ด์„œ ๋ณด์—ฌ์ฃผ๊ณ  ๋“ฑ๋“ฑ..

์ด๋Ÿฐ ๊ฒƒ๋“ค์ด ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„๋˜์–ด ์žˆ๊ธธ๋ž˜ ๊ฐ€๋Šฅํ•œ ๊ฑด์ง€ ๊ถ๊ธˆํ•œ ๋งˆ์Œ์— ์ฝ”๋“œ๋ฅผ ์ฐพ์•„๋ดค์Šต๋‹ˆ๋‹ค ๐Ÿ˜‚

 

https://github.com/pacocoursey/next-themes/blob/main/packages/next-themes/src/index.tsx

 

ํ•ต์‹ฌ์ ์ธ ๋ถ€๋ถ„์œผ๋กœ ์ƒ๊ฐํ•˜๋Š” ๋ช‡ ๊ตฐ๋ฐ ํ•ด์„ํ•ด๋ณด๋ ค ํ•ฉ๋‹ˆ๋‹ค.

 

scriptSrc ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ์–ด๋–ป๊ฒŒ ๊นœ๋นก์ž„ ์—†์ด ์ดˆ๊ธฐ ์ƒํƒœ๋ฅผ ๋ณด์—ฌ์ฃผ๋„๋ก ํ•˜๊ณ  ์žˆ๋Š”์ง€ ์œ ์ถ”ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

๊ฐ„๋‹จํ•˜๊ฒŒ ์ฝ”๋“œ๋ฅผ ํ•ด์„ํ•ด ๋ณด์ž๋ฉด, ๋กœ์ปฌ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ๋œ theme์ด ์žˆ์œผ๋ฉด ํ•ด๋‹น theme์„, ์—†์œผ๋ฉด prefers-color-scheme์„ ํ†ตํ•ด theme์„ updateDoM์„ ํ†ตํ•˜๋Š” ํ•จ์ˆ˜๋กœ, script์˜ dangerousySetInnerHTML์„ ํ†ตํ•ด์„œ ์ฃผ์ž…ํ•˜๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

๊ทธ๋ฆฌ๊ณ  ์œ„ ์ฝ”๋“œ๋Š” ํ…Œ๋งˆ๋ฅผ ์ ์šฉํ•˜๋Š” ํ•จ์ˆ˜์ธ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

document.documentElement ์ฆ‰ html์— ํด๋ž˜์Šค๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  ์žˆ๋„ค์š”.

 

์•„๋ž˜ ์‚ฌ์ง„์„ ๋ณด์‹œ๋ฉด document.documentElement์€ html์ž…๋‹ˆ๋‹ค.

 

์ด์ „์— ์œ„ ๊ฒฐ๊ณผ ํ™”๋ฉด์—์„œ html์—์„œ ๋ณ€๊ฒฝ์ด ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ“• ๋งˆ๋ฌด๋ฆฌ

Next.js app router์—์„œ vanilla-extract์™€ next-themes๋ฅผ ํ†ตํ•œ ๋‹คํฌ๋ชจ๋“œ๋ฅผ ์ ์šฉํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

vanilla-extract์˜ ๊ฐ•๋ ฅํ•œ theme ์ง€์›๊ณผ next-themese๋กœ ์ธํ•œ ์•„์ฃผ ์งง์€ ์ฝ”๋“œ๋กœ ์„ฑ๊ณต์ ์œผ๋กœ ์ ์šฉํ•  ์ˆ˜ ์žˆ์—ˆ๋„ค์š”.

 

๋งค์šฐ ์ข‹์€ ๊ฒฝํ—˜์ด์—ˆ์Šต๋‹ˆ๋‹ค :)

 

๊ตฌํ˜„ ์‹œ ์ฐธ๊ณ ํ–ˆ๋˜ ํฌ์ŠคํŠธ๋“ค:

https://www.youtube.com/watch?v=7zqI4qMDdg8

https://www.youtube.com/watch?v=RTAJ-enfums

https://www.joo-dev.com/post/detail/NEXTJS-Vanilla-Extract-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0--Dark-mode-%EA%B5%AC%ED%98%84-NextJS-%EB%B8%94%EB%A1%9C%EA%B7%B8

https://samuelkraft.com/blog/vanilla-extract-with-next-themes

๋ฐ˜์‘ํ˜•