나만의 디자인 시스템을 만들어보자! - 디자인 토큰 & Sprinkles & Theme

흑우·2024년 2월 19일
0

Design Tokens

디자인 토큰은 무엇일까요? 간단하게 말하면 해당 디자인 시스템에서 개발자가 사용하는 다양한 값을 상수로 정해놓은 것을 말합니다.

세상에는 무수히 많은 표현들이 있지만 우리들은 한정된 단어로만 의사소통을 진행합니다. 이것과 마찬가지로 디자인 시스템은 한정된 디자인 토큰을 통해 UI를 표현합니다.

디자인 토큰을 사용하는 이유는 일관성 유지, 효율성 증대, 협업 강화 등이 있습니다.

디자인 토큰에 대한 더 깊은 이해가 필요하신 분들이라면 제가 Reference에 첨부한 영상들을 보시면 도움이 될겁니다.

color.ts

// src/tokens/color.ts
export const colors = {
  base: {
    white: "#ffffff",
    black: "#121212",
    gray: "#E2E8F0",
    red: "red",
    bluePrimary: "#3182ce",
    blueSecondary: "#4299e1",
    blueTertiary: "#63b3ed",
    blueBackground: "#ebf8ff",
    redPrimary: "#e53e3e",
    redSecondary: "#f56565",
    redTertiary: "#fc8181",
    redBackground: "#fff5f5",
    greenPrimary: "#38a169",
    greenSecondary: "#48bb78",
    greenTertiary: "#68d391",
    greenBackground: "#f0fff4",
    orangePrimary: "#dd6b20",
    orangeSecondary: "#ed8936",
    orangeTertiary: "#f6ad55",
    orangeBackground: "#fffaf0",
    statusPrimary: "#9f7aea",
    statusInfo: "#4299e1",
    statusSuccess: "#48bb78",
    statusWarning: "#ed8936",
    statusDanger: "#f56565",
    hoverImg: "rgba(0, 0, 0, 0.48)",
  },
  light: {
    brandPrimary: "#9f7aea",
    brandSecondary: "#b794f4",
    brandTertiary: "#d6bcfa",
    brandBackground: "#faf5ff",
    gray50: "#f7fafc",
    gray100: "#edf2f7",
    gray200: "#e2e8f0",
    gray300: "#cbd5e0",
    gray400: "#a0aec0",
    gray500: "#718096",
    gray600: "#4a5568",
    gray700: "#2d3748",
    gray800: "#1a202c",
    gray900: "#171923",
    borderPrimary: "#cbd5e0",
    borderSecondary: "#e2e8f0",
    borderTertiary: "#edf2f7",
    textPrimary: "#171923",
    textSecondary: "#718096",
    textTertiary: "#a0aec0",
    textPlaceholder: "#a0aec0",
    textDisabled: "#cbd5e0",
    textWhite: "#f7fafc",
    textHoverWhite: "#f7fafc",
    backgroundBase: "#ffffff",
  },
  dark: {
    brandPrimary: "#a787e8",
    brandSecondary: "#b898f1",
    brandTertiary: "#d7bef8",
    brandBackground: "#faf5ff",
    gray50: "#171923",
    gray100: "#1a202c",
    gray200: "#2d3748",
    gray300: "#4a5568",
    gray400: "#718096",
    gray500: "#a0aec0",
    gray600: "#cbd5e0",
    gray700: "#e2e8f0",
    gray800: "#edf2f7",
    gray900: "#f7fafc",
    borderPrimary: "#4a5568",
    borderSecondary: "#2d3748",
    borderTertiary: "#1a202c",
    textPrimary: "#f7fafc",
    textSecondary: "#cbd5e0",
    textTertiary: "#a0aec0",
    textPlaceholder: "#a0aec0",
    textDisabled: "#718096",
    textWhite: "#171923",
    textHoverWhite: "#f7fafc",
    backgroundBase: "#121212",
  },
};

저는 테마 기능을 구현할 예정이므로 lightTheme와 darkTheme일 때 서로 다른 색상 값을 보여줘야 합니다.

base는 테마와 상관없이 동일한 색상 값을 보여주는 token을 뜻하고 light와 dark는 각각 light 테마와 dark 테마에서 보여주는 색상 값을 뜻합니다.

gap.ts

