๐ŸŽจ Vanilla Extract๋กœ Next.js ์Šคํƒ€์ผ๋ง ๋งˆ์Šคํ„ฐํ•˜๊ธฐ

โ€ข

CSS-in-JS์˜ ๋”œ๋ ˆ๋งˆ์™€ ์ƒˆ๋กœ์šด ํ•ด๊ฒฐ์ฑ…

Vanilla Extract๋Š” ์ œ๋กœ ๋Ÿฐํƒ€์ž„ CSS-in-TypeScript ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ž…๋‹ˆ๋‹ค. ๋นŒ๋“œ ํƒ€์ž„์— ์ •์  CSS ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜๋ฉด์„œ๋„ TypeScript์˜ ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ์™„๋ฒฝํ•˜๊ฒŒ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. Styled-components๋‚˜ Emotion๊ณผ๋Š” ๋‹ฌ๋ฆฌ ๋Ÿฐํƒ€์ž„ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ์ „ํ˜€ ์—†์Šต๋‹ˆ๋‹ค.

ํ˜„๋Œ€ ์›น ๊ฐœ๋ฐœ์—์„œ CSS-in-JS๋Š” ์ปดํฌ๋„ŒํŠธ ๊ธฐ๋ฐ˜ ์Šคํƒ€์ผ๋ง์˜ ํ‘œ์ค€์ด ๋˜์—ˆ์ง€๋งŒ, ๋Ÿฐํƒ€์ž„ ์„ฑ๋Šฅ ๋ฌธ์ œ๋ผ๋Š” ๊ทผ๋ณธ์ ์ธ ํ•œ๊ณ„๋ฅผ ์•ˆ๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. Vanilla Extract๋Š” ์ด๋Ÿฌํ•œ ๋”œ๋ ˆ๋งˆ๋ฅผ ํ•ด๊ฒฐํ•˜๋ฉฐ, ํŠนํžˆ Next.js์™€ ๊ฐ™์€ SSR ํ™˜๊ฒฝ์—์„œ ํƒ์›”ํ•œ ์„ฑ๋Šฅ์„ ๋ฐœํœ˜ํ•ฉ๋‹ˆ๋‹ค.

Next.js ํ™˜๊ฒฝ์—์„œ์˜ Vanilla Extract ๋„์ž…

ํŒจํ‚ค์ง€ ์„ค์น˜ ๋ฐ ์˜์กด์„ฑ ๊ตฌ์„ฑ

Vanilla Extract๋ฅผ Next.js ํ”„๋กœ์ ํŠธ์— ํ†ตํ•ฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‹ค์Œ ํŒจํ‚ค์ง€๋“ค์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

# Next.js ํ”Œ๋Ÿฌ๊ทธ์ธ (๊ฐœ๋ฐœ ์˜์กด์„ฑ)
npm install --save-dev @vanilla-extract/next-plugin

# ํ•ต์‹ฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋“ค
npm install @vanilla-extract/css @vanilla-extract/sprinkles @vanilla-extract/recipes

Next.js ๋นŒ๋“œ ์‹œ์Šคํ…œ ํ†ตํ•ฉ

Next.js์—์„œ Vanilla Extract๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋ฐ˜๋“œ์‹œ ์ „์šฉ ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ํ”Œ๋Ÿฌ๊ทธ์ธ์€ ๋นŒ๋“œ ํƒ€์ž„์— .css.ts ํŒŒ์ผ์„ ์ •์  CSS๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ•ต์‹ฌ ์—ญํ• ์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค.

// next.config.js
const { createVanillaExtractPlugin } = require("@vanilla-extract/next-plugin");
const withVanillaExtract = createVanillaExtractPlugin();

/** @type {import('next').NextConfig} */
const nextConfig = {};

module.exports = withVanillaExtract(nextConfig);

๋ณตํ•ฉ ํ”Œ๋Ÿฌ๊ทธ์ธ ํ™˜๊ฒฝ์—์„œ์˜ ์„ค์ •

