Skip to content

스타일링 가이드

Vanilla Extract

이 프로젝트는 Vanilla Extract를 CSS-in-JS 솔루션으로 사용합니다.

특징

  • ✅ Zero-runtime CSS (빌드 타임에 CSS 생성)
  • ✅ TypeScript 기반 타입 안전성
  • ✅ 뛰어난 성능
  • ✅ CSS Modules와 유사한 사용성
  • ✅ 테마 지원

기본 사용법

스타일 파일 작성

typescript
// Component.css.ts
import { style } from '@vanilla-extract/css';
import { token } from '@shared/styles';

export const container = style({
  display: 'flex',
  flexDirection: 'column',
  gap: '16px',
  padding: token.layout.spacing.size20,
  backgroundColor: token.background.base,
  borderRadius: token.layout.radius.md,
});

export const title = style({
  fontSize: '24px',
  fontWeight: 'bold',
  color: token.text.default.primary,
});

export const button = style({
  padding: '8px 16px',
  backgroundColor: token.foundation.primary.base,
  color: token.text.onPrimary.default,
  border: 'none',
  borderRadius: token.layout.radius.sm,
  cursor: 'pointer',

  ':hover': {
    backgroundColor: token.foundation.primary.hover,
  },

  ':active': {
    backgroundColor: token.foundation.primary.pressed,
  },
});

컴포넌트에서 사용

typescript
// Component.tsx
import * as styles from './Component.css';

export default function Component() {
  return (
    <div className={styles.container}>
      <h1 className={styles.title}>제목</h1>
      <button className={styles.button}>버튼</button>
    </div>
  );
}

디자인 토큰

토큰 구조

typescript
// shared/styles/theme.css.ts
export const token = {
  foundation: {
    primary: { base, hover, pressed },
    secondary: { base, hover, pressed },
    tertiary: { base, hover, pressed },
  },
  text: {
    onPrimary: { default },
    default: { primary, secondary, tertiary },
  },
  background: {
    base,
    secondary,
    tertiary,
    surface: { primary, secondary },
    overlay: { hover, pressed },
  },
  border: {
    base,
    variant: { base, divider },
  },
  semantic: {
    success,
    warning,
    error,
    info,
  },
  layout: {
    spacing: { size80, size120, size160, size20, ... },
    radius: { xs, sm, md, lg, xl },
  },
};

토큰 사용

typescript
import { token } from '@shared/styles';

export const card = style({
  padding: token.layout.spacing.size20,
  backgroundColor: token.background.surface.primary,
  borderRadius: token.layout.radius.md,
  border: `1px solid ${token.border.variant.base}`,
});

CSS 스타일링에서 템플릿 리터럴 적용

이 프로젝트는 모든 문자열 보간 상황에서 템플릿 리터럴 사용을 권장합니다. CSS 값도 예외가 아니며, 모든 CSS 문자열 값은 템플릿 리터럴로 작성합니다.

typescript
import { style } from '@vanilla-extract/css';
import { token } from '@shared/styles';

// ✅ 올바른 사용 - 모든 문자열 값을 템플릿 리터럴로
export const container = style({
  // 토큰 조합
  width: `calc(100% - ${token.layout.spacing.size240})`,
  padding: `${token.layout.spacing.size160} ${token.layout.spacing.size20}`,
  margin: `0 ${token.layout.spacing.size120}`,

  // 토큰 단일 사용도 템플릿 리터럴
  backgroundColor: `${token.background.base}`,
  color: `${token.text.default.primary}`,
  borderRadius: `${token.layout.radius.md}`,

  // CSS 함수와 함께
  border: `1px solid ${token.border.base}`,
  boxShadow: `0 4px 8px ${token.background.overlay.hover}`,
  background: `linear-gradient(to bottom, ${token.background.base}, ${token.background.secondary})`,

  // 빈 문자열도 템플릿 리터럴
  content: `""`,
});

// ❌ 잘못된 사용 - 템플릿 리터럴 없이 사용 금지
export const badContainer = style({
  backgroundColor: token.background.base, // ❌ 템플릿 리터럴 필수
  padding: '16px 20px', // ❌ 하드코딩 금지
  border: '1px solid ' + token.border.base, // ❌ 문자열 연결 금지
});