// src/tokens/radii.ts
export const radii = {
  1: "4px",
  2: "8px",
  3: "16px",
  4: "24px",
  5: "32px",
  6: "40px",
  7: "48px",
  8: "64px",
  9: "80px",
}

color를 제외한 나머지 디자인 토큰들은 다음과 같이 단순한 객체 형태로 지정해줍니다.

font, border, radii, shadows, space .. 등등 다양한 토큰이 있을 수 있지만 이건 정해진 것이 아니라 자신이 정하기 나름입니다.

저도 처음에는 이 token 값에 대해서 많은 고민을 했는데요. 사실 이건 개발적인 부분이라기 보다는 기획과 디자인적인 부분이 많아서 그냥 간단하게 해보거나 다른 오픈소스를 참고하시는 걸 추천드립니다.

index.ts

import { colors } from "./color";
import { radii } from "./radii";
import { shadows } from "./shadows";
import { space } from "./space";
import {
  fontFamilies,
  fontSizes,
  fontWeights,
  letterSpacing,
  lineHeights,
  textDecoration,
} from "./fonts";
import { media } from "./media";
import { borderStyles, borderWidths } from "./border";

export const tokens = {
  borderStyles,
  borderWidths,
  colors,
  radii,
  shadows,
  space,
  fontFamilies,
  fontSizes,
  fontWeights,
  letterSpacing,
  lineHeights,
  textDecoration,
  media,
};

export type Tokens = typeof tokens;

정의한 다양한 토큰들을 index.ts 파일을 통해 tokens라는 변수로 export 해줍니다.

Theme

Vanilla Extract에서는 Theme 기능을 쉽게 구현할 수 있도록 createGlobalThemecreateGlobalThemeContract를 지원하고 있습니다.

createGlobalTheme는 단어 그대로 테마를 생성하는 것이지만 createGlobalThemeContract는 테마의 껍데기를 생성한다고 생각하시면 편하실 거 같습니다.

vanilla-extract의 theme에 대해서 더 궁금하시다면 공식 문서단테님의 글을 참고하시면 도움이 될겁니다.

vars.css.ts

// vars.css.ts

import {
  createGlobalTheme,
  createGlobalThemeContract,
} from "@vanilla-extract/css";
import { tokens } from "../tokens";
import { Mode } from "@/components/Other/Theme/ThemeProvider";
import { Theme } from "@/css/types";
import merge from "deepmerge";

export const getVarName = (_value: string | null, path: string[]) =>
  path.join("-").replace(".", "_").replace("/", "__");

const { colors, ...restTokens } = tokens;

const baseTokens: Omit<Theme, "colors" | "mode"> = restTokens;
const baseVars = createGlobalThemeContract(baseTokens, getVarName);
createGlobalTheme(":root", baseVars, baseTokens);

const makeColorScheme = (mode: Mode = "light") => {
  const colors = tokens.colors[mode];

  return {
    colors: {
      ...tokens.colors.base,
      brandPrimary: colors.brandPrimary,
      brandSecondary: colors.brandSecondary,
      brandTertiary: colors.brandTertiary,
      brandBackground: colors.brandBackground,
      gray50: colors.gray50,
      gray100: colors.gray100,
      gray200: colors.gray200,
      gray300: colors.gray300,
      gray400: colors.gray400,
      gray500: colors.gray500,
      gray600: colors.gray600,
      gray700: colors.gray700,
      gray800: colors.gray800,
      gray900: colors.gray900,
      borderPrimary: colors.borderPrimary,
      borderSecondary: colors.borderSecondary,
      borderTertiary: colors.borderTertiary,
      textPrimary: colors.textPrimary,
      textSecondary: colors.textSecondary,
      textTertiary: colors.textTertiary,
      textPlaceholder: colors.textPlaceholder,
      textDisabled: colors.textDisabled,
      textWhite: colors.textWhite,
      textHoverWhite: colors.textHoverWhite,
      backgroundBase: colors.backgroundBase,
    },
    mode: {
      colors: {
        ...colors,
      },
    },
  };
};

const modeTokens = makeColorScheme("light");
const modeVars = createGlobalThemeContract(modeTokens, getVarName);

createGlobalTheme("[data-color-mode='light']", modeVars, modeTokens);
createGlobalTheme(
  "[data-color-mode='dark']",
  modeVars,
  makeColorScheme("dark"),
);