MDX๋‚˜ ๋‹ค๋ฅธ Next.js ํ”Œ๋Ÿฌ๊ทธ์ธ๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ๋•Œ๋Š” ํ”Œ๋Ÿฌ๊ทธ์ธ ์กฐํ•ฉ ์ˆœ์„œ๊ฐ€ ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

// next.config.js
const { createVanillaExtractPlugin } = require("@vanilla-extract/next-plugin");
const withVanillaExtract = createVanillaExtractPlugin();

const withMDX = require("@next/mdx")({
  extension: /\.mdx$/,
});

/** @type {import('next').NextConfig} */
const nextConfig = {};

// ํ”Œ๋Ÿฌ๊ทธ์ธ ์ˆœ์„œ๊ฐ€ ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค
module.exports = withVanillaExtract(withMDX(nextConfig));

๊ธฐ๋ณธ ์Šคํƒ€์ผ๋ง ์‹œ์Šคํ…œ ๊ตฌ์ถ•

TypeScript ๊ธฐ๋ฐ˜ ์Šคํƒ€์ผ ์ •์˜

Vanilla Extract์˜ ํ•€์‹ฌ์€ .css.ts ํŒŒ์ผ์—์„œ TypeScript๋กœ ์Šคํƒ€์ผ์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์ปดํŒŒ์ผ ํƒ€์ž„์— ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ๋ณด์žฅ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// styles.css.ts
import { style } from "@vanilla-extract/css";

export const container = style({
  padding: 10,
  backgroundColor: "blue",
  borderRadius: 4,
  color: "white",
});

React ์ปดํฌ๋„ŒํŠธ ํ†ตํ•ฉ

Vanilla Extract์—์„œ ์ƒ์„ฑ๋œ ์Šคํƒ€์ผ์€ ์ผ๋ฐ˜์ ์ธ CSS ํด๋ž˜์Šค๋ช…์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. React ์ปดํฌ๋„ŒํŠธ์—์„œ๋Š” className prop์— ์ง์ ‘ ํ• ๋‹นํ•˜์—ฌ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

// app.tsx
import { container } from "./styles.css.ts";

export default function App() {
  return <div className={container}>Hello Vanilla Extract!</div>;
}

๊ธฐ์—…๊ธ‰ ํ…Œ๋งˆ ์‹œ์Šคํ…œ ์„ค๊ณ„

CSS ๋ณ€์ˆ˜ ๊ธฐ๋ฐ˜ ํ…Œ๋งˆ ๊ตฌ์กฐ

Vanilla Extract์˜ ํ•ต์‹ฌ ๊ฐ•์ ์€ CSS ๋ณ€์ˆ˜๋ฅผ ํ™œ์šฉํ•œ ์ฒด๊ณ„์ ์ธ ํ…Œ๋งˆ ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๋‹คํฌ ๋ชจ๋“œ, ๋ธŒ๋žœ๋“œ ์ปค์Šคํ…€๋งˆ์ด์ง• ๋“ฑ์„ ์›ํ™œํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// theme.css.ts
import { createTheme } from "@vanilla-extract/css";

export const [themeClass, vars] = createTheme({
  color: {
    brand: "blue",
    accent: "green",
    background: "white",
    text: "black",
  },
  font: {
    body: "Arial, sans-serif",
    heading: "Georgia, serif",
  },
});

export const darkTheme = createTheme(vars, {
  color: {
    brand: "lightblue",
    accent: "lightgreen",
    background: "black",
    text: "white",
  },
  font: {
    body: "Arial, sans-serif",
    heading: "Georgia, serif",
  },
});

ํ…Œ๋งˆ ๊ณ„์•ฝ(Contract) ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜

