안녕하세요. 피처링 프론트엔드 파트의 리키입니다. 1부에서는 Vanilla-Extract를 도입하게된 계기와 피처링의 디자인 토큰들을 어떻게 세팅했는지를 다뤘다면, 2부에서는 Vanilla-Extract가 제공하는 api, 그 중에서도 저희가 자주 사용했던 api를 위주로 소개하고 각 api를 사용하여 실제 어떤식으로 스타일링을 하는지에 대해 다뤄보려고 합니다.
Styling API
Vanilla-Extract의 api는 sprinkles
나 일부 유틸을 제외하면 대부분 .css.ts
파일에 작성해야합니다. 이 같은 suffix가 붙은 파일을 모두 찾아, Vanilla-Extract가 컴파일 타임에 css 파일의 형태로 일괄 전처리합니다.
.css.ts
파일에서 작성되는 대부분의 스타일은 style
api를 사용하여 작성됩니다. 기본적인 형태는 React 컴포넌트에서 인라인 스타일을 적용할 때의 규칙과 매우 유사합니다. 특히 모든 스타일 속성명을 카멜케이스로 작성하는 것까지 인라인 스타일 작성방식과 동일합니다.
// style.css.ts const ellipsisStyle = style({ display: '-webkit-box', WebkitBoxOrient: 'vertical', textOverflow: 'ellipsis', })
위 코드는 아래와 같이 변환됩니다. 변수명에 스타일이 작성된 파일명, 난수가 더해진 클래스 명이 생성됩니다.
// generated CSS .style_ellipsisStyle__2b6jpb2 { display: -webkit-box; -webkit-box-orient: vertical; text-overflow: ellipsis; font-size: 14px; }
또한 작성된 스타일은 생성된 클래스를 반환하여, 아래와 같이 엘리먼트의 className
에 주입하여 사용합니다.
<p className={ellipsisStyle}>hello</p>
선택자 및 의사클래스
style
api에서 선택자 및 의사클래스를 작성하는 방법은 크게 두 가지를 지원합니다.
첫 번째로 간단한 작성방식입니다. 아래와 같이 &를 생략하고 style
api 하위에 바로 작성하는 방식을 지원합니다.
// style.css.ts export const selectorStyle = style({ ':hover': { backgroundColor: 'blue', }, ':last-of-type': { border: 0, }, '::before': { content: 'content', }, });
// generated CSS .style_selectorStyle__2b6jpb2:hover { background-color: blue; } .style_selectorStyle__2b6jpb2:last-of-type { border: 0; } .style_selectorStyle__2b6jpb2::before { content: "content"; }
간단한 작성방식으로는 작성할 수 없던 대부분의 선택자는 selector 필드 아래에 작성할 수 있습니다. 이때에는 &
선택자를 포함해야합니다.
// style.css.ts export const selectorStyle = style({ selectors: { '&:nth-child(2n)': { backgroundColor: 'green', }, '&[data-attr=true]': { backgroundColor: 'yellow', }, }, });
// generated CSS .style_selectorStyle__2b6jpb2:nth-child(2n) { background-color: green; } .style_selectorStyle__2b6jpb2[data-attr=true] { background-color: yellow; }
자식 선택자와 그외 선택자 (w/globalStyle)
style
api에서는 여러가지 선택자를 사용할 수 있도록 기능을 제공하지만 오직 &
, 즉 style
api를 통해 생성될 클래스를 대상으로 하는 선택자만을 지원합니다. 즉 아래와 같이 선택자를 작성할 수 없습니다.
// style.css.ts export const selectorStyle = style({ selectors: { '& > div': { backgroundColor: 'blue', }, }, });