type ColorVars = typeof modeVars;
const colorVars = modeVars as ColorVars;

type ThemeVars = typeof baseVars & typeof colorVars;
export const vars = merge(baseVars, colorVars) as ThemeVars;

makeColorScheme 함수를 통해 light와 dark 테마에 맞는 color 값을 추출하고 color 값의 변수와 color 값이 아닌 변수를 merge에서 vars 변수에 담아 export하고 있습니다.

ThemeProvider

// ThemeProvider.tsx
import {
  PropsWithChildren,
  createContext,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";

export type Mode = "light" | "dark";

type ThemeContextValue = {
  mode: Mode;
  setMode: (mode: Mode) => void;
};

const ThemeContext = createContext<ThemeContextValue>({
  mode: "light",
  setMode: () => {},
});

export type ThemeProviderProps = {
  defaultMode?: Mode;
};

const ThemeProvider = ({
  children,
  defaultMode = "light",
}: PropsWithChildren<ThemeProviderProps>) => {
  const [mode, setMode] = useState<Mode>(defaultMode);

  const value = useMemo(
    () => ({
      mode,
      setMode,
    }),
    [mode],
  );

  useEffect(() => {
    const element = document.documentElement;
    element.setAttribute("data-color-mode", mode);

    return () => {
      element.removeAttribute("data-color-mode");
    };
  }, [mode]);

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
};

export default ThemeProvider;

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("ThemeProvider를 추가해주세요!");
  return context;
};

vars.css.ts를 통해 정의한 light 테마와 dark 테마는 ThemeProvider를 통해 사용할 수 있습니다.

html 태그에 있는 data-color-mode 속성을 통해 테마를 결정하는 코드입니다.

Sprinkles

Vanilla Extract에서는 sprinkles라는 기능을 지원합니다.

sprinkles는 반복적인 스타일을 제거하고, 조건부 스타일을 구현할 수 있습니다.

더 자세한 설명이 필요하시다면 참고해주세요!

sprinkles.css.ts

import { createSprinkles, defineProperties } from "@vanilla-extract/sprinkles";
import { vars } from "./vars.css";
import { calc } from "@vanilla-extract/css-utils";

const space = vars.space;

const negativeSpace = {
  ["-px"]: `${calc(space.px).negate()}`,
  ["-0.5"]: `${calc(space["0.5"]).negate()}`,
  ["-1"]: `${calc(space["1"]).negate()}`,
  ["-1.5"]: `${calc(space["1.5"]).negate()}`,
  ["-2"]: `${calc(space["2"]).negate()}`,
  ["-2.5"]: `${calc(space["2.5"]).negate()}`,
  ["-3"]: `${calc(space["3"]).negate()}`,
  ["-3.5"]: `${calc(space["3.5"]).negate()}`,
  ["-4"]: `${calc(space["4"]).negate()}`,
  ["-10"]: `${calc(space["10"]).negate()}`,
};

const margins = {
  ...space,
  ...negativeSpace,
};