ํ…Œ๋งˆ ๊ณ„์•ฝ์€ CSS ์—†์ด ํ…Œ๋งˆ ๊ตฌ์กฐ๋งŒ ์ •์˜ํ•˜๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค. ์ด ์ ‘๊ทผ๋ฒ•์€ ๋ฒˆ๋“ค ๋ถ„ํ•  ์ตœ์ ํ™”์™€ ์—ฌ๋Ÿฌ ํ…Œ๋งˆ์˜ ๋…๋ฆฝ์  ๊ด€๋ฆฌ๋ฅผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

// contract.css.ts - ํ…Œ๋งˆ ๊ตฌ์กฐ๋งŒ ์ •์˜
import { createThemeContract } from "@vanilla-extract/css";

export const vars = createThemeContract({
  color: {
    brand: null,
    accent: null,
    background: null,
    text: null,
  },
  font: {
    body: null,
    heading: null,
  },
});
// light-theme.css.ts
import { createTheme } from "@vanilla-extract/css";
import { vars } from "./contract.css.ts";

export const lightTheme = createTheme(vars, {
  color: {
    brand: "blue",
    accent: "green",
    background: "white",
    text: "black",
  },
  font: {
    body: "Arial, sans-serif",
    heading: "Georgia, serif",
  },
});

์œ ํ‹ธ๋ฆฌํ‹ฐ ํผ์ŠคํŠธ CSS ์‹œ์Šคํ…œ

Sprinkles์˜ ๊ธฐ์ˆ ์  ์šฐ์œ„์„ฑ

Sprinkles๋Š” Tailwind CSS์™€ ๋น„์Šทํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํผ์ŠคํŠธ ์ ‘๊ทผ๋ฒ•์„ ์ œ๊ณตํ•˜์ง€๋งŒ, ๋นŒ๋“œ ํƒ€์ž„ ์ตœ์ ํ™”์™€ ์™„์ „ํ•œ ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ๋™์‹œ์— ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

์›์ž์  CSS ํด๋ž˜์Šค ์‹œ์Šคํ…œ ๊ตฌ์„ฑ

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

// ์ŠคํŽ˜์ด์‹ฑ ๊ฐ’๋“ค ์ •์˜
const space = {
  none: 0,
  small: "4px",
  medium: "8px",
  large: "16px",
  xlarge: "32px",
};

// ์ƒ‰์ƒ ํŒ”๋ ˆํŠธ ์ •์˜
const colors = {
  "blue-50": "#eff6ff",
  "blue-100": "#dbeafe",
  "blue-200": "#bfdbfe",
  "gray-100": "#f3f4f6",
  "gray-700": "#374151",
  "gray-800": "#1f2937",
  "gray-900": "#111827",
};

// ๋ฐ˜์‘ํ˜• ์†์„ฑ๋“ค ์ •์˜
const responsiveProperties = defineProperties({
  conditions: {
    mobile: {},
    tablet: { "@media": "screen and (min-width: 768px)" },
    desktop: { "@media": "screen and (min-width: 1024px)" },
  },
  defaultCondition: "mobile",
  properties: {
    display: ["none", "flex", "block", "inline"],
    flexDirection: ["row", "column"],
    justifyContent: [
      "stretch",
      "flex-start",
      "center",
      "flex-end",
      "space-around",
      "space-between",
    ],
    alignItems: ["stretch", "flex-start", "center", "flex-end"],
    paddingTop: space,
    paddingBottom: space,
    paddingLeft: space,
    paddingRight: space,
  },
  shorthands: {
    padding: ["paddingTop", "paddingBottom", "paddingLeft", "paddingRight"],
    paddingX: ["paddingLeft", "paddingRight"],
    paddingY: ["paddingTop", "paddingBottom"],
    placeItems: ["justifyContent", "alignItems"],
  },
});

// ์ƒ‰์ƒ ์†์„ฑ๋“ค ์ •์˜ (๋‹คํฌ๋ชจ๋“œ ์ง€์›)
const colorProperties = defineProperties({
  conditions: {
    lightMode: {},
    darkMode: { "@media": "(prefers-color-scheme: dark)" },
  },
  defaultCondition: "lightMode",
  properties: {
    color: colors,
    background: colors,
  },
});

