이 글은 STOCAT 프로젝트에서 디자인 시스템 토큰을 정리하면서 발견한 코드 개선 경험을 기록한 글이다.
발단 : styleVariants가 슬슬 불편해지기 시작했다
이 프로젝트는 @vanilla-extract/css를 처음 도입하여, 스타일을 관리한다.
컴포넌트에 여러 외형이 필요할 때는 styleVariants를 써왔는데, MarketCard 같은 컴포넌트에서 이런 식으로 작성했었다.
// MarketCard.css.ts
const base = style({
flexShrink: 0,
borderRadius: 16,
backgroundColor: vars.color.role.background2,
});
export const layout = styleVariants({
horizontal: [base, { display: "flex", flexDirection: "row", width: 260 }],
vertical: [base, { display: "flex", flexDirection: "column", width: 160, height: 160 }],
});
그리고 컴포넌트에서는,
<div className={styles.layout[variant]}>
딱 여기까지는 괜찮다.
근데 이번에 HomeExchange 컴포넌트를 3가지 variant로 만들어야 할 상황이었다.

varaint를 각각 default, compact, highlight라고 대강 이름을 지었다.
그리고 variant마다 달라져야 하는 게 container 레이아웃뿐만 아니라 아이콘 크기, 텍스트 그룹 정렬, 환율 텍스트 굵기까지 모두 달랐다.
styleVariants를 그대로 쓰면 이렇게 된다.
export const container = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
export const icon = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
export const textGroup = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
export const rate = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
그리고 컴포넌트에서는 styles.container[variant], styles.icon[variant], styles.textGroup[variant]... 이렇게 네 번 반복하게 된다.
실수할 여지도 있고, 새 variant가 추가되면 모든 styleVariants에 빠짐없이 추가해야해서 여러모로 유지보수에 불편하다.
이 상태에서 @vanilla-extract/recipes라는 패키지(?)를 발견했다.
- https://vanilla-extract.style/documentation/packages/recipes/
recipe가 뭔데?
@vanilla-extract/css와 별개로 설치하는 패키지다.
React 생태계에서 CVA(class-variance-authority)라는 라이브러리를 써본 적이 있다면 구조가 거의 비슷하다
CVA는 보통 Tailwind와 함께 이런 식으로 쓴다.
// CVA (class-variance-authority)
const button = cva("base-class", {
variants: {
intent: {
primary: "bg-blue-500",
danger: "bg-red-500",
},
},
defaultVariants: {
intent: "primary",
},
});
// 사용: button({ intent: "primary" })
recipe는 이 패턴을 vanilla-extract 환경에서 그대로 쓸 수 있게 해준다.
차이가 있다면 클래스 이름 문자열이 아니라 vanilla-extract의 스타일 객체를 쓴다는 것,
그리고 빌드 타임에 CSS가 정적으로 생성된다는 것 정도.
// @vanilla-extract/recipes
export const container = recipe({
base: { /* 공통 스타일 */ },
variants: {
variant: {
default: { /* variant별 스타일 */ },
compact: { /* ... */ },
},
},
defaultVariants: {
variant: "default",
},
});
// 사용: styles.container({ variant: "default" })
styleVariants와 비교하면 이렇다.
styleVariants |
recipe |
|
|---|---|---|
| 여러 prop이 같은 variant를 공유 | 각각 별도 styleVariants 정의 |
하나의 recipe로 묶음 |
| 기본값 지정 | 불가 | defaultVariants로 지원 |
| 사용 방식 | styles.layout[variant] |
styles.container({ variant }) |
| 새 variant 추가 시 | 각 styleVariants마다 수동 추가 |
한 곳에서 관리 |
실제 구현 화면