왜 모든 경우에 템플릿 리터럴을 사용하나요?

  • 일관성: 모든 CSS 값이 동일한 패턴으로 작성됨
  • 명확성: 디자인 토큰 사용이 코드에서 명확하게 드러남
  • 유지보수: 하드코딩 값과 토큰 값을 쉽게 구분 가능
  • 테마 대응: 테마 변경 시 모든 값이 자동으로 반영됨
  • 도구 지원: 정적 분석 도구에서 토큰 사용 추적이 용이함

테마

라이트/다크 테마

typescript
// shared/styles/light.ts
export const lightTheme = createTheme(token, {
  foundation: {
    primary: {
      base: '#1976d2',
      hover: '#1565c0',
      pressed: '#0d47a1',
    },
  },
  background: {
    base: '#ffffff',
    secondary: '#f5f5f5',
  },
  text: {
    default: {
      primary: '#000000',
      secondary: '#666666',
    },
  },
  // ...
});

// shared/styles/dark.ts
export const darkTheme = createTheme(token, {
  foundation: {
    primary: {
      base: '#90caf9',
      hover: '#64b5f6',
      pressed: '#42a5f5',
    },
  },
  background: {
    base: '#121212',
    secondary: '#1e1e1e',
  },
  text: {
    default: {
      primary: '#ffffff',
      secondary: '#b3b3b3',
    },
  },
  // ...
});

테마 적용

typescript
// app/App.tsx
import { lightTheme, darkTheme } from '@shared/styles';

export default function App() {
  const { theme } = useTheme();

  return (
    <div className={theme === 'light' ? lightTheme : darkTheme}>
      {/* 앱 컨텐츠 */}
    </div>
  );
}

Style Variants

기본 Variants

typescript
import { styleVariants } from '@vanilla-extract/css';

const buttonBase = style({
  padding: '8px 16px',
  border: 'none',
  borderRadius: token.layout.radius.sm,
  cursor: 'pointer',
  fontWeight: 'bold',
});

export const button = styleVariants({
  primary: [
    buttonBase,
    {
      backgroundColor: token.foundation.primary.base,
      color: token.text.onPrimary.default,
    },
  ],
  secondary: [
    buttonBase,
    {
      backgroundColor: token.foundation.secondary.base,
      color: token.text.onPrimary.default,
    },
  ],
  outlined: [
    buttonBase,
    {
      backgroundColor: 'transparent',
      border: `1px solid ${token.border.variant.base}`,
      color: token.text.default.primary,
    },
  ],
});

사용

typescript
<button className={button.primary}>Primary</button>
<button className={button.secondary}>Secondary</button>
<button className={button.outlined}>Outlined</button>

조건부 스타일

Recipe 패턴

typescript
import { recipe } from '@vanilla-extract/recipes';

export const button = recipe({
  base: {
    padding: '8px 16px',
    border: 'none',
    borderRadius: token.layout.radius.sm,
    cursor: 'pointer',
  },

  variants: {
    variant: {
      primary: {
        backgroundColor: token.foundation.primary.base,
        color: token.text.onPrimary.default,
      },
      secondary: {
        backgroundColor: token.foundation.secondary.base,
        color: token.text.onPrimary.default,
      },
    },
    size: {
      small: { padding: '4px 8px', fontSize: '12px' },
      medium: { padding: '8px 16px', fontSize: '14px' },
      large: { padding: '12px 24px', fontSize: '16px' },
    },
    disabled: {
      true: { opacity: 0.5, cursor: 'not-allowed' },
    },
  },

  defaultVariants: {
    variant: 'primary',
    size: 'medium',
  },
});

사용

typescript
<button className={button({ variant: 'primary', size: 'large' })}>
  Large Primary
</button>

<button className={button({ variant: 'secondary', disabled: true })}>
  Disabled Secondary
</button>

반응형 디자인

Media Query

typescript
import { style } from '@vanilla-extract/css';