// ์ตœ์ข… sprinkles ํ•จ์ˆ˜ ์ƒ์„ฑ
export const sprinkles = createSprinkles(responsiveProperties, colorProperties);
export type Sprinkles = Parameters<typeof sprinkles>[0];

์‹ค์ „ ํ™œ์šฉ ์˜ˆ์ œ

// styles.css.ts
import { sprinkles } from "./sprinkles.css.ts";

export const responsiveContainer = sprinkles({
  display: "flex",
  paddingX: "small",
  // ๋ฐ˜์‘ํ˜• ์Šคํƒ€์ผ๋ง
  flexDirection: {
    mobile: "column",
    desktop: "row",
  },
  // ๋‹คํฌ๋ชจ๋“œ ๋Œ€์‘
  background: {
    lightMode: "blue-50",
    darkMode: "gray-700",
  },
});

Sprinkles์˜ ์žฅ์ 

  • ํƒ€์ž… ์•ˆ์ „์„ฑ: ์ž˜๋ชป๋œ ์†์„ฑ๊ฐ’ ์‚ฌ์šฉ ์‹œ ์ปดํŒŒ์ผ ์—๋Ÿฌ
  • ๋ฒˆ๋“ค ์ตœ์ ํ™”: ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” ์Šคํƒ€์ผ์€ ์ž๋™์œผ๋กœ ์ œ๊ฑฐ
  • ๋ฐ˜์‘ํ˜• ๋””์ž์ธ: ๋ฏธ๋””์–ด ์ฟผ๋ฆฌ๋ฅผ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ฒ˜๋ฆฌ

์ปดํฌ๋„ŒํŠธ ๋ณ€ํ˜• ๊ด€๋ฆฌ ์‹œ์Šคํ…œ

Recipe ํŒจํ„ด์˜ ํ•„์š”์„ฑ

Recipe ์‹œ์Šคํ…œ์€ ์ปดํฌ๋„ŒํŠธ์˜ ๋‹ค์–‘ํ•œ ๋ณ€ํ˜•(variant)์„ ์ฒด๊ณ„์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋Š” ํŒจํ„ด์ž…๋‹ˆ๋‹ค. ๋Œ€๊ทœ๋ชจ ๋””์ž์ธ ์‹œ์Šคํ…œ์—์„œ ์ปดํฌ๋„ŒํŠธ์˜ ์ผ๊ด€์„ฑ๊ณผ ์œ ์—ฐ์„ฑ์„ ๋™์‹œ์— ํ™•๋ณดํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ธฐ๋ณธ Recipe ๊ตฌ์กฐ ์„ค๊ณ„

// button.css.ts
import { recipe } from "@vanilla-extract/recipes";

export const button = recipe({
  base: {
    borderRadius: 6,
    border: "none",
    cursor: "pointer",
    fontWeight: "bold",
  },
  variants: {
    color: {
      neutral: { background: "whitesmoke", color: "black" },
      brand: { background: "blueviolet", color: "white" },
      accent: { background: "slateblue", color: "white" },
    },
    size: {
      small: { padding: "8px 12px", fontSize: "14px" },
      medium: { padding: "12px 16px", fontSize: "16px" },
      large: { padding: "16px 24px", fontSize: "18px" },
    },
    rounded: {
      true: { borderRadius: 999 },
    },
  },
  // ์—ฌ๋Ÿฌ variant ์กฐํ•ฉ์— ๋Œ€ํ•œ ํŠน๋ณ„ํ•œ ์Šคํƒ€์ผ
  compoundVariants: [
    {
      variants: {
        color: "neutral",
        size: "large",
      },
      style: {
        background: "ghostwhite",
      },
    },
  ],
  defaultVariants: {
    color: "accent",
    size: "medium",
  },
});

