스타일링 가이드
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 적극 활용
- [ ] 명확한 네이밍