export const container = style({
  padding: '16px',

  '@media': {
    'screen and (min-width: 768px)': {
      padding: '24px',
    },
    'screen and (min-width: 1024px)': {
      padding: '32px',
    },
  },
});

애니메이션

Keyframes

typescript
import { keyframes, style } from '@vanilla-extract/css';

const fadeIn = keyframes({
  '0%': { opacity: 0 },
  '100%': { opacity: 1 },
});

const slideUp = keyframes({
  '0%': { transform: 'translateY(20px)', opacity: 0 },
  '100%': { transform: 'translateY(0)', opacity: 1 },
});

export const modal = style({
  animation: `${fadeIn} 0.3s ease`,
});

export const content = style({
  animation: `${slideUp} 0.3s ease`,
});

전역 스타일

Global Styles

typescript
// shared/styles/global.css.ts
import { globalStyle } from '@vanilla-extract/css';
import { token } from './theme.css';

globalStyle('*', {
  margin: 0,
  padding: 0,
  boxSizing: 'border-box',
});

globalStyle('body', {
  fontFamily: 'Pretendard, -apple-system, BlinkMacSystemFont, sans-serif',
  backgroundColor: token.background.base,
  color: token.text.default.primary,
  lineHeight: 1.6,
});

globalStyle('a', {
  color: 'inherit',
  textDecoration: 'none',
});

레이아웃 유틸리티

Spacing

typescript
// shared/styles/layout.ts
export const spacing = {
  size80: '8px',
  size120: '12px',
  size160: '16px',
  size20: '20px',
  size240: '24px',
  size320: '32px',
  size400: '40px',
  size480: '48px',
};

export const radius = {
  xs: '2px',
  sm: '4px',
  md: '8px',
  lg: '12px',
  xl: '16px',
  full: '9999px',
};

컴포넌트 스타일 패턴

복잡한 컴포넌트

typescript
// Modal.css.ts
import { style } from '@vanilla-extract/css';
import { token } from '@shared/styles';

export const overlay = style({
  position: 'fixed',
  top: 0,
  left: 0,
  right: 0,
  bottom: 0,
  backgroundColor: 'rgba(0, 0, 0, 0.5)',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  zIndex: 1000,
});

export const modal = style({
  backgroundColor: token.background.base,
  borderRadius: token.layout.radius.lg,
  padding: token.layout.spacing.size320,
  maxWidth: '500px',
  width: '90%',
  boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
});

export const header = style({
  display: 'flex',
  justifyContent: 'space-between',
  alignItems: 'center',
  marginBottom: token.layout.spacing.size20,
  paddingBottom: token.layout.spacing.size160,
  borderBottom: `1px solid ${token.border.variant.divider}`,
});

export const title = style({
  fontSize: '20px',
  fontWeight: 'bold',
  color: token.text.default.primary,
});

export const closeButton = style({
  background: 'none',
  border: 'none',
  cursor: 'pointer',
  padding: token.layout.spacing.size80,
  color: token.text.default.tertiary,

  ':hover': {
    color: token.text.default.secondary,
  },
});

export const body = style({
  marginBottom: token.layout.spacing.size20,
});

export const footer = style({
  display: 'flex',
  justifyContent: 'flex-end',
  gap: token.layout.spacing.size120,
});

성능 최적화

1. 스타일 재사용

typescript
// ✅ 공통 스타일 분리
const flexCenter = style({
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
});

export const container = style([
  flexCenter,
  {
    padding: '20px',
  },
]);

2. 불필요한 스타일 생성 방지

typescript
// ❌ 동적 스타일 (런타임 생성)
const getStyle = (color: string) =>
  style({
    backgroundColor: color,
  });

// ✅ Variants 사용 (빌드 타임 생성)
export const box = styleVariants({
  red: { backgroundColor: 'red' },
  blue: { backgroundColor: 'blue' },
  green: { backgroundColor: 'green' },
});

체크리스트

스타일 작성 시:

  • [ ] 디자인 토큰(token) 사용
  • [ ] 하드코딩된 색상/크기 지양
  • [ ] 테마 전환 대응
  • [ ] 재사용 가능한 스타일 분리
  • [ ] Variants 적극 활용
  • [ ] 명확한 네이밍