Sprinkles์™€ Recipe ํ†ตํ•ฉ ํŒจํ„ด

๊ณ ๊ธ‰ ๋””์ž์ธ ์‹œ์Šคํ…œ์—์„œ ๊ฐ€์žฅ ํšจ๊ณผ์ ์ธ ์ ‘๊ทผ๋ฒ•์€ Sprinkles ์œ ํ‹ธ๋ฆฌํ‹ฐ์™€ Recipe ์‹œ์Šคํ…œ์„ ์‚ฌ๋ฆฌ์— ๋งž๊ฒŒ ๊ฒฐํ•ฉํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.

// advanced-button.css.ts
import { recipe } from "@vanilla-extract/recipes";
import { reset } from "./reset.css.ts";
import { sprinkles } from "./sprinkles.css.ts";

export const advancedButton = recipe({
  base: [
    reset, // CSS ๋ฆฌ์…‹
    sprinkles({
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
    }),
  ],
  variants: {
    color: {
      neutral: sprinkles({ background: "gray-100" }),
      brand: sprinkles({ background: "blue-100" }),
      accent: sprinkles({ background: "blue-200" }),
    },
    size: {
      small: sprinkles({ paddingX: "small", paddingY: "small" }),
      medium: sprinkles({ paddingX: "medium", paddingY: "medium" }),
      large: sprinkles({ paddingX: "large", paddingY: "large" }),
    },
  },
  defaultVariants: {
    color: "accent",
    size: "medium",
  },
});

๋Ÿฐํƒ€์ž„ ์Šคํƒ€์ผ ์ œ์–ด

๋™์  ๋””์ž์ธ ์‹œ์Šคํ…œ ๊ตฌ์ถ•

์‚ฌ์šฉ์ž ์ธํ„ฐ๋ž™์…˜์ด๋‚˜ API ์‘๋‹ต์— ๋”ฐ๋ผ ๋Ÿฐํƒ€์ž„ ์Šคํƒ€์ผ ์—…๋ฐ์ดํŠธ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. vanilla-extract๋Š” @vanilla-extract/dynamic ํŒจํ‚ค์ง€๋ฅผ ํ†ตํ•ด ํƒ€์ž… ์•ˆ์ „ํ•œ ๋™์  ์Šคํƒ€์ผ๋ง์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

// app.tsx
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { container, themeVars } from "./theme.css.ts";

interface ContainerProps {
  brandColor: string;
  fontFamily: string;
}

const Container = ({ brandColor, fontFamily }: ContainerProps) => (
  <section
    className={container}
    style={assignInlineVars(themeVars, {
      color: { brand: brandColor },
      font: { body: fontFamily },
    })}
  >
    ๋™์  ์Šคํƒ€์ผ๋ง์ด ์ ์šฉ๋œ ์ปจํ…Œ์ด๋„ˆ
  </section>
);

๋ช…๋ นํ˜• ์Šคํƒ€์ผ ์ œ์–ด

DOM API๋ฅผ ํ™œ์šฉํ•˜์—ฌ CSS ๋ณ€์ˆ˜๋ฅผ ์ง์ ‘ ์ œ์–ดํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

// app.ts
import { setElementVars } from "@vanilla-extract/dynamic";
import { brandColor, textColor } from "./styles.css.ts";

const el = document.getElementById("myElement");

setElementVars(el, {
  [brandColor]: "pink",
  [textColor]: null, // null ๊ฐ’์€ ํ• ๋‹น๋˜์ง€ ์•Š์Œ
});

ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… ๊ฐ€์ด๋“œ

์ฃผ์š” ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ†ตํ•ฉ ์ด์Šˆ

์ฆ์ƒ

