๐ฉ๐ป๐ป ์ด๋์ด ๋ฐฐ๊ฒฝ์ ๋ฐ์ ๊ธ์จ๋ ๋คํฌ ๋ชจ๋, ๋ฐ์ ๋ฐฐ๊ฒฝ์ ์ด๋์ด ๊ธ์จ๋ ๊ธฐ๋ณธ UI (๋ผ์ดํธ ๋ชจ๋)๋ก ์ค์ ํ ์ ์๋ค.
๐ ๋คํฌ ๋ชจ๋๋ฅผ ์ฌ์ฉํ๋ฉด ๋์ ํผ๋ก๋๋ฅผ ๋ฎ์ถ ์ ์๋ค๋ ์ ์์ ์ข ์ข ์ง์ํ๋ ๊ธฐ๋ฅ์ด๋ค.
๐ ๋ฐ์ ์กฐ๋ช ์๋์์ ๋คํฌ๋ชจ๋๋ ๊ฐ๋ ์ฑ์ด ๋จ์ด์ง ์ ์๊ธฐ ๋๋ฌธ์ ๋ผ์ดํธ ๋ชจ๋๋ ์ง์ํด ์ฃผ๋ ๊ฒ์ด ์ข๋ค.
๐ ๋คํฌ/๋ผ์ดํธ ๋ชจ๋๋ฅผ ์ ํํ ์ ์๋๋ก ํ๊ฑฐ๋ os์ ๊ธฐ๋ณธ ์ค์ ์ ๋ฐ๋ผ๊ฐ ์ ์๋ค.
๐ค ๊ทผ๋ฐ ์ด๋ ๊ฒ ์ ์ ๊ฐ ์ ํํ ๋ชจ๋๋ os์ ์ค์ ์ค ์ด๋ค๊ฒ ๋ ์ฐ์ ์์๊ฐ ๋์์ผ ํ๋?
์ค๋์ MDN Web Docs์์ ํํธ๋ฅผ ์ป์ด ๋คํฌ/๋ผ์ดํธ ๋ชจ๋์ ์ฐ์ ์์๋ฅผ ๋ถ์ฌํ๋ ๋ฐฉ์์ ๋ํด ์ดํด๋ณด์.
๊ตฌํ
dark/light ๋ชจ๋๋ฅผ ๊ตฌํํ๊ธฐ ์ํด ์ค๋น๋ฌผ๋ค์ ๋จผ์ ์ดํด๋ณด์
โ useAppTheme.ts ์ปค์คํ ํ : ํ์ฌ ๋ชจ๋๋ฅผ ์ ์ดํ ์ ์๋ ์ปค์คํ ํ
โ provider/ThemeProvider.tsx : ํ ๋ง ๋ชจ๋๋ฅผ ์ ์ฒด์ ๊ฐ์ธ์ค ์ปดํฌ๋ํธ
โ provider/layout.tsx : ๋ชจ๋ Provider๋ฅผ ํ ๊ณณ์ ๋ชจ์ ์ปดํฌ๋ํธ
โ (user ์ ํ) ๋ฒํผ : light/ dark ๋ชจ๋๋ฅผ ๋ณ๊ฒฝํ ์ ์๋ ๋ฒํผ ์ปดํฌ๋ํธ, useAppTheme ํ ์ ํ์ฉํด์ ๋ชจ๋๋ฅผ ํ ๊ธ ํ ์ ์๋ค.
โ (user/os ์ ํ) ๋ฒํผ : user ํน์ os์ ์ฐ์ ์์๋ฅผ ๋ถ์ฌํ ์ง ์ ํํ ๋ฒํผ ์ปดํฌ๋ํธ, os ์ ํ ์ os ๋ชจ๋ ๋ฐ๋ผ๊ฐ๊ณ user ํด๋ฆญ ์ ํ ์ ์ ์ ์ ์ ํ์ด ์ฐ์ ๋๋ค.
โ ๋คํฌ/๋ผ์ดํธ ๋ชจ๋ ์ค์ ํ ์ปดํฌ๋ํธ
!! ์์ธํ scss ์ฝ๋๋ ์๋ตํ์ต๋๋ค!!
- @tripie/design-system/src/components/_body.tsx : ํฐํธ์ ๋ชจ๋๋ฅผ ์ฑ ์ ์ฒด์ ์ ์ฉํ ์ปดํฌ๋ํธ
"use client";
import { useAppTheme } from "@tripie/hooks";
import classNames from "classnames";
import localFont from "next/font/local";
import { HTMLAttributes, ReactNode } from "react";
import "./_body.scss";
export interface BodyProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
}
const maruBuri = localFont({
src: [
{
path: "../../static/fonts/MaruBuri-Regular.woff",
style: "normal",
},
],
});
const Body = ({ children, className, ...props }: BodyProps) => {
const { mode } = useAppTheme();
return (
<section
className={classNames(className, mode, maruBuri.className)}
{...props}
>
<div className="background-container">
<div className="stars"></div>
<div className="twinkling"></div>
</div>
<div className="layout-wrap">{children}</div>
</section>
);
};
export default Body;โ
- @tripie/design-system/src/components/_body.scss : ํฐํธ์ ๋ชจ๋๋ฅผ ์ฑ ์ ์ฒด์ ์ ์ฉํ ์ปดํฌ๋ํธ
@use "../../base/" as *;
@use "../../functions/" as *;
@use "../../generator/" as *;
@use "../../mixins/" as *;
@use "./night-sky" as *;
@use "sass:map";
@include generate-styles;
.dark {
color: color(default, 800);
@include night-sky;
}
- @tripie/design-system/src/components/_night-sky.scss : ํฐํธ์ ๋ชจ๋๋ฅผ ์ฑ ์ ์ฒด์ ์ ์ฉํ ์ปดํฌ๋ํธ
@use "../../mixins/" as *;
@use "../../base/" as *;
@use "sass:map";
@mixin night-sky {
.background-container {
overflow-x: hidden;
@include position(fixed);
}
.stars {
background: rgb(0, 0, 0) url("../../static/images/stars.png") repeat;
@include position(absolute);
@include z-index(base);
}
@keyframes move-background {
from {
-webkit-transform: translate3d(0px, 0px, 0px);
}
to {
-webkit-transform: translate3d(1000px, 0px, 0px);
}
}
.twinkling {
background: transparent url("../../static/images/twinkling.png") repeat;
@include position(absolute);
@include z-index(mask);
animation: move-background 70s linear infinite;
}
img {
@include position(absolute);
@include z-index(fixed);
}
.layout-wrap {
@include position(absolute);
@include z-index(masked);
}
}
- @tripie/design-system/src/components/_body.scss : ํฐํธ์ ๋ชจ๋๋ฅผ ์ฑ ์ ์ฒด์ ์ ์ฉํ ์ปดํฌ๋ํธ
@use "../base/" as *;
@use "../functions/" as *;
@use "../mixins/" as *;
/// ๋ชจ๋ ์คํ์ผ ์์ฑ
@mixin generate-styles {
@include generate-reset;
@include generate-normalize;
@include generate-color-variables;
@each $key, $value in $config-theme {
@include generate-color-variables(
$colors: $value,
$selector: "[data-theme=#{$key}]:root" // dark ๋ชจ๋์ผ ๊ฒฝ์ฐ [data-theme="dark"]:root ์์ฑ
);
}
@include generate-default;
@include generate-media;
}
๋ชจ๋ ์ ์ด๊ถ์ ์ค ๋์์ ๋ฐ๋ผ ๊ตฌํ์ด ์กฐ๊ธ์ฉ ๋ฌ๋ผ์ง๋ค.
1. os ๋ชจ๋ ์ ์ด๊ถ ๋ถ์ฌ
import { useEffect, useState } from "react";
import { useLocalStorage, useMediaQuery } from "usehooks-ts";
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
const THEME_MODE = {
DARK: "dark",
LIGHT: "light",
} as const;
type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
type UseAppThemeOutput = {
mode: ThemeMode;
};
export const useAppTheme = (): UseAppThemeOutput => {
const isDarkOS = useMediaQuery(COLOR_SCHEME_QUERY);
const [osPrefersMode] = useState(
isDarkOS ? THEME_MODE.DARK : THEME_MODE.LIGHT
);
const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>(
"app-theme",
osPrefersMode ?? THEME_MODE.LIGHT
);
useEffect(() => {
const root = document?.documentElement;
setThemeMode(isDarkOS ? THEME_MODE.DARK : THEME_MODE.LIGHT);
if (root) {
root.dataset.theme = themeMode;
}
}, [themeMode, isDarkOS]);
return {
mode: themeMode,
};
};โ
os setting์์ mode๋ฅผ ๋ณ๊ฒฝํ๋ฉด ์ ํ๋ฆฌ์ผ์ด์ ๋ ๋๊ฐ์ด ๋ฐ์๋๋ค.
๋คํฌ ๋ชจ๋์ผ ๊ฒฝ์ฐ html์ data-theme= "dark"์ด๊ณ , root ์์๋ค๋ ๋คํฌ theme์ ๋ฐ๋ผ๊ฐ๋ค.
๋ผ์ดํธ ๋ชจ๋์ผ ๊ฒฝ์ฐ html์ data-theme= "light"์ด๊ณ , root ์์๋ค๋ ๋ํดํธ(๋ผ์ดํธ) theme์ ๋ฐ๋ผ๊ฐ๋ค.
๐ฉ๐ป๐ป ์ด ๋ฐฉ์์ os์ ๋ฐ๋ผ ์๋์ผ๋ก ๋ณ๊ฒฝ์ด ๋๋ฏ๋ก ๊ฐ๋จํ๊ฒ ๊ตฌํํ ์ ์๋ค.
ํ์ง๋ง ์ ์ ๊ฐ ๋ง์ฝ ๋ชจ๋๋ฅผ ๋ฒ๊ฒฝํ๊ณ ์ํ๋ฉด ์ ํ๋ฆฌ์ผ์ด์ ํ๋ฉด์์ ๋ฒ์ด๋ ์ค์ ์ ์ง์ ๋ค์ด๊ฐ์ ๋ณ๊ฒฝ์ ํด์ผ ํ๋ค๋ ์ ์์ ๋ฒ๊ฑฐ๋กญ๋ค. ๋ฟ๋ง ์๋๋ผ ํ์ฌ ์ ํ๋ฆฌ์ผ์ด์ ๋ง ํน์ ๋ชจ๋๋ก ํ๊ณ ๋ค๋ฅธ ์ ํ๋ฆฌ์ผ์ด์ ์ os ๋ชจ๋๋ฅผ ์ ์งํ๋๋ก ํ๊ณ ํ ๋์ฆ๊ฐ ์์ ์๊ฐ ์์ ์๋ ์๋ค.
๊ทธ๋ผ ์ด๋ฒ์๋ ์ ์ ์๊ฒ ์ ์ด๊ถ์ ๋๊ธฐ๋ ๋ฐฉ์์ ์ดํด๋ณด์.
2. ์ ์ ์๊ฒ ๋ชจ๋ ์ ํ ์ ์ด๊ถ ๋ถ์ฌ
import { useEffect, useState } from "react";
import { useLocalStorage, useMediaQuery } from "usehooks-ts";
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
const THEME_MODE = {
DARK: "dark",
LIGHT: "light",
} as const;
type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
type UseAppThemeOutput = {
mode: ThemeMode;
toggle: () => void;
setLight: () => void;
setDark: () => void;
setMode: (mode: ThemeMode) => void;
};
export const useAppTheme = (): UseAppThemeOutput => {
const isDarkOS = useMediaQuery(COLOR_SCHEME_QUERY);
const [osPrefersMode] = useState(
isDarkOS ? THEME_MODE.DARK : THEME_MODE.LIGHT
);
const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>(
"app-theme",
osPrefersMode ?? THEME_MODE.LIGHT
);
useEffect(() => {
const root = document?.documentElement;
if (root) {
root.dataset.theme = themeMode;
}
}, [themeMode, isDarkOS]);
return {
mode: themeMode,
toggle: () =>
setThemeMode((previous) =>
previous === THEME_MODE.DARK ? THEME_MODE.LIGHT : THEME_MODE.DARK
),
setLight: () => setThemeMode(THEME_MODE.LIGHT),
setDark: () => setThemeMode(THEME_MODE.DARK),
setMode: (mode) => setThemeMode(mode),
};
};
// app/page.tsx
"use client";
import ThemeButton from "../components/ThemeButton";
export default function Home() {
return (
<div>
<ThemeButton.Toggle />
<h1>this is home</h1>
</div>
);
}
์ด ๋ฐฉ์์ ํ์ฌ ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ชจ๋๋ฅผ ์ ์ ๊ฐ ์ํ๋ ๋๋ก ๋ณ๊ฒฝํ ์ ์๋๋ก ๊ตฌํํ๋ค.
ํ์ง๋ง os ์ค์ ์ด ๋ฐ์๋์ง ์์ผ๋ฏ๋ก ๋ถํธํ ์ํฉ์ด ์์ ์ ์๋ค.
์๋ฅผ ๋ค์ด ๋คํฌ ๋ชจ๋๋ฅผ ์ฌ์ฉํ๊ณ ์๋๋ฐ ์ด ์ ํ๋ฆฌ์ผ์ด์ ๋ง ๋ผ์ดํธ ๋ชจ๋์ผ ๊ฒฝ์ฐ ๊ฐ์๊ธฐ ๋์ด ๋๋ฌด ๋ถ์๊ฑฐ๋,
์ด๋์ด ๊ณณ์์ ๋ณด๊ณ ์๋ ์ค์ด๋ผ os ์ค์ ์ด ๋ผ์ดํธ์ธ๋ฐ ์๋์ผ๋ก ์ ํ๋ฆฌ์ผ์ด์ ์ ๋คํฌ ๋ชจ๋๋ก ์ค์ ๋์ด ์๋ค๋ฉด ๊ตณ์ด ๋ณ๊ฒฝ์ ํด์ผ ํ๋ค๋ ์ ์ด์์ฌ์ ์ข ๋ถ๋ฆฌ์๋ฉด๋ถํธํ๋ค.
๊ทธ๋ฆฌ๊ณ ์ฒ์ ์ ํ๋ฆฌ์ผ์ด์ ์ ์ง์ ํ์ ๊ฒฝ์ฐ ๋คํฌ๋ชจ๋๋ก ์ค์ ํด์ค์ผ ํ ์ง ํน์ ๋ผ์ดํธ ๋ชจ๋๋ก ์ค์ ํด์ผ ํ ์ง ์ ์ ์ ์ ํ์ด ์๋ ๊ฐ๋ฐ์ ์์๋๋ก ์ ํ ์๋ฐ์ ์๋ค.
๊ทธ๋ฐ ์ ์์ os๋ฅผ ๋ฐ๋ผ๊ฐ๋ ๊ฑธ ๊ธฐ๋ณธ์ผ๋ก (์ ์ ์ ์ทจํฅ ๊ณ ๋ ค) ํ๋ฉด์๋ ์ ์ ๊ฐ ์ํ๋ ๋๋ก ์ค์ ์ ํ ์ ์๋๋ก ๊ตฌํํ ์ ์์๊น?
๊ทธ๋ฌ๊ธฐ ์ํด ๊ณต๋ถ๋ฅผ ํ๋ฉด์ ์ข ์ข ๋ฐฉ๋ฌธํ๋ MDN Web Docs๋ ๋ผ์ดํธ/๋คํฌ ๋ชจ๋๋ฅผ ์ด๋ป๊ฒ ๊ตฌํํ๋์ง ์ดํด๋ณด์.
MDN์ ๋ผ์ดํธ/๋คํฌ ๋ชจ๋
๊ธฐ๋ณธ์ ์ผ๋ก os ์ค์ ์ ๋ฐ๋ผ๊ฐ๋๋ก ํ๊ณ , light์ด๋ dark ๋ชจ๋๋ฅผ ์ ํํ๋ฉด os ์ค์ ์ ์ค๋ฒ๋ผ์ด๋ํ๋ค.
์ด ๋์๊ณผ ๋์ผํ๋๋ก ์ฝ๋๋ฅผ ์ด์ง ์์ ํด๋ณด์.
import { useEffect, useState } from "react";
import { useLocalStorage, useMediaQuery } from "usehooks-ts";
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
const THEME_MODE = {
DARK: "dark",
LIGHT: "light",
} as const;
const CONTROL_MODE = {
USER: "user",
OS_DEFAULT: "os_default",
} as const;
type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
type ControlMode = (typeof CONTROL_MODE)[keyof typeof CONTROL_MODE];
type UseAppThemeOutput = {
mode: ThemeMode;
toggle: () => void;
setLight: () => void;
setDark: () => void;
setMode: (mode: ThemeMode) => void;
setControl: (control: ControlMode) => void; /** ๐ ์ถ๊ฐ : user/os ์๊ฒ ์ ์ด๊ถ ๋๊ธฐ๊ธฐ */
};
export const useAppTheme = (): UseAppThemeOutput => {
const isDarkOS = useMediaQuery(COLOR_SCHEME_QUERY);
const [osPrefersMode] = useState(
isDarkOS ? THEME_MODE.DARK : THEME_MODE.LIGHT
);
/**
๐ ์ถ๊ฐ : ์ด๊ธฐ ์ ์ด๊ถ์ os๊ฐ ์ง๋๋ค.
*/
const [themeMode, setThemeMode] = useLocalStorage<ThemeMode>(
"app-theme",
osPrefersMode
);
const [themeControl, setThemeControl] = useLocalStorage<ControlMode>(
"control-theme",
CONTROL_MODE.OS_DEFAULT
);
useEffect(() => {
const root = document?.documentElement;
if (themeControl === "os_default") {
setThemeMode(isDarkOS ? THEME_MODE.DARK : THEME_MODE.LIGHT);
}
root.dataset.theme = themeMode;
}, [isDarkOS, osPrefersMode, themeControl, themeMode]);
return {
mode: themeMode,
toggle: () => {
setThemeControl("user");
setThemeMode((previous) =>
previous === THEME_MODE.DARK ? THEME_MODE.LIGHT : THEME_MODE.DARK
);
},
setLight: () => setThemeMode(THEME_MODE.LIGHT),
setDark: () => setThemeMode(THEME_MODE.DARK),
setMode: (mode) => setThemeMode(mode),
setControl: (control) => setThemeControl(control),
};
};
// Theme button
"use client";
import { MyButton } from "@tripie/design-system";
import { useAppTheme } from "@tripie/hooks";
const ThemeButton = () => {
const { setControl } = useAppTheme();
return (
<MyButton onClick={() => setControl("os_default")}>os default</MyButton>
);
};
const ToggleButton = () => {
const { mode, toggle } = useAppTheme();
return (
<MyButton onClick={toggle}>
to {mode === "dark" ? "light" : "dark"}
</MyButton>
);
};
ThemeButton.Toggle = ToggleButton;
export default ThemeButton;
// app/page.tsx
"use client";
import ThemeButton from "../components/ThemeButton";
export default function Home() {
return (
<div>
<ThemeButton />
<ThemeButton.Toggle />
<h1>this is home</h1>
</div>
);
}
๐ ๋ง์น๋ฉด์
์ค๋์ ๋คํฌ/๋ผ์ดํธ ํ ๋ง ์ ์ด๊ถ์ ์ ์ ๋ os์ ๋๊ธฐ๋ ๋ฐฉ์์ ๋ํด ์ดํด๋ดค๋ค.
๋ฌผ๋ก ํ๋ก์ ํธ๊ฐ ์ ๊ณตํ๋ ์๋น์ค์ ๋ฐ๋ผ์ ๋คํฌ/๋ผ์ดํธ ๋ชจ๋๋ฅผ ์ค์ ํด ์ฃผ๋ ๊ฒ์ด ์ค์ํ์ง ์์ ์๋ ์์ง๋ง,
์ ์๊ธฐ ์ ์นจ๋์ ๋์ ํฐ์ ๋ณด๋ ์ฌ๋์๊ฒ๋ ํ์์ง ์์๊น ์ถ๋ค๐คญ
๋คํฌ ๋ชจ๋ ๊ตฌํ์ ๊ดํด ์นด์นด์ค FE ๊ธฐ์ ๋ธ๋ก๊ทธ๋ body ํ๊ทธ์ ํด๋์ค๋ฅผ ๋ถ์ด๋ ๋ฐฉ์์ผ๋ก ๊ตฌํํ๊ธฐ๋ฅผ ์ถ์ฒํ์ง๋ง ํ์ฌ ์ฝ๋์์๋ html์ ๋ถ์ด๊ณ ์๋ค. ๊ทธ๋ฆฌ๊ณ body ํ๊ทธ ๋ฐ๋ก ์๋์ ์ ํ๋ฆฌ์ผ์ด์ ์ ์ฒด๋ฅผ ๊ฐ์ธ๊ณ ์๋ section์ ํด๋์ค๋ฅผ ๋ถ์ฌํ๋ ๋ฐฉ์์ด๋ค.
์ด๋ body ํ๊ทธ๊ฐ app/layout.tsx ์ธ์๋ ๋ฑ์ฅํ ์ ์๋ค๋ next์ ์ ์ฝ(๊ธ ์์ฑ ๊ธฐ์ค)๋๋ฌธ์ ์ ํํ ๋ฐฉ์์ด๋ค.
ํํธ html์ ์ปฌ๋ฌ ๋ชจ๋๋ฅผ ์ค์ ํด ์ค์ document์ ์ต์๋จ์ด๋ฏ๋ก ๋ชจ๋ ์คํ์ผ์ด cascade ๋๊ณ , spa route ๋ณ๊ฒฝ์ด๋ full reload์ ๊น๋นก๊ฑฐ๋ฆผ์ ์ ๊ฑฐํ ์ ์๋ค๋ ์ ์์ ์ข์ ๊ฑฐ ๊ฐ๋ค.
๐ References
https://fe-developers.kakaoent.com/2021/211118-dark-mode/