vanilla extract - 3

dante Yoon·2022년 10월 14일
6

vanilla-extract

목록 보기
3/3
post-thumbnail

영상으로 보기!

https://www.youtube.com/watch?v=sjHBF8giNbc

글을 쓰기에 앞서서!

안녕하세요, 단테입니다.
오늘은 vanilla extract의 세번째 시간입니다.
지난 포스팅에서는 vanilla extract의 기본적인 api인 style을 사용해 클래스 이름을 만들고 스타일을 입히는 방법, 미디어 쿼리를사용하는방법, 테마를 이용해 정적/동적으로 스타일을 생성하는 방법에 대해 알아보았습니다.

오늘은 실제 vanilla extract를 사용해 런타임에서 테마를 주입할 수 있는 기능을 가진 컴포넌트를 만들어보겠습니다.

그리고 컴파일 타임 생성 / 런타임 생성으로 나뉘어 두 가지 방법 모두 적용하려고 할 때 어떻게 접근해야 하는지 알아보겠습니다.

컴파일 타임 빌드

css variables 생성

우리가 사용할 css variables 를 생성하고 :root 아래에 각 variables가 나타내는 스타일을 정의하겠습니다. 우리는 컴포넌트에 스타일링을 할 수도 있지만 css variables를 사용하여 스타일링 하는 것을 우선순위로 삼겠습니다.

import {
  createGlobalThemeContract,
} from '@vanilla-extract/css';

export const vars = createGlobalThemeContract({
  color: {
    accentColor: '',
    accentColorForeground: '',
    actionButtonBorder: '',
    actionButtonBorderMobile: '',
    actionButtonSecondaryBackground: '',
  },
  font: {
    body: ''
  }
});

먼저 createGlobalThemeContract를 통해 theme contract에서 사용할 color와 font를 정의했습니다 vars를 출력해보면 다음과 같습니다.

{
  colors: {
    accentColor: 'var(--colors-accentColor)',
    accentColorForeground: 'var(--colors-accentColorForeground)',
    actionButtonBorder: 'var(--colors-actionButtonBorder)',
    actionButtonBorderMobile: 'var(--colors-actionButtonBorderMobile)',
    actionButtonSecondaryBackground: 'var(--colors-actionButtonSecondaryBackground)',
    closeButton: 'var(--colors-closeButton)',
  },
  font: {
    body: 'var(--font-body)'
  }
}

앞서 선언한 theme contract는 document 아래에서 사용하는 모든 css variables를 대상으로 스타일을 입힐 것이므로

다음과 같이 :root psuedo class 아래 css variables실제 스타일을 매핑해주빈다.

이 때 사용할 vanilla extract의 api는 createGlobalTheme 입니다.

... 

import {
  createGlobalTheme,
} from '@vanilla-extract/css';

createGlobalTheme(':root', vars, {
  color: {
    accentColor: '#0E76FD', 
    accentColorForeground: '#FFF',
    actionButtonBorder: 'rgba(0, 0, 0, 0.04)',
    actionButtonBorderMobile: 'rgba(0, 0, 0, 0.06)',
    actionButtonSecondaryBackground: 'rgba(0, 0, 0, 0.06)',
    closeButton: 'rgba(61, 67, 67, 0.6)',
  },
  font: {
    body: 'roboto'
  }
});

앞서 정의했던 theme contract인 vars 를 두번째 인자에 넣고 실제 css 스타일 값을 세번째 인자에 넣었습니다.

이는 컴파일 타임 때 번들링된 css 파일에서 다음과 같이 생성 되어집니다.

:root {
  --colors-accentColor: #0E76FD;
  --colors-accentColorForeground: #FFF;
  --colors-actionButtonBorder: rgba(0, 0, 0, 0.04);
  --colors-actionButtonBorderMobile: rgba(0, 0, 0, 0.06);
  --colors-actionButtonSecondaryBackground: rgba(0, 0, 0, 0.06);
  --colors-closeButton: rgba(61, 67, 67, 0.6);
  --font-body: roboto;
}

이제 사용할 일만 남았습니다.
css variables를 어떻게 사용할 수 있을까요?

간단합니다. style api를 사용해서 클래스를 생성해주면 됩니다.