์™ธ๋ถ€ ๋””์ž์ธ ์‹œ์Šคํ…œ ์ปดํฌ๋„ŒํŠธ์˜ ์Šคํƒ€์ผ์ด ๋ˆ„๋ฝ๋˜๊ฑฐ๋‚˜ ๋Ÿฐํƒ€์ž„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๊ฒฝ์šฐ์ž…๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•: Next.js ์„ค์ •์—์„œ transpilePackages ์˜ต์…˜์„ ํ™œ์šฉํ•˜์—ฌ ํ•ด๋‹น ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํŠธ๋žœ์ŠคํŒŒ์ผ ๋Œ€์ƒ์— ํฌํ•จ์‹œํ‚ต๋‹ˆ๋‹ค.

// next.config.js
const nextConfig = {
  transpilePackages: ["@company/design-system"],
};

module.exports = withVanillaExtract(nextConfig);

Jest ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ์ถฉ๋Œ

Jest์˜ ๊ธฐ๋ณธ CSS ๋ชจํ‚น ์„ค์ •์ด vanilla-extract์˜ .css.ts ํŒŒ์ผ๊ณผ ์ถฉ๋Œํ•˜์—ฌ ํ…Œ์ŠคํŠธ ์‹คํ–‰์— ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ• 1: vanilla-extract ์ „์šฉ Jest transformer ์ ์šฉ

// jest.config.js
{
  "transform": {
    "\\.css\\.ts$": "@vanilla-extract/jest-transform"
  }
}

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ• 2: ์ •๊ทœ ํ‘œํ˜„์‹์„ ํ†ตํ•ด ๋ชจํ‚น ๋Œ€์ƒ์„ ์ •๋ฐ€ํ•˜๊ฒŒ ์ œ์–ด

{
  "jest": {
    "moduleNameMapper": {
      "legacy-styles/.*\\.css$": "<rootDir>/styleMock.js"
    }
  }
}

์„ฑ๋Šฅ ์ตœ์ ํ™” ์ „๋žต

CSS ํด๋ž˜์Šค๋ช… ์ตœ์ ํ™”

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

// next.config.js
const withVanillaExtract = createVanillaExtractPlugin({
  identifiers: "short", // 'hnw5tz3' ๊ฐ™์€ ์งง์€ ํ•ด์‹œ
});

// ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ๋””๋ฒ„๊น…์„ ์œ„ํ•ด
const withVanillaExtract = createVanillaExtractPlugin({
  identifiers: "debug", // 'myfile_mystyle_hnw5tz3' ๊ฐ™์€ ์„ค๋ช…์  ์ด๋ฆ„
});

๋ฒˆ๋“ค ํฌ๊ธฐ ์ตœ์ ํ™”

๋ฐ˜์‘ํ˜• ๋ฐฐ์—ด ํ‘œ๊ธฐ๋ฒ•์„ ํ™œ์šฉํ•˜์—ฌ ์ฝ”๋“œ ๊ฐ€๋…์„ฑ๊ณผ ๋ฒˆ๋“ค ํšจ์œจ์„ฑ์„ ๋™์‹œ์— ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// sprinkles.css.ts
const responsiveProperties = defineProperties({
  conditions: {
    mobile: {},
    tablet: { "@media": "screen and (min-width: 768px)" },
    desktop: { "@media": "screen and (min-width: 1024px)" },
  },
  defaultCondition: "mobile",
  responsiveArray: ["mobile", "tablet", "desktop"], // ์ˆœ์„œ ์ •์˜
});

// ์‚ฌ์šฉ๋ฒ•
sprinkles({
  flexDirection: ["column", "row", "row"], // mobile, tablet, desktop ์ˆœ์„œ
});

์‹ค๋ฌด ์ ์šฉ ๊ฐ€์ด๋“œ

์ปดํฌ๋„ŒํŠธ Props ๋ถ„๋ฆฌ ํŒจํ„ด

Sprinkles ์Šคํƒ€์ผ ์†์„ฑ๊ณผ ์ปดํฌ๋„ŒํŠธ ๊ณ ์œ  ์†์„ฑ์„ ์ž๋™์œผ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ๊ด€๋ฆฌํ•˜๋Š” ๊ณ ๊ธ‰ ํŒจํ„ด์ž…๋‹ˆ๋‹ค.