이런 경우 Vanilla-Extract는 globalStyle
을 사용하는 해결방법을 제공합니다.
// style.css.ts export const selectorStyle = style([]); // 빈 스타일을 작성하는 것만으로도 클래스를 생성합니다. globalStyle(`${selectorStyle} > div`, { backgroundColor: 'blue', });
// generated CSS .style_selectorStyle__2b6jpb3 > div { background-color: blue; }
globalStyle
은 태그명 등을 지정하는 스타일링으로, reset CSS 등을 작성할 때도 용이하지만 스타일 작성 주체가 아닌 요소에 대한 선택자를 사용할 수도 있도록 지원하고 있습니다.
fallback style
아래와 같이 속성 값을 배열형태로 넘기는 것으로 fallback style을 작성하는 것을 지원합니다.
// style.css.ts export const fallbackStyle = style({ display: ['-webkit-box', 'flex'], });
// generated CSS .style_fallbackStyle__2b6jpb2 { display: -webkit-box; display: flex; }
뒤에 작성한 스타일부터 우선 적용되며, 크로스 브라우징 시 유용하게 사용할 수 있습니다.
CSS Variable
style
api에서 CSS 변수는 vars
필드 아래에 작성합니다. 이를 위해 Vanilla-Extract는 createVar
함수를 제공하고 있습니다. createVar
는 그 자체만으로 CSS를 생성하는 함수는 아닙니다. 단지 vars
하위에서 사용할 참조를 제공할 뿐입니다.
// style.css.ts export const zIndexVar = createVar(); export const container = style({ zIndex: zIndexVar, vars: { [zIndexVar]: '10', } }) export const box = style({ vars: { [zIndexVar]: '100', } })
// generated CSS .style_container__2b6jpb3 { --zIndexVar__2b6jpb2: 10; z-index: var(--zIndexVar__2b6jpb2); } .style_box__2b6jpb4 { --zIndexVar__2b6jpb2: 100; }
createVar
로 선언된 변수는 한 개의 style
api 뿐 아니라 여러 개의 style
api 내부에서 참조할 수 있고, 미디어 쿼리의 내부에서도 별도 참조가 가능합니다.
Style Composition
style
api는 기존에 style
api로 만들어진 스타일을 조합하여 새로운 스타일을 만들 수도 있습니다. 이 때는 style
api에 배열형태로 여러 개의 스타일을 전달합니다. 같은 파일 내에서 base 스타일을 생성해 공통으로 사용할 수도 있고, 앞서 소개드렸던 sprinkles
나 미리 생성해둔 다른 스타일을 import해서 조합하는 것 역시 가능합니다.
// style.css.ts const base = style({ backgroundColor: '#fff', }) const container = style([ base, { width: '100%', height: 300, } ])
Packages
Vanilla-Extract에서는 보다 편리한 스타일링을 위한 추가 패키지들을 제공합니다. 옵셔널 패키지이지만 굉장히 유용한 기능들을 포함하고 있습니다. 앞서 소개드렸던 sprinkles
또한 이 패키지 중 하나입니다.
Recipe (Recipes)
recipes 패키지에 포함된 api로 여러가지 variant를 가진 스타일을 작성하기에 유용합니다. recipe
역시 스타일은 빌드타임에 생성되지만, variant가 미리 정해진 스타일에 대해서는 일부 동적인 스타일링이 가능하도록 도와줍니다. recipe
는 style
api와 달리 함수를 반환하며, 해당 함수에서 variant를 전달받는다는 점에서 리액트 컴포넌트의 props와도 유사합니다.
style
api와 동일하게 .css.ts
파일에 작성되어야하며 recipe
의 사용 예시는 아래와 같습니다.
// style.css.ts import { recipe } from '@vanilla-extract/recipes'; export const button = recipe({ base: { borderRadius: '4px', }, variants: { size: { sm: { height: '24px' }, md: { height: '32px' }, }, hasIconOnly: { true: { padding: '0' }, false: { padding: '0 8px' }, }, }, compoundVariants: [ { variants: { size: 'sm', hasIconOnly: true, }, style: { width: '24px', }, }, { variants: { size: 'md', hasIconOnly: true, }, style: { width: '32px', }, }, ], defaultVariants: { size: 'md', hasIconOnly: false, }, });
크게 recipe
는 base
, variants
, compoundVariants
, defaultVariants
로 구성되어있습니다.
base
기본이 되는 스타일로, 어떤 variant가 변경되더라도 항상 일정하게 적용되는 스타일을 작성합니다. 바로 style
api 처럼 작성하거나, style
api로 이미 만들어진 스타일을 넣을 수도 있습니다.
const container = recipe({ base: baseStyle, // or base: sprinkles({ rounded: 'radius-100', }), // or base: { borderRadius: '4px', }, });
variants
이 스타일의 variant와 그에 따른 스타일을 정의합니다. variant는 이후 해당 스타일 리액트의 컴포넌트와 비교하면 일종의 props를 정의하는 것과 유사합니다. `variants` 아래에 원하는 variant를 필드로 두고, 해당 variant의 value가 될 수 있는 옵션과 스타일을 그 하위에 작성합니다.
가령 버튼의 사이즈 타입에 따라 각기 다른 스타일을 적용하고 싶은 경우,
const button = recipe({ base, variants: { size: { sm: { height: '24px' }, md: { height: '32px' }, }, }, });
위와 같이 작성합니다.
variants
에 작성된 값은 이후에 작성할 항목과, 해당 스타일 클래스를 사용할 컴포넌트에서도 일관되게 type-safe한 개발자 경험을 제공합니다. compoundVariants
및 defaultVariants
에서는 variants
에 작성된 값을 바탕으로 정확한 타입 추론을 제공하며, 별도의 RecipeVariants
유틸리티 타입을 사용해 해당 variants
의 타입을 컴포넌트의 props 타입 등으로 확장하는 것도 가능합니다.

// style.css.ts type ButtonVariants = RecipeVariants<typeof button>; // 위 유틸리티 타입은 아래와 같을 것이다. type ButtonVariants = { size?: 'sm' | 'md'; } // Button.tsx // 컴포넌트의 props 타입으로 확장해서 사용할 수 있다. interface ButtonProps extends ButtonVariants { {...} }
Compound Variants
compoundVariants
에는 더욱 복합적인 상황의 스타일을 만들 수 있습니다. 가령 버튼의 넓이는 고정적이지 않지만, hasIcon
이 true
일 때만 사이즈에 따라 넓이를 주어야한다고 가정한다면 아래와 같이 작성할 수 있습니다.
compoundVariants: [ { variants: { size: 'sm', hasIconOnly: true, }, style: { width: '24px', }, }, { variants: { size: 'md', hasIconOnly: true, }, style: { width: '32px', }, }, ],
defaultVariants
이 속성은 이름에서도 알 수 있듯, 과거 리액트에서 사용하던 defaultProps
와 유사한 기능을 제공합니다. 각 variants
에 값이 제공되지 않은 경우 선택될 기본 값을 정의하는 곳으로, 역시 variants
에 작성한 값의 범위 내에서 작성할 수 있습니다.
recipe
를 사용하면 styled-components에서 props를 전달받던 것과 같은 동적인 스타일링이 일부 가능해집니다. 일반적인 스타일링에는 물론 디자인 시스템과 같이 다양한 variants들을 바탕으로 추상화된 컴포넌트를 작성할 때 진가를 발휘합니다.

물론 진짜 동적인 스타일링은 아닙니다. 이 역시 빌드타임에 모든 variants
들에 따른 스타일을 하나하나 미리 생성하기에 가능한 것입니다. 하지만 사전에 전혀 정의되지 않은 값을 진짜 동적으로 처리하려면 어떻게 해야할까요.
AssignInlineVars (Dynamic)
동적인 스타일링이 필요한 경우에는 dynamic 패키지에서 제공하는 assignInlineVars
를 사용하면 런타임에 CSS Variable을 할당할 수 있습니다. 런타임이라곤 하지만 번들크기는 1.4kb로 매우 작고 따라서 실질적인 성능 부담도 거의 없습니다. 이 덕분에 컴파일 타임에 정적인 CSS를 생성하는 Vanilla-Extract로도 동적인 UI 요구사항에 유연하게 대응할 수 있습니다.
assignInlineVars
를 사용하면 앞서 살펴봤던 createVar
와 create(Global)ThemeContract
로 만들었던 참조를 통해 CSS Variable의 값을 재할당 할 수 있습니다.
createVar
createVar
로 생성한 변수는 style api에서만 사용하는 것이 아니라, 컴포넌트 내부의 동적 스타일링에도 활용할 수 있습니다. 특정 엘리먼트의 style
속성에서 assignInlineVars
를 사용하면, 이미 스타일에 선언된 CSS 변수에 런타임에서 새로운 값을 동적으로 할당할 수 있습니다.
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { modalContainer, zIndexVar } from './style.css'; const Modal = ({ zIndex }: Props) => { return ( <div className={modalContainer} style={assignInlineVars({ [zIndexVar]: zIndex?.toString(), // 반드시 string으로 작성. })} > {...} </div> ); };
CreateGlobalThemeContract
이전 포스트의 Theming에서 다루었던 ThemeContract
역시 assignInlineVars
를 사용해 간단히 값을 재할당 할 수 있습니다. 특히 이 기능을 사용하면 프로덕트마다 기본적인 디자인 토큰의 정의는 동일하게 가져가되, 각 프로덕트만의 메인이나 포인트 컬러만 고유한 값을 할당할 수 있습니다.
// _document.tsx <body style={assignInlineVars(vars, { global: { colors: { primary: { // 해당 프로덕트의 메인 컬러셋 10: '#edf5ff', 20: '#d0e2ff', 30: '#a6c8ff', {...} }, }, }, })} >
피처링에서는 어떻게 사용할까.
피처링 프론트엔드 팀에서는 앞서 소개드렸던 style composition 기능을 적극활용하고 있습니다. 자주 사용하는 스타일들은 sprinkles
, styleVariants
, recipe
등을 활용해서 미리 유틸리티화해두고, 각 스타일을 조합하여 새로운 스타일을 만들어내는 방식으로 사용하고 있습니다.
flex
모든 스타일링 시 가장 많이 사용하는 flex 기능을 recipe
를 사용해 유틸리티 함수화 했습니다.style
api에서 뿐 아니라, 간단히 컴포넌트에서도 바로 사용할 수 있습니다.
export const container = style([ flex({ direction: 'column', justify: 'center', align: 'stretch', gap: 'spacing-200', // theming을 통해 정의된 디자인 토큰 }), { width: '500px', }, ]);
typoVariant
처음 디자인 토큰을 정의할 때, 타이포그래피는 단순히 토큰으로만 표현하기에도, 전혀 다르게 다루기에도 애매한 영역이었습니다. 그래서 각 타이포그래피에 필요한 속성값들을 우선 디자인 토큰으로 세분화해 정의한 뒤, styleVariants
로 묶어 실제 사용이 간편하도록 구성했습니다. 그 결과, 피그마 시안에서 보던 모습과 가까운 형태로 일관된 타이포그래피 스타일을 코드에서 쉽게 재현할 수 있게 되었습니다.
style 사용예시
지금까지 소개한 내용을 모두 종합한 사용예시는 아래와 같습니다.
const widthVar = createVar(); export const container = style([ // style composition 활용. // 1. sprinkles - 디자인토큰이 사용된 부분은 가능하다면 sprinkles 사용. sprinkles({ bgColor: 'background-1', rounded: 'radius-100', paddingX: 'spacing-200', color: 'text-2', }), // 2. 유틸화된 스타일 flex({ direction: 'column', justify: 'center', align: 'stretch', gap: 'spacing-200', }), typoVariant.body[1], // 3. 해당 클래스의 신규 스타일 { // 동적 스타일은 CSS Variable로 처리. width: widthVar, // sprinkles로 처리할 수 없는 디자인 토큰은 vars 사용. border: `1px solid ${vars.semantic.color.border.default}`, vars: { [widthVar]: '1200px', } }, ]);
외부에서 스타일 주입받기
추상화된 컴포넌트를 만들다보면 확장 가능성을 고려해, 외부에서 스타일을 주입받도록 하는 경우가 있습니다. 이런 케이스에서는 clsx를 사용하여 클래스를 병합할 수 있었습니다.
import { clsx } from 'clsx'; import * as styles from './Button.css'; interface ButtonProps { className?: string; children: React.ReactNode; } export const Button = ({ className, children }: ButtonProps) => { return ( <button className={clsx(styles.base, className)}> {children} </button> ); };
아주 단순하게 스타일을 병합할 수 있게 되었습니다.
하지만 Vanilla-Extract와 clsx를 사용한 클래스 병합에는 명확한 한계점이 있습니다. clsx는 단순히 클래스 문자열을 병합해주는 유틸리티일 뿐, CSS 명시성(Specificity)까지 보장하지는 않습니다. 이로 인해 아래와 같은 문제가 발생할 수 있습니다.
-
clsx에서의 클래스의 순서는 해당 스타일의 명시성까지 보장하지 않습니다. 따라서 별도의 명시성의 구분이 없다면 CSS 파일 상에서 나중에 선언된 스타일의 우선순위가 더 높습니다.
-
즉, 두 개 이상의 클래스 병합에서 사용자의 의도대로 스타일의 우선순위가 정해지지 않을 수 있습니다. 여러 클래스에서 같은 속성의 스타일을 작성했다면 어떤 쪽의 명시성이 높을지는 오로지 빌드된 CSS파일에 달린 일이이 때문입니다.
.css.ts
를 벗어난 파일에서의 스타일 병합을 지원하지 않고, 따라서 .css.ts
를 벗어난 곳에서는 스타일의 우선순위를 조정 할 수 없다는 것은 Vanilla-Extract의 한계로 느껴졌습니다.
기존에 사용하던 styled-components와는 여러모로 많이 다른 패키지였습니다. 그만큼 일반 CSS와는 꽤나 다른 사용성을 보이는 스타일 라이브러리였습니다. 사용자에 따라 앞서 언급한 한계점이나, .css.ts
에서만 스타일을 작성할 수 있도록 하는 강제성도 이 패키지를 불호하게 만드는 요소일지도 모릅니다. 하지만 기능 전반에 강력한 타입 추론를 제공하며 디자인 시스템의 약속된 토큰을 세팅하기에도 정말 용이했으며, sprinkles
와 같은 도구를 제공하는 것까지 포함해 개인적으로는 굉장히 긍정직인 DX를 경험했다고 느꼈고 만족하며 사용하고 있습니다.
기존에 사용하던 styled-components와 비교했을 때, vanilla-extract는 여러 면에서 확연히 다른 접근 방식을 가진 스타일 라이브러리였습니다. 정적인 형태의 스타일을 컴파일 타임에 생성한다는 특성 때문에 알고있던 CSS-in-JS의 유연함보다는, 더 정형화되고 명시적인 스타일 설계를 지향하고 있었습니다.
.css.ts
에서만 스타일을 정의해야 한다는 구조적 제약이나, class 병합에서 발생할 수 있는 우선순위 이슈 등은 사용자의 취향과 프로젝트 특성에 따라 불편하게 느껴질 수 있다고 생각합니다. 따라서 모든 상황에서 정답이라고 할 수는 없겠습니다. 하지만 개인적으로는 타입 기반의 강력한 추론, 디자인 토큰의 체계적인 적용, 그리고 sprinkles
를 통한 유틸리티 스타일링 등 전반적으로 매우 긍정적인 개발 경험을 할 수 있었고, 만족했던 스타일 라이브러리였습니다.
저희와 비슷한 고민을 하고 있다면 한 번쯤 도입을 고민하거나 경험해볼 만한 가치가 충분하다고 생각합니다.
참고