export const brandText = style({
  color: vars.color.accentColor,
  fontFamily: vars.font.body,
});

위 코드는 빌드타임 때 아래처럼 변환됩니다.

.uerampa4j {
  color: var(--colors-accentColor);
  fontFamily: var(--font-body);
}

다크 테마 / 라이트 테마

앞서서 createGlobalTheme를 사용해 :root psuedo class 아래의 css variables에 대한 스타일 매핑을 만들었습니다.

다크 테마 / 라이트 테마와 같이 특정 테마에 대한 글로벌 스타일을 빌드 타임 때 만드려면 어떻게 해야 할까요?

:root 말고 다른 selector를 사용하면 됩니다.

createGlobalTheme('[data-mode="light"]', vars, makeColorScheme("light"));
createGlobalTheme('[data-mode="dark"]', vars, makeColorScheme('dark'));

이렇게 하면 빌드타임 때 번들링된 css 파일에 아래와 같이 생성됩니다.

[data-mode="light"] {
  --colors-accentColor: #0E76FD;
  --colors-accentColorForeground: #FFF;
  --colors-actionButtonBorder: rgba(0, 0, 0, 0.04);
  --colors-actionButtonBorderMobile: rgba(0, 0, 0, 0.06);
  --colors-actionButtonSecondaryBackground: rgba(0, 0, 0, 0.06);
  --colors-closeButton: rgba(61, 67, 67, 0.6);
  --font-body: roboto;
}

[data-mode="dark"] {
  ...
}

모든 스타일 코드는 빌드타임에 만들어 두고 런타임에 color scheme를 판단해 다크모드나 라이트 모드에 따라 적절한 스타일을 사용할 수 있을 것 같습니다.

좋습니다. 이제 프로덕션 레벨로 넘어가서 디자인 시스템을 제공한다고 가정합시다.

앞서 만든 vars, 즉 global theme contract를 이용해 css variables를 제로 런타임으로 만들었습니다. 근데 이러한 스타일을 라이브러리로 제공한다고 했을 때 다른 스타일 라이브러리의 css variables와 중복될 수 있습니다. 또한 라이브러리 고유의 토큰을 제공하고 싶습니다. 이 때 css variables의 이름을 수정할 수 있습니다.

export const themeVars = createGlobalThemeContract(vars, (_, path) =>
  "dante".concat(path.join('-')),
)

이렇게 하면 다음처럼 만들어집니다.

:root {
  --dante-colors-accentColor: #0E76FD;
  --dante-colors-accentColorForeground: #FFF;
  ...
}

정말 깔끔하지 않나요?

런타임 번들을 이야기 전에 sprinkles 패키지에 대해 이야기하겠습니다.

Sprinkles

sprinkles는 런타임/컴파일 타임 상관 없이 모두 사용 가능합니다.
.css.ts에서 작성하는 모든 스타일 코드들은 컴파일 타임에서 빌드되는데요, 그래서 아래에서 쓰이는 sprinkles는 컴파일 타임에 css로 변환됩니다.

import { sprinkles } from './sprinkles.css.ts';

export const container = sprinkles({
  display: 'flex',
  paddingX: 'small',
  // Conditional sprinkles:
  flexDirection: {
    mobile: 'column',
    desktop: 'row'
  },
  background: {
    lightMode: 'blue-50',
    darkMode: 'gray-700'
  }
});
// created by sprinkles:  _1itcdg50 uerampa4j uerampbcz ueramp6b uerampcl7 uerampfnd uerampe37

마치 style api를 사용했을 때 처럼 변환됩니다.

.sprinkles_display_none_mobile__i8ksq0 {
  display: none;
}
.sprinkles_display_flex_mobile__i8ksq3 {
  display: flex;
}
.sprinkles_display_block_mobile__i8ksq6 {
  display: block;
}
.sprinkles_display_inline_mobile__i8ksq9 {
  display: inline;
}
.sprinkles_flexDirection_row_mobile__i8ksqc {
  flex-direction: row;
}

실제로 내부적으로 style api를 사용하고 클래스 리스트를 반환하기 때문에 selector로도 사용할 수 있습니다.