// ์Šคํ”„๋งํด์Šค ์†์„ฑ์ธ์ง€ ํ™•์ธ
sprinkles.properties.has('paddingX'); // boolean ๋ฐ˜ํ™˜

// ์ปดํฌ๋„ŒํŠธ์—์„œ ํ™œ์šฉ
function MyComponent(props) {
  const sprinkleProps = {};
  const otherProps = {};

  Object.keys(props).forEach(key => {
    if (sprinkles.properties.has(key)) {
      sprinkleProps[key] = props[key];
    } else {
      otherProps[key] = props[key];
    }
  });

  return (
    <div
      className={sprinkles(sprinkleProps)}
      {...otherProps}
    >
      {props.children}
    </div>
  );
}

ํ‚คํ”„๋ ˆ์ž„ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๊ณ ๊ธ‰ ๊ธฐ๋ฒ•

import { createVar, keyframes } from "@vanilla-extract/css";

const angle = createVar({
  syntax: "<angle>",
  inherits: false,
  initialValue: "0deg",
});

export const spin = keyframes({
  "0%": {
    vars: { [angle]: "0deg" },
  },
  "100%": {
    vars: { [angle]: "360deg" },
  },
});

CSS ๊ณ„์‚ฐ ํ•จ์ˆ˜ ์ตœ์ ํ™”

vanilla-extract์˜ @vanilla-extract/css-utils ํŒจํ‚ค์ง€๋Š” CSS calc() ํ•จ์ˆ˜๋ฅผ ํƒ€์ž… ์•ˆ์ „ํ•˜๊ฒŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ•๋ ฅํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ calc ์—ฐ์‚ฐ

import { calc } from "@vanilla-extract/css-utils";

// ์ง์ ‘ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ
const styles = {
  height: calc.multiply("var(--grid-unit)", 2),
  width: calc.divide("100vw", 3),
  margin: calc.negate("var(--space-large)"),
};

์ฒด์ด๋‹ API ํ™œ์šฉ

๋ณต์žกํ•œ ๊ณ„์‚ฐ์‹์„ ์ฒด์ธ์œผ๋กœ ์—ฐ๊ฒฐํ•˜์—ฌ ๊ฐ€๋…์„ฑ ์žˆ๊ฒŒ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

import { calc } from "@vanilla-extract/css-utils";

const complexCalculation = style({
  // ์—ฌ๋Ÿฌ ์—ฐ์‚ฐ์„ ์ฒด์ด๋‹
  marginTop: calc("var(--space-large)").divide(2).negate().toString(),

  // CSS Grid ๊ณ„์‚ฐ
  gridTemplateColumns: calc("100%")
    .subtract("var(--sidebar-width)")
    .subtract("var(--padding)")
    .toString(),
});

์ค‘์ฒฉ ์—ฐ์‚ฐ ๊ฐœ์„  (v1.8.0+)

์ตœ์‹  ๋ฒ„์ „์—์„œ๋Š” ์ค‘์ฒฉ๋œ calc ์—ฐ์‚ฐ์ด ํฌ๊ฒŒ ๊ฐœ์„ ๋˜์–ด ๋” ์ง๊ด€์ ์ธ ์ฝ”๋“œ ์ž‘์„ฑ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

// ์ด์ „ ๋ฒ„์ „: toString() ๋ช…์‹œ์  ํ˜ธ์ถœ ํ•„์š”
const oldStyle = {
  width: calc("10px").add(calc("20px").subtract("4px").toString()),
};

// ๊ฐœ์„ ๋œ ๋ฒ„์ „: ์ž๋™ ์ฒ˜๋ฆฌ
const newStyle = {
  width: calc("10px").add(calc("20px").subtract("4px")),
};