HomeExchange는 3가지 variant가 있다.
- default: 아이콘+제목이 좌측, 환율이 우측 (space-between 레이아웃)
- compact: 아이콘 + "환전소 · 1,444.78" 합쳐진 텍스트를 나란히
- highlight: 큰 이미지 아이콘이 좌상단에 절대 위치, 텍스트는 중앙 정렬
스타일 파일은 이렇게 구성했다.
// HomeExchange.css.ts
import { recipe, RecipeVariants } from "@vanilla-extract/recipes";
import { style } from "@vanilla-extract/css";
import { vars } from "@/shared/styles/vars.css";
export const container = recipe({
base: {
marginTop: 8,
padding: "16px 30px",
width: "100%",
height: 70,
backgroundColor: vars.color.role.background2,
borderRadius: 16,
overflow: "hidden",
},
variants: {
variant: {
default: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
compact: {
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 16,
},
highlight: {
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
},
},
},
defaultVariants: {
variant: "default",
},
});
export type ContainerVariants = RecipeVariants<typeof container>;
export const icon = recipe({
base: { flexShrink: 0 },
variants: {
variant: {
default: { width: 28, height: 28 },
compact: { width: 28, height: 28 },
highlight: {
position: "absolute",
top: -20,
left: 0,
width: 100,
height: 100,
},
},
},
defaultVariants: { variant: "default" },
});
export const rate = recipe({
base: { color: vars.color.role.text },
variants: {
variant: {
default: { ...vars.typography.body4 },
compact: { ...vars.typography.body3 },
highlight: { ...vars.typography.body3 },
},
},
});
컴포넌트에서는 이렇게 쓴다.
// HomeExchange.tsx
export default function HomeExchange({ variant, title, description, rate }: HomeExchangeProps) {
return (
<div className={styles.container({ variant })}>
{variant === "default" && (
<>
<div className={styles.defaultIconWrapper}>
<HomeCoinOne className={styles.icon({ variant })} />
<div className={styles.textGroup({ variant })}>
<span className={styles.subText}>{description}</span>
<span className={styles.title}>{title}</span>
</div>
</div>
<span className={styles.rate({ variant })}>{rate.toLocaleString()}</span>
</>
)}
{variant === "compact" && (
<>
<HomeCoinOne className={styles.icon({ variant })} />
<div className={styles.textGroup({ variant })}>
<span className={styles.subText}>{description}</span>
<span className={styles.title}>{title} · {rate.toLocaleString()}</span>
</div>
</>
)}
</div>
);
}
styles.container({ variant }), styles.icon({ variant }),
styles.rate({ variant }) — variant 값 하나를 넘기면 각 recipe가 조합된 클래스 이름을 반환한다.
RecipeVariants가 하는 일
@vanilla-extract/recipes는 타입 유틸리티도 제공한다.
export type ContainerVariants = RecipeVariants<typeof container>;
// 결과:
// { variant?: "default" | "compact" | "highlight" }
recipe 정의에서 variant 목록을 자동으로 추출해서 타입으로 만들어준다.
variant를 추가하거나 이름을 바꾸면 CSS 파일 한 곳만 수정해도 props 타입까지 자동으로 반영된다.
지금은 HomeExchangeProps에서 직접 "default" | "compact" | "highlight"를 유니온으로 쓰고 있지만,
ContainerVariants를 확장하면 CSS와 타입이 항상 동기화된 상태를 유지할 수 있다.
직접 사용해보고 나서
좋았던 점은 관리하기 편하다는 점(?)이다. variant가 달라질 때 영향을 받는 스타일들이 한 파일 안에서, 같은 recipe 블록 안에서 관리된다. MarketCard의 styleVariants 방식도 나쁘지 않았지만, 스타일 요소가 많아질수록 "이 variant에 이 스타일이 빠진 건 아닐까" 하는 불안이 없어지는 게 체감됐다.
아쉬운 점이라면 recipe가 처음엔 좀 낯설다는 것. styleVariants는 결과가 객체라서 직관적으로 인덱싱할 수 있는데, recipe는 함수라서 "왜 호출해야 하지?"라는 의문이 드는 사람도 있을 것 같다. 빌드 타임에 정적 CSS를 생성하는 vanilla-extract 특성상 런타임 조합이 아니라 미리 정해진 클래스 이름을 반환하는 구조여서 그렇다. 함수처럼 보이지만 사실 사전에 만들어진 클래스 이름을 선택하는 것에 가깝다.
@vanilla-extract/css를 이미 쓰고 있다면 별도 패키지를 하나 더 설치하는 게 전부라 진입 장벽도 낮다. 같은 패키지 생태계 안에서 CVA 패턴을 쓸 수 있다는 게 생각보다 편했다.
'개발 일지 > FrontEnd_프론트엔드' 카테고리의 다른 글
| [STOCAT] vanilla-extract 타이포그래피 토큰, 3줄 -> 1줄로 줄이기 (0) | 2026.02.28 |
|---|---|
| [STOCAT] Tailwind CSS만 쓰던 나의 vanilla-extract 첫 도전기 (2) | 2026.02.26 |