export const container = sprinkles({
  padding: 'small'
});

globalStyle(`${container} *`, {
  boxSizing: 'border-box'
});

css.ts에서 작성하지 않은 스타일들은 런타임에 빌드됩니다.

import { sprinkles } from './sprinkles.css.ts';

const flexDirection =
  Math.random() > 0.5 ? 'column' : 'row';

document.write(`
  <section class="${sprinkles({
    display: 'flex',
    flexDirection
  })}">
    ...
  </section>
`);

sprinkles 사용이 좋은 developer experience를 주는 것은 스타일 선언을 축약해서 해줄 수 있고 px, rem과 같은 unit이 필요한 문구들도 원하는 형식으로 바꾸어 선언할 수 있게 해주기 때문인데요, 그러면서 완벽한 타입 추론까지 지원합니다.

자 먼저 defineProperties api를 사용해 원하는 속성 값을 정의해봅시다.

defineProperties

이름부터 되게 직관적인 이 api는 properties, conditions, shorthands 라고 하는 키 값을 받습니다. 한번 보겠습니다.


const space = {
  none: 0,
  small: '4px',
  medium: '8px',
  large: '16px'
};

const colors = {
  blue50: '#eff6ff',
  blue100: '#dbeafe',
  blue200: '#bfdbfe'
  // etc.
};

일단 디자인 시스템에서 정의했거나 미리 적어놓고 사용하고 싶은 스타일을 object literal 형식으로 만들어 놓았습니다. space는 마진이나 패딩 값을 4px 단위로 최대 16px까지만 설정할 수 있게 하고 싶습니다.

그리고 반응형 디자인을 위해 responsiveProperties를 선언하겠습니다.

const responsiveProperties = defineProperties({
  conditions: {
    mobile: {},
    tablet: { '@media': 'screen and (min-width: 768px)' },
    desktop: { '@media': 'screen and (min-width: 1024px)' }
  },
  defaultCondition: 'mobile',
  properties: {
    display: ['none', 'block', 'flex'],
    flexDirection: ['row', 'column'],
    padding: space
    // etc.
  },
  shorthands: {
    padding: [
      'paddingTop',
      'paddingBottom',
      'paddingLeft',
      'paddingRight'
    ],
    paddingX: ['paddingLeft', 'paddingRight'],
    paddingY: ['paddingTop', 'paddingBottom']
  }
});

properties에 padding에는 space를 넣었는데요, 이 것은 sprinkles를 사용할 때 사용할 수 있는 padding의 값으로 0, 4, 8, 16px만 가능하게 한다는 말입니다.

즉 sprinkles에서 사용할 수 있는 display, flexDirection, padding의 값을 일부 값으로 제한한 것입니다.

또한 conditions라는 키 값이 있는데요, mobile, tablet, desktop일 경우 각각 몇 breakPoint를 기준으로 미디어쿼리를 적용할지에 대해 선언했습니다. 이 condition이 나중에 실제로 어떻게 쓰이는지 한번 눈여겨 보세요.

shorthands는 축약어를 선언하는 것입니다. paddingX의 경우 좌우 패딩, paddingY의 경우 패딩 상하를 변경시키기 위해 정의해두었습니다.

또 다른 예시로 type scale을 선언해봅시다. fontSize에 따라서 폰트 크기 뿐만이 아니라 lineHeight도 함께 변경 시키고 싶습니다.

const typeScaleProperties = defineProperties({
  fontSize: {
    '12': { fontSize: '12px', lineHeight: '18px' },
    '13': { fontSize: '13px', lineHeight: '18px' },
    '14': { fontSize: '14px', lineHeight: '18px' },
    '16': { fontSize: '16px', lineHeight: '20px' },
    '18': { fontSize: '18px', lineHeight: '24px' },
    '20': { fontSize: '20px', lineHeight: '24px' },
    '23': { fontSize: '23px', lineHeight: '29px' },
  },
  fontWeight: {
    regular: '400',
    medium: '500',
    semibold: '600',
    bold: '700',
    heavy: '800',
  },
})

이제 만들어 둔 defineProperties를 한데 모아서 sprinkles를 만들어줍니다.