ํ…Œ๋งˆ ๋ณ€์ˆ˜์™€์˜ ๊ฒฐํ•ฉ

ํ…Œ๋งˆ ์‹œ์Šคํ…œ๊ณผ ๊ฒฐํ•ฉํ•˜์—ฌ ๋™์  ๊ณ„์‚ฐ์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

import { calc } from "@vanilla-extract/css-utils";
import { vars } from "./theme.css.ts";

const responsiveContainer = style({
  // ํ…Œ๋งˆ ๋ณ€์ˆ˜๋ฅผ ํ™œ์šฉํ•œ ๋ฐ˜์‘ํ˜• ๊ณ„์‚ฐ
  padding: calc(vars.space.medium).multiply(2),

  // ๋ทฐํฌํŠธ ๊ธฐ๋ฐ˜ ๊ณ„์‚ฐ
  maxWidth: calc("100vw").subtract(vars.space.large),

  // ๋ณตํ•ฉ ๊ณ„์‚ฐ์‹
  fontSize: calc(vars.fontSize.base)
    .multiply("var(--scale-factor, 1)")
    .add("2px"),
});

์œ ์šฉํ•œ calc ํŒจํ„ด

์‹ค๋ฌด์—์„œ ์ž์ฃผ ์‚ฌ์šฉ๋˜๋Š” ์œ ์šฉํ•œ ํŒจํ„ด๋“ค์ž…๋‹ˆ๋‹ค.

import { calc } from "@vanilla-extract/css-utils";

// ์„ผํ„ฐ๋ง ๊ณ„์‚ฐ
const centeredElement = style({
  left: "50%",
  transform: `translateX(${calc("50%").negate()})`,
});

// ๋น„์œจ ๊ธฐ๋ฐ˜ ๊ณ„์‚ฐ
const aspectRatioContainer = style({
  width: "100%",
  height: calc("100%").multiply(9).divide(16), // 16:9 ๋น„์œจ
});

// ๊ทธ๋ฆฌ๋“œ ๊ฐญ ๊ณ„์‚ฐ
const gridItem = style({
  width: calc("100%").subtract(calc(vars.grid.gap).multiply(2)).divide(3),
});

// ๋‹ค์ค‘ ๋‹จ์œ„ ๊ณ„์‚ฐ
const fluidTypography = style({
  fontSize: calc("1rem").add(calc("2vw")).add(calc("0.5em")),
});

์ด๋Ÿฌํ•œ calc ์œ ํ‹ธ๋ฆฌํ‹ฐ๋ฅผ ํ™œ์šฉํ•˜๋ฉด ๋ณต์žกํ•œ CSS ๊ณ„์‚ฐ์„ ํƒ€์ž… ์•ˆ์ „ํ•˜๊ฒŒ ๊ด€๋ฆฌํ•˜๋ฉด์„œ๋„ ์ฝ”๋“œ์˜ ๊ฐ€๋…์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ํฌ๊ฒŒ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฒฐ๋ก 

vanilla-extract๋Š” Next.js 14+ ํ™˜๊ฒฝ์—์„œ ์ œ๋กœ ๋Ÿฐํƒ€์ž„ CSS-in-TypeScript ์†”๋ฃจ์…˜์˜ ์ƒˆ๋กœ์šด ํ‘œ์ค€์„ ์ œ์‹œํ•ฉ๋‹ˆ๋‹ค. ์ปดํŒŒ์ผ ํƒ€์ž„ ์ตœ์ ํ™”์™€ TypeScript์˜ ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ๊ฒฐํ•ฉํ•˜์—ฌ, ๋Œ€๊ทœ๋ชจ ํ”„๋กœ๋•์…˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์š”๊ตฌ๋˜๋Š” ์„ฑ๋Šฅ๊ณผ ๊ฐœ๋ฐœ์ž ๊ฒฝํ—˜์„ ๋ชจ๋‘ ๋งŒ์กฑ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์™ธ๋ถ€ ๋งํฌ