Emotion으로 variant를 선언적으로 다루기: emotion-variants 개발기
목차
Emotion으로 버튼 같은 컴포넌트를 만들다 보면 매번 비슷한 일을 반복하게 돼요. size나 color에 따라 모양이 달라져야 하니까 스타일 객체를 만들고, props 타입을 따로 적고, 어떤 값일 때 어떤 스타일을 입힐지 조건문으로 이어 붙이죠.
처음에는 별일 아니에요. 그런데 prop이 늘고 "이 조합일 때만 다르게" 같은 예외가 붙기 시작하면 이야기가 달라져요. 값 하나를 추가하는데도 스타일 객체와 타입, 조건문을 오가며 고쳐야 하거든요.
Tailwind CSS로 개발할 때는 이런 번거로움이 없었어요. cva(class-variance-authority)에 조합을 한 번 선언하면 끝이었으니까요. 같은 버튼을 만드는데 개발 경험이 이렇게 다르다는 점이 늘 아쉬웠어요.
"Emotion에서도 cva처럼 쓸 수는 없을까?"
이 아쉬움이 계속 남아서 결국 직접 만들어 보기로 했어요. 그렇게 만든 라이브러리가 emotion-variants예요.
이 글에서는 emotion-variants가 풀고자 하는 문제가 무엇인지, styled만으로는 왜 부족했는지, 그리고 어떤 원칙으로 만들었는지 이야기하려고 해요.
variant가 뭐길래?
버튼 컴포넌트 하나를 떠올려 볼게요. 버튼은 보통 한 가지 모양으로만 쓰이지 않아요. 크기도 여러 가지고, 색도 여러 가지죠. 색으로 꽉 채운 버튼, 테두리만 있는 버튼, 글자만 있는 버튼처럼 생김새도 나뉘고요.
이 글에서는 디자인 시스템에서 흔히 볼 수 있는 버튼을 예시로 쓸게요. MUI 같은 디자인 시스템의 버튼도 variant, size, color 세 가지 prop을 가지고 있어요.
| prop | 값 | 바뀌는 것 |
|---|---|---|
variant | filled, outlined, text | 생김새 |
size | small, medium, large | 크기 |
color | primary, secondary, success, error, info, warning | 색 |
이렇게 컴포넌트의 모양을 결정하는 선택지를 variant라고 불러요. 이 글에서 쓰는 용어는 세 가지만 기억하면 돼요.
- variant:
size,color처럼 모양을 바꾸는 prop이에요. prop마다small,large같은 값을 가져요. 표의 첫 번째 prop은 이름부터variant인데, 버튼의 생김새를 가리킬 때 흔히 쓰는 이름이라 그대로 썼어요. - compound variant: "
filled이면서warning일 때만 글자를 어둡게"처럼, 여러 variant가 특정 조합일 때만 적용되는 스타일이에요. - default variant: prop을 생략했을 때 쓰는 기본값이에요.
variant가 3가지, size가 3가지, color가 6가지면 버튼의 모양은 벌써 54가지예요. prop이 하나 늘 때마다 조합은 곱으로 늘어나요.
Emotion만으로 variant를 다루면
Emotion만으로 이 버튼을 만들면 보통 이런 코드가 돼요.
// Button.tsx
import { css } from '@emotion/react';
// 1. variant별 스타일 객체
const variantStyles = {
filled: {
background: 'var(--button-color)',
color: 'white',
},
outlined: {
background: 'transparent',
borderColor: 'var(--button-color)',
color: 'var(--button-color)',
},
text: {
background: 'transparent',
color: 'var(--button-color)',
},
};
const sizeStyles = {
small: {
padding: '8px 16px',
fontSize: 12,
},
medium: {
padding: '10px 20px',
fontSize: 14,
},
large: {
padding: '12px 24px',
fontSize: 16,
},
};
const colorStyles = {
primary: {
'--button-color': 'pink',
},
secondary: {
'--button-color': 'gray',
},
success: {
'--button-color': 'green',
},
error: {
'--button-color': 'red',
},
info: {
'--button-color': 'blue',
},
warning: {
'--button-color': 'orange',
},
};
// 2. props 타입은 손으로 작성
type ButtonProps = {
variant?: 'filled' | 'outlined' | 'text';
size?: 'small' | 'medium' | 'large';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning';
};
// 3. 기본값, 조합, 복합 규칙은 컴포넌트가 떠안음
function Button({
variant = 'filled',
size = 'medium',
color = 'primary',
}: ButtonProps) {
return (
<button
css={css({
border: '1px solid transparent',
borderRadius: 8,
cursor: 'pointer',
...variantStyles[variant],
...sizeStyles[size],
...colorStyles[color],
...(variant === 'filled' && color === 'warning' && {
color: 'black',
}),
})}
>
Click
</button>
);
}색을 다루는 방식부터 볼게요. color는 --button-color라는 CSS 변수에 색 하나만 담아요. 그 색을 배경에 칠할지, 테두리와 글자에 쓸지는 variant가 정하고요. 덕분에 색이 6가지여도 variant마다 색을 다시 적지 않아도 돼요.
그런데 실제로 버튼을 만들다 보면 이 깔끔한 규칙에도 예외가 생겨요. filled 버튼의 글자는 흰색인데, warning처럼 밝은 색 위에서는 흰 글자가 잘 읽히지 않을 수 있거든요. 그래서 "filled이면서 warning일 때만 글자를 검은색으로"라는 규칙이 붙어요. variant와 color를 함께 봐야 정해지는 스타일이라 어느 한쪽 객체에 넣을 수가 없어요. 이게 앞에서 말한 compound variant예요. 디자인의 예외는 늘 이렇게 prop과 prop이 만나는 자리에서 생겨요.
이 코드도 동작에는 아무 문제가 없어요. 문제는 버튼 하나의 모양을 설명하는 정보가 세 조각으로 흩어져 있다는 거예요.
- 스타일은
variantStyles,sizeStyles,colorStyles객체에 있어요. - 어떤 값을 받을 수 있는지는
ButtonProps타입에 있어요. - 기본값과 복합 규칙은 컴포넌트 안에 있어요.
그래서 size에 xlarge 하나를 추가하려면 스타일 객체와 타입을 모두 고쳐야 해요. 복합 규칙에 얽혀 있다면 컴포넌트까지 손대야 하고요. 컴포넌트가 하나일 때는 괜찮지만, 디자인 시스템처럼 이런 컴포넌트가 수십 개가 되면 이야기가 달라지죠.
styled를 쓰면 되지 않을까?
여기서 이런 생각이 들 수 있어요. "Emotion에는 styled가 있잖아요. props에 따라 스타일을 바꿀 수 있는데 그걸로 충분하지 않나요?"
절반은 맞는 말이에요.
단일 variant는 쉬워요
variant가 하나뿐이라면 styled로 깔끔하게 쓸 수 있어요.
// Button.tsx
import styled from '@emotion/styled';
const Button = styled.button<{
size: 'small' | 'medium' | 'large';
}>(({ size }) => sizeStyles[size]);prop 값으로 스타일 객체를 찾아오기만 하면 되니까요. 여기까지는 따로 도구가 필요하다는 생각이 들지 않아요.
compound부터 어려워져요
문제는 variant가 두 개 이상이 되고, 조합 규칙이 생길 때부터예요. 앞의 버튼을 styled로 옮기면 이렇게 돼요.
// Button.tsx
import styled from '@emotion/styled';
const Button = styled.button<ButtonProps>(
{
border: '1px solid transparent',
borderRadius: 8,
cursor: 'pointer',
},
({ variant = 'filled' }) => variantStyles[variant],
({ size = 'medium' }) => sizeStyles[size],
({ color = 'primary' }) => colorStyles[color],
// 복합 규칙: 조건 분기를 직접 써야 해요
({ variant = 'filled', color }) =>
variant === 'filled' && color === 'warning'
? {
color: 'black',
}
: undefined,
);이 코드도 동작은 해요. 하지만 쓰다 보면 다섯 가지 문제가 보여요.
1. 조건이 선언이 아니라 코드예요.
복합 규칙 하나마다 &&와 ?: 분기가 하나씩 늘어나요. 나중에 info 버튼에도 같은 규칙이 필요해져서 "filled이면서 warning이거나 info일 때"가 되면 어떨까요? 조건이 조금만 복잡해져도 읽기가 금방 어려워져요.
2. 기본값이 여러 곳에 흩어져요.
위 코드에서 variant = 'filled'가 두 번 나오는 걸 볼 수 있어요. 복합 규칙 쪽에서 이 기본값을 빠뜨리면 어떻게 될까요? <Button color="warning" />처럼 variant를 생략했을 때 undefined로 비교돼서, 복합 규칙이 조용히 적용되지 않아요. 화면에는 filled 버튼으로 그려지는데 글자는 흰색 그대로인 거예요. 에러도 나지 않아서 찾기 어려운 버그가 되죠.
3. 적용 순서가 작성 순서에 달려 있어요.
복합 규칙은 개별 variant보다 뒤에 적용되어야 스타일을 덮어쓸 수 있어요. 위 코드에서도 복합 규칙의 검은 글자색이 variantStyles의 흰 글자색을 덮어써야 하죠. 그런데 이 순서를 지켜 주는 건 코드를 쓴 순서뿐이에요. 누군가 복합 규칙 줄을 variantStyles 줄보다 위로 옮기면, warning 버튼의 글자는 다시 흰색이 돼요.
4. 타입을 손으로 맞춰야 해요.
sizeStyles에 xlarge를 추가해도 ButtonProps에는 자동으로 반영되지 않아요. keyof typeof sizeStyles로 맞출 수는 있지만, 그것도 prop마다 직접 써야 하죠.
5. 여러 요소로 된 컴포넌트에는 조건이 반복돼요.
Card처럼 root, title, footer로 이루어진 컴포넌트를 생각해 보세요. 세 요소가 같은 size에 반응해야 한다면, styled 컴포넌트마다 같은 조건을 반복해서 써야 해요.
정리하면
styled는 compound variant를 "만들 수 있는" 도구이지, "선언할 수 있는" 도구는 아니에요.
왜 그동안 Emotion용 도구는 없었을까?
이런 불편함이 있는데도, Emotion에서 cva처럼 쓸 수 있는 독립된 라이브러리는 찾아보기 어려워요. 왜 그럴까 궁금해서 제 나름대로 이유를 생각해 봤어요.
첫째, 어느 정도까지는 직접 만들어 쓸 수 있어요.
앞에서 본 것처럼 단일 variant는 스타일 객체 하나면 충분해요. 불편함은 compound variant가 필요해질 때에야 드러나죠. 그때쯤에는 이미 팀마다 작은 유틸 함수를 만들어 쓰고 있을 가능성이 커요. 필요는 있었지만 각자 안에서 해결해 온 셈이에요.
둘째, cva를 가져와도 Emotion답게 쓰기는 어려워요.
cva는 class 이름 문자열을 조합하는 도구예요. 그래서 @emotion/css로 class 이름을 만들어 넘기면 Emotion과 함께 쓸 수는 있어요.
// Button.tsx
import { css } from '@emotion/css';
import { cva } from 'class-variance-authority';
const button = cva(
css({
borderRadius: 8,
}),
{
variants: {
size: {
small: css({
fontSize: 12,
}),
large: css({
fontSize: 16,
}),
},
},
},
);
function Button() {
return (
<button
className={button({
size: 'large',
})}
>
Click
</button>
);
}동작은 해요. 하지만 이렇게 쓰면 Emotion을 쓰던 방식에서 꽤 멀어져요.
cssprop을 쓸 수 없어요. cva가 돌려주는 건 class 이름이라서className으로 넘겨야 해요.cssprop으로 작성해 온 코드라면 방식을 바꿔야 하죠.@emotion/react의css()와는 함께 쓸 수 없어요. 이 함수는 class 이름이 아니라 객체를 돌려주거든요. cva에 넘겨도 에러는 나지 않고, 의미 없는 class 이름만 만들어져요.@emotion/css는@emotion/react와 다른 별도의 캐시를 써요. 그래서CacheProvider에 준 설정이 적용되지 않고, 서버 렌더링을 한다면 스타일을 따로 추출해야 해요.- 스타일이 겹칠 때 어느 쪽이 이기는지가 class를 나열한 순서가 아니라 스타일이 만들어진 순서로 정해져요.
쓸 수는 있지만, Emotion에서 cva처럼 쓴다기보다 Emotion을 cva에 맞춰 쓰게 되는 셈이에요.
셋째, 관심이 다른 곳으로 옮겨 갔어요.
variant API를 널리 알린 Stitches는 자체 스타일 엔진을 쓰는 라이브러리였어요. 그 뒤로 사람들의 관심은 vanilla-extract의 recipes, Panda CSS 같은 zero-runtime 도구와 Tailwind CSS로 옮겨 갔고요. Emotion 기반인 MUI나 Chakra UI도 variant 기능을 갖추고 있지만, 그 프레임워크 안에서만 쓸 수 있어요.
하지만 Emotion을 이미 쓰고 있는 코드베이스는 여전히 많아요. 이런 프로젝트는 스타일 도구를 통째로 바꾸기는 어렵지만, cva 같은 사용 경험은 필요하죠. Emotion으로 개발하면서 같은 아쉬움을 느낀 저도 그중 한 명이었고요.
emotion-variants로 풀기
emotion-variants에서는 같은 버튼을 이렇게 써요.
// Button.tsx
import { ev, type VariantProps } from 'emotion-variants';
const button = ev({
base: {
border: '1px solid transparent',
borderRadius: 8,
cursor: 'pointer',
},
variants: {
variant: {
filled: {
background: 'var(--button-color)',
color: 'white',
},
outlined: {
background: 'transparent',
borderColor: 'var(--button-color)',
color: 'var(--button-color)',
},
text: {
background: 'transparent',
color: 'var(--button-color)',
},
},
size: {
small: {
padding: '8px 16px',
fontSize: 12,
},
medium: {
padding: '10px 20px',
fontSize: 14,
},
large: {
padding: '12px 24px',
fontSize: 16,
},
},
color: {
primary: {
'--button-color': 'pink',
},
secondary: {
'--button-color': 'gray',
},
success: {
'--button-color': 'green',
},
error: {
'--button-color': 'red',
},
info: {
'--button-color': 'blue',
},
warning: {
'--button-color': 'orange',
},
},
},
compoundVariants: [
{
variants: {
variant: 'filled',
color: 'warning',
},
style: {
color: 'black',
},
},
],
defaultVariants: {
variant: 'filled',
size: 'medium',
color: 'primary',
},
});
type ButtonProps = VariantProps<typeof button>;
function Button({ variant, size, color }: ButtonProps) {
return <button css={button({ variant, size, color })}>Click</button>;
}흩어져 있던 세 조각이 ev() 하나에 모였어요. 이제 size에 xlarge를 추가하고 싶다면 variants.size에 값 하나만 추가하면 돼요. props 타입은 VariantProps가 알아서 따라와요.
앞에서 본 styled의 다섯 가지 문제와 나란히 놓으면 이렇게 달라져요.
styled로 직접 만들 때 | emotion-variants |
|---|---|
조건을 &&, ?:로 직접 작성 | compoundVariants에 데이터로 선언 |
| 기본값을 함수마다 반복 | defaultVariants 한 곳에 선언하고, 복합 규칙 판정에도 반영 |
| 적용 순서가 작성 순서에 의존 | base → variants → compound 순서로 고정 |
| props 타입을 손으로 작성 | VariantProps로 자동 추론 |
| 요소마다 같은 조건을 반복 | slots로 한 번에 정의 |
앞에서 본 "filled이면서 warning이거나 info일 때" 같은 조건도 배열로 간단하게 쓸 수 있어요. 조건문을 고치는 대신 값 하나를 추가하면 돼요.
compoundVariants: [
{
variants: {
variant: 'filled',
color: ['warning', 'info'],
},
style: {
color: 'black',
},
},
],만들면서 지킨 원칙
기능을 정할 때 기준으로 삼은 원칙이 네 가지 있어요.
1. cva를 써 본 사람에게 익숙할 것
애초에 cva의 사용 경험을 Emotion으로 가져오고 싶어서 시작한 일이에요. 그래서 variants, compoundVariants, defaultVariants처럼 이미 익숙한 이름을 그대로 가져왔어요. 새로 배워야 할 것이 적을수록 좋으니까요.
2. css prop에 그대로 넘길 수 있을 것
button({ variant, size, color })의 반환값은 Emotion의 css prop에 바로 넘길 수 있어요. 스타일을 정의할 때도 css()를 호출할 필요 없이 평범한 스타일 객체만 쓰면 돼요. Emotion을 쓰던 방식에서 달라지는 건 ev()로 감싼다는 점 하나뿐이에요.
3. 계산은 정의할 때 한 번만
스타일 객체를 CSS로 바꾸는 작업은 ev()를 호출해 정의하는 시점에 한 번만 해요. 컴포넌트가 렌더링될 때는 미리 만들어 둔 스타일 중에서 조건에 맞는 것을 골라 오기만 하죠. 반응형 variant만 예외예요. 호출할 때 값이 정해지는 스타일이라, 처음 쓰일 때 한 번 만들고 그 뒤로는 다시 써요.
4. Emotion 위의 얇은 레이어일 것
emotion-variants는 Emotion을 대체하지 않아요. Emotion이 스타일을 만들고 적용하는 방식은 그대로 두고, 그 위에 variant를 선언하는 방법 하나만 더해요. 따로 딸려 오는 의존성도 없어서, 필요한 건 Emotion뿐이에요.
지금의 emotion-variants
2025년 12월 말에 첫 구현을 올렸고, 2026년 1월 1일에 1.0.0을 배포했어요. 지금은 2.1 버전까지 왔고, 처음보다 할 수 있는 일이 꽤 늘었어요.
- Slots: 여러 요소로 이루어진 컴포넌트를 스타일 정의 하나로 다뤄요.
- Extend: 이미 만든 스타일 정의를 바탕으로 새 정의를 만들어요.
- 반응형 variant: breakpoint마다 다른 variant 값을 줄 수 있어요.
이 중 slots는 앞에서 이야기한 "여러 요소에 같은 조건이 반복되는 문제"를 풀어 줘요.
// Card.tsx
import { ev } from 'emotion-variants';
const card = ev({
slots: {
root: {
borderRadius: 12,
padding: 16,
},
title: {
fontWeight: 600,
},
},
variants: {
size: {
large: {
root: {
padding: 24,
},
title: {
fontSize: 20,
},
},
},
},
});
function Card() {
const { root, title } = card({
size: 'large',
});
return (
<section css={root()}>
<h3 css={title()}>Weekly report</h3>
</section>
);
}size를 한 번만 넘기면 root와 title이 함께 바뀌어요.
이런 경우에는 필요 없어요
모든 컴포넌트에 emotion-variants가 필요한 건 아니에요.
- variant가 없거나 하나뿐인 컴포넌트라면
css()나styled만으로 충분해요. - 조합 규칙이 없는 단순한 컴포넌트라면 도구를 더하는 것보다 그대로 두는 편이 읽기 쉬워요.
variant가 두 개 이상이고 조합 규칙이 생기기 시작할 때, 그때가 emotion-variants를 꺼내 볼 만한 시점이에요.
마무리
emotion-variants는 "Emotion에서도 cva처럼 쓰고 싶다"는 작은 아쉬움에서 시작했어요. 만들면서 다시 느낀 건, 불편함이 꼭 "할 수 없어서" 생기는 건 아니라는 점이었어요. styled로도 compound variant는 만들 수 있어요. 다만 매번 손으로 조건을 쓰고, 기본값을 맞추고, 타입을 챙겨야 하죠. 할 수는 있지만 매번 반복해야 하는 일, 그게 바로 도구가 필요한 자리였어요.
혹시 여러분도 Emotion으로 컴포넌트를 만들다가 스타일 객체와 타입과 조건문 사이를 오가며 지친 적이 있다면, emotion-variants를 한번 써 보세요. 사용해 보고 불편한 점이나 필요한 기능이 있다면 GitHub 이슈로 편하게 알려 주세요.