export const sprinkles = createSprinkles(
  colorProperties,
  responsiveProperties,
  typeScaleProperties,
);

이렇게 만든 sprinkles들은 각 컴포넌트의 스타일을 담당하는 ComponentName.css.ts에서 사용하여 컴파일 타임에 스타일이 선언된 클래스 이름들로 변경해줄 수 있습니다.

// ComponentName.css.ts
export const content = style([
  sprinkles({
    display: 'flex',
    flexDirection: 'column',
    fontSize: '23',
  }),
  {
    animation: `${slideUp} 350ms cubic-bezier(.16,1.16,0.7,1.25), ${fadeIn} 150ms ease`,
    maxWidth: '100vw',
  },
]);

앞서 properties에서 선언해두었던 값들을 sprinkles 내부에서 쓸 수 있는데요,
style api 내부에서 배열로 sprinkles와, 그리고 react의 style props와 같은 평범한 style object를 혼용해서 사용할 수도 있습니다.

만약에 fontSize에 23이 아니라 24를 넣으면 어떻게 될까요? 타입을 추론해줘서 잘못된 값이 들어가지 못하게 방지해줍니다.

shorthands의 일종이었던 paddingX도 잘못된 단위를 넣었을 때 이렇게 표기해줍니다.

앞서서 responsiveProperties에 선언했던 conditions 덕분에 미디어 쿼리를 명시적으로 작성해주지 않아도 됩니다.

sprinkles({
  display: {
    mobile: "none",
    desktop: "block"
  }
})

프로젝트 내부에서 사용하는 space 단위가 px로 고정이 되어있고 최대 200px 까지 쓸 수 있다면 다음처럼 로직을 작성해도 됩니다.

const createSpaceDict = (length: number = 200): SpaceDict => {
  return [...Array(length)].reduce((acc, _, index) => {
    acc[`p${index}`] = `${index}px`
    return acc
  }, {} as Record<SpaceKey, SpaceValue>)
}

const space = createSpaceDict()
...

sprinkles({
  width: "u48",
})

이렇게 스타일을 작성할 때 한 sprinkles 함수 내부에서 조건문, 축약어를 활용할 수 있기 때문에 hover color와 active color가 다르다거나 하는 등 여러 줄에 나눠서 작성해야 하는 코드들을 쉽게 한 객체 안에 선언하여 사용할 수 있습니다.

런타임 번들

앞서서 컴파일 타임에 sprinkles를 만들 때는 definedProperties에 주어지는 모든 속성 값들에 스타일을 매핑했는데요, 테마에 따라서 다른 값을 매핑해야 하는 경우도 있습니다. 유저가 직접 커스텀 테마를 넣어야 하는 경우도 있죠.

이 경우 @vanilla-extract/dynamic에서 제공하는 assignInlineVars api를 사용하면 됩니다.

앞서 봤던 createGlobalThemeContract에서 생성된 theme contract와 이 contract에 매핑할 style을 런타임 시점에 주입하는 것입니다.

런타임에 스타일을 주입함에도 css-in-js에서 사용하는 ThemeProvider 같은 context 기반의 api를 사용할 필요없이 vanilla extract에서는 스타일링을 css variables 기반으로 하기 때문에 그저 style 태그 안에만 넣어주면 됩니다.

<div>
  <style
    dangerouslySetInnerHTML={{
                         __html: `[${selectorId}]{
                           ${cssStringFromTheme(theme)}
                         }`,
    }}
  />
</div>

글을 마치며

오늘은 vanilla extract를 사용해서 테마를 런타임에 주입하는 방법, 빌드 타임에 css variables를 생성하는 방법, sprinkles와 같은 패키지를 사용하며 개발자 친화적인 경험을 간접적으로 느껴봤습니다.

매우 간단하면서도 직관적이고, 타입 추론도 가능하며 css-in-js와 다르게 런타임에서 스타일을 생성하지도 않고 코드 스플리팅도 지원합니다.

아주 개인적으로 만족하면서 사용했던 스타일 라이브러리였습니다.

긴 글 읽어주셔서 감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

1개의 댓글

comment-user-thumbnail
2023년 10월 18일

createSpaceDict 부분에서 "u48" 이 부분 "p48" 인가요?

답글 달기