[Next.js] vanilla-extract๋ก ๋คํฌ๋ชจ๋(dark mode) ๊ตฌํํ๊ธฐ (feat.next-themes)
๐ ๋ค์ด๊ฐ๋ฉฐ
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
๋จผ์ ๊ฐ๋จํ๊ฒ 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๋ก ์๋ฒ ์ปดํฌ๋ํธ๋ก ์ ๋ฌํ๋ ๊ฒฝ์ฐ์๋ ๊ทธ๋ ์ง ์์ต๋๋ค.
์๋ ๊ณต์๋ฌธ์์ ํด๋น ์น์ ์ ์ฐธ๊ณ ํด ์ฃผ์ธ์
๋ค์ ๋ณธ๋ก ์ผ๋ก ๋์๊ฐ๊ฒ ์ต๋๋ค.
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
๊ทธ๋ฆฌ๊ณ html ์์ฑ์ผ๋ก suppressHydrationWarning์ ์ถ๊ฐํฉ๋๋ค.
<html lang="ko" suppressHydrationWarning>
์๋ฒ ์ปดํฌ๋ํธ ์ฌ์ฉ ์ ์๋ฒ์ ํด๋ผ์ด์ธํธ์์ ๋ ๋๋ง ํ ์ปจํ ์ธ ๊ฐ ๋ค๋ฅผ ๊ฒฝ์ฐ ๊ฒฝ๊ณ ๊ฐ ๋ฐ์ํฉ๋๋ค.
์ฆ, ์๋ฒ๋ ๋ผ์ดํธ ๋ชจ๋์ธ์ง ๋คํฌ ๋ชจ๋์ธ์ง ๋ด๊ฐ ์ค์ ํ ํ ๋ง์ ๋ํด์ ์ ์ ์์ต๋๋ค.
์ง๊ธ ๊ฐ์ ๊ฒฝ์ฐ์๋ ํ ๋ง๋ฅผ ์ค์ ํ๋ ๋ฐ์ ์์ด์ ํผํ ์ ์๊ธฐ ๋๋ฌธ์ ์ ์ ํ ์ฌ์ฉ์ ๋๋ค.
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://samuelkraft.com/blog/vanilla-extract-with-next-themes