const responsiveProperties = defineProperties({
  conditions: {
    mobile: {},
    tablet: { "@media": "screen and (min-width: 768px)" },
    desktop: { "@media": "screen and (min-width: 1024px)" },
  },
  defaultCondition: "mobile",
  properties: {
    appearance: ["none"],
    left: margins,
    top: margins,
    right: margins,
    bottom: margins,
    whiteSpace: ["nowrap"],
    clip: ["rect(0, 0, 0, 0)"],
    boxSizing: ["border-box", "content-box"],
    overflow: ["hidden", "auto", "scroll", "visible"],
    display: ["none", "flex", "block", "inline", "inline-block", "inline-flex"],
    flexDirection: ["row", "column"],
    justifyContent: [
      "stretch",
      "flex-start",
      "center",
      "flex-end",
      "space-around",
      "space-between",
    ],
    alignItems: ["stretch", "flex-start", "center", "flex-end"],
    borderStyle: vars.borderStyles,
    borderWidth: vars.borderWidths,
    borderBottomWidth: vars.borderWidths,
    borderLeftWidth: vars.borderWidths,
    borderRightWidth: vars.borderWidths,
    borderTopWidth: vars.borderWidths,
    borderRadius: vars.radii,
    borderBottomLeftRadius: vars.radii,
    borderBottomRightRadius: vars.radii,
    borderTopLeftRadius: vars.radii,
    borderTopRightRadius: vars.radii,
    paddingTop: vars.space,
    paddingBottom: vars.space,
    paddingLeft: vars.space,
    paddingRight: vars.space,
    marginTop: margins,
    marginBottom: margins,
    marginLeft: margins,
    marginRight: margins,
    gap: vars.space,
    height: vars.space,
    lineHeight: vars.lineHeights,
    maxWidth: vars.space,
    maxHeight: vars.space,
    minWidth: vars.space,
    minHeight: vars.space,
    fontSize: vars.fontSizes,
    fontWeight: vars.fontWeights,
    listStyle: ["none", "outside"],
    position: ["absolute", "fixed", "relative", "sticky"],
    textAlign: ["center", "left", "right"],
    visibility: ["hidden", "visible"],
    width: vars.space,
    captionSide: ["bottom", "top"],
    zIndex: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    userSelect: ["none"],
    transform: ["rotate(45deg)"],
    wordWrap: ["break-word"],
    zoom: [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5],
  },
  shorthands: {
    borderLeftRadius: ["borderBottomLeftRadius", "borderTopLeftRadius"],
    borderRightRadius: ["borderBottomRightRadius", "borderTopRightRadius"],
    borderTopRadius: ["borderTopLeftRadius", "borderTopRightRadius"],
    borderBottomRadius: ["borderBottomLeftRadius", "borderBottomRightRadius"],
    margin: ["marginTop", "marginBottom", "marginLeft", "marginRight"],
    marginX: ["marginLeft", "marginRight"],
    marginY: ["marginTop", "marginBottom"],
    padding: ["paddingTop", "paddingBottom", "paddingLeft", "paddingRight"],
    paddingX: ["paddingLeft", "paddingRight"],
    paddingY: ["paddingTop", "paddingBottom"],
  },
});

const selectorProperties = defineProperties({
  conditions: {
    base: {},
    active: { selector: "&:active" },
    focus: { selector: "&:focus" },
    hover: { selector: "&:hover" },
    disabled: { selector: "&:disabled" },
    checked: { selector: "&:checked" },
  },
  defaultCondition: "base",
  properties: {
    backgroundColor: vars.colors,
    borderColor: vars.colors,
    boxShadow: vars.shadows,
    color: vars.colors,
    outlineColor: vars.colors,
    textDecoration: vars.textDecoration,
    accentColor: vars.colors,
    outline: ["none", "Highlight"],
    cursor: ["pointer", "not-allowed", "auto"],
    opacity: [
      "0",
      "0.1",
      "0.2",
      "0.3",
      "0.4",
      "0.5",
      "0.6",
      "0.7",
      "0.8",
      "0.9",
      "1",
    ],
  },
});

export const sprinkles = createSprinkles(
  responsiveProperties,
  selectorProperties,
);

export type Sprinkles = Parameters<typeof sprinkles>[0];

위 코드를 간단하게 설명드리겠습니다. defineProperties을 통해서 속성을 정의하고 createSprinkles 을 통해 정의한 속성을 sprinkles로 생성할 수 있습니다.

defineProperties는 조건을 뜻하는 conditions, 속성을 뜻하는 properties, 축약어를 뜻하는 shorthands, 기본 값을 뜻하는 defaultCondition이 있습니다.

responsiveProperties의 경우 반응형 디자인 구현하기 위해 사용됩니다. 기본 값은 mobile이며 screen의 width 값에 따라 css를 다르게 적용할 수 있습니다.

selectorProperties는 selector를 쉽게 표현하기 위해 사용합니다. active, focus, hover, disabled, checked 등 다양한 상태를 표현할 수 있습니다.

styles.css.ts

import { sprinkles } from './sprinkles.css.ts';

export const container = sprinkles({
  display: 'flex',
  paddingX: 'small',

  flexDirection: {
    mobile: 'column',
    desktop: 'row'
  },
  background: {
    lightMode: 'blue-50',
    darkMode: 'gray-700'
  }
});

sprinkles는 다음과 같이 사용할 수 있습니다. flexDirection 속성이 조건에 따라서 적용되는 값이 다른거죠.

Reference

profile
흑우 모르는 흑우 없제~

0개의 댓글