vanilla extract css 리뷰 & 팁

eaasurmind·2023년 2월 17일
10

TIPS

목록 보기
10/12

선택하게 된 배경

a. stitches가 DX면에서 최고라고 판단하였지만 신규 프로젝트에 도입하기에는 maintainer들의 활동이 활발하지 않았다.

b. 신규 프로젝트의 계획을 누구한테나 물어봐도 '와 무겁겠다'라고 말할만한 사이즈의 앱이어서 속도와 경량화에 초점을 두었다. 데이터 시각화인만큼 테이블과 같은 것들이 런타임에 스타일링이 바뀌는 것을 원하지 않았다.

c. 개발 속도를 고려하면 tailwind가 있었지만 유지보수 측면과 코드 가독성면에서 더 높은 점수를 주게되었다. 원래 scss를 선호했던 영향도 큰 것 같다.

d. css를 타입스크립트 환경에서 적는다는 것은 정말 매력적인 요소였다. scss의 단점이었던 부분을 자동 완성이나 타입 지정으로 인해 개발 시간을 상당수 줄여주었다.

e. 다운로드 수가 스타일드 컴포넌트처럼 많지는 못하지만 사용처가 쇼피파이였고 꽤나 활발한 깃 활동 이력이 보였다.

tmi

vanilla extract는 shopify에서 사용중인 스택이며 만든 사람이 remix run (shopify)에 속한 Mark Dalgleish. Module css와 닮아있는 이유는 이 분이 CSS Modules의 co-creator이기 때문이다.

리뷰

Shopify polaris 팀에서 리서치한 자료를 토대로 하나씩 짚어보면

vanilla-extract
Modern solution with great TypeScript integration and no runtime overhead. It's pretty minimal in its features, straightforward and opinionated. Everything is processed at compile time, and it generates static CSS files, similar to CSS Modules, Linaria, or Astroturf.

Pros

TypeScript support
Atomic CSS support
✅ Context-aware code completion
✅ Built-in theming support
✅ Framework agnostic
✅ Styling via className
Variant support
✅ .css style extraction
Once set up easy to extend
Design language
Mapped to utility classes
✅ Actually zero run time

Cons

🟠 Forbids nested arbitrary selectors (ie: & > span)
🟠 No styles/component co-location (placed in external .css.ts file)
Familiar to Shopifolk working in admin
❌ No styling via styled component
❌ No support for as or css prop
❌ style tag injection
❌ Multiple dependencies for vanilla-extract, sprinkles etc. (too many layer to the 🍰)
❌ The Box component (dessert-box) is opinionated
❌ Lacking documentation (+1)
❌ Layers of pain?

수 개월간 익히고 응용해본 결과 모든 항목들이 공감이 갔으나 그 중에서도 할 말이 많은 부분들을 굵게 표시해두었다.

TypeScript support & Atomic CSS support

타입 safe와 sprinkles를 이용한 theming, atomic css지원등이 vanilla extract가 zero runtime이외에도 내세우는 핵심 feature들입니다.

vanilla extract를 고려한다면 우리가 타입스크립트를 고려하는 이유와도 같습니다. scss를 이용하면서 자동완성, 철자검사등이 없어 고생했던 기억이있는데 타입 지정으로 이러한 문제들이 해결해서 파일 전환을 할 일이 줄어 좋았습니다.

sprinkles에 객체를 정의해 atomic css를 구현할 수도 있는데 여기서 좋은점은 자주 쓰이는 것들만 정의해두고 나머지는 일반 스타일링을 하는 합성의 형태가 가능하다는 점입니다.

Variant support

  • 디자인 시스템을 만들 때 variant 기능을 한 번 맛보면 헤어나올 수 없다.
    다만 vanilla extract의 variant는 stitches에 비해 부족한 점이 너무 많아 그 부분에 대해 이야기하고 싶다.

variant기능을 사용하기 위해서는 recipe라는 애드온 라이브러리를 받아줘야합니다.

아래는 공식문서에 소개된 기본적인 쓰임입니다.

Button.css.ts

export const button = recipe({
  base: {
    borderRadius: 6
  },
  variants: {
    color: {
      neutral: { background: 'whitesmoke' },
      brand: { background: 'blueviolet' },
    },
    size: {
      small: { padding: 12 },
      medium: { padding: 16 },
      large: { padding: 24 }
    },
    rounded: {
      true: { borderRadius: 999 }
    }
  },
  defaultVariants: {
    color: 'neutral',
    size: 'medium'
  }
});
import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';

import { vars } from '@/styles/vars.css';

const base = {
  fontWeight: vars.fontWeight.bold,
  transition: 'all 0.3s ease',
  border: 'none',
  cursor: 'pointer',
  selectors: {
    '&:disabled': {
      opacity: '0.64',
    },
  },
};

const variants = {
  color: {
    primary: {
      backgroundColor: vars.color.primary_btn,
      color: vars.color.white,
      selectors: {
        '&:active': {
          backgroundColor: vars.color.primary_pressed,
        },
        '&:hover': {
          backgroundColor: vars.color.primary_pressed,
        },
      },
    },
   ...
  },
  sizes: {
    sm: {
      padding: '10px 18px',
      fontSize: vars.fontSize.body1,
      borderRadius: vars.borderRadius.sm,
    },
    borderRadius: vars.borderRadius.lg,
    md: {
      padding: '13px 21px',
      fontSize: vars.fontSize.body1,
      borderRadius: vars.borderRadius.md,
    },
   ...
  },
  radii: {
    rect: { borderRadius: '12px' },
    round: { borderRadius: '30px' },
  },
  outlined: {
   ...
  },
  types: {
    badge: {
      color: vars.color.primary_text,
      backgroundColor: vars.color.gray2,
      padding: '10px 16px',
      borderRadius: '17px',
      fontWeight: '600',
      selectors: {
        '&:active': {
          backgroundColor: vars.color.tertiary_pressed,
        },
        '&:hover': {
          backgroundColor: vars.color.tertiary_pressed,
        },
      },
    },
    loader: {
      padding: '0px',
    },
    default: {
      backgroundColor: 'transparent',
      color: vars.color.secondary_text,
      fontSize: vars.fontSize.body2,
      padding: '0px',
      border: 'none',
      
      ...
    },
  },
};

export const button = recipe({
  base: {
    ...base,
    textAlign: 'center',
  },
  variants: variants,
  defaultVariants: {
    color: 'primary',
    radii: 'rect',
    sizes: 'md',
  },
});

export const loading = style({});
export const childSvgStyle = style({
  selectors: {
    [`${loading} &`]: {},
  },
});

export type ButtonVariants = RecipeVariants<typeof button>;
export interface ButtonVariantProps {
  color?: keyof typeof variants.color; //"primary" | "secondary" | "tertiary" | undefined
  sizes?: keyof typeof variants.sizes;
  radii?: keyof typeof variants.radii;
  outlined?: keyof typeof variants.outlined;
  types?: keyof typeof variants.types;
}

Button/index.tsx

import React, { forwardRef, HTMLAttributes, ReactNode, Ref } from 'react';

import { cx } from '@/styles/classNames';

import Loader from '../Lottie/Loader';
import {
  button,
  ButtonVariantProps,
  childSvgStyle,
  loading,
} from './Button.css';

interface ButtonProps extends HTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
  className?: string;
  disabled?: boolean;
  type?: 'button' | 'submit' | 'reset';
  isLoading?: boolean;
}

const Button = forwardRef(
  (
    {
      children,
      className,
      color,
      sizes,
      ...
    }: ButtonProps & ButtonVariantProps,
    fowardRef,
  ) => {
    return (
      <button
        {...rest}
        type={type}
        disabled={disabled}
        ref={fowardRef as Ref<HTMLButtonElement> | undefined}
        className={cx(
          button({
            color,
            sizes,
            radii,
            outlined,
           ...
          }),
          className,
          isLoading ? loading : '',
        )}
      >
        {children}
      </button>
    );
  },
);

export default Button;

아쉬운 점은 recipe로 말아도 결국 사용할때 객체를 파서 사전에 정의한 variant를 불러오는 느낌인데 stitches보다 타이핑량도 많고 복잡함이 있습니다. 특정한 경우에는 아래와 같이 recipe를 바로 불러오지만 자주 쓰인다면 위와 같이 컴포넌트에서 정의한다음 사용처에서는 props로 받아오는 형태로 사용합니다.

variants를 정의할 때의 팁

a. base 객체랑 variants객체를 따로 상단에서 정의합니다
b. css파일, 컴포넌트 양단에서 variant의 타입을 지정하는 것이 아닌
css에서 variants의 타입을 정의하고 컴포넌트에서 &로 엮어서 타입을 받아줍니다.

<div className={styles.something({color: 'brandColor'})} />

Once set up easy to extend

사용하면서 가장 마음에 들었던 점은 vanilla extract가 제공하는 유연하고 무궁무진한 응용방식입니다.

css를 js에서 사용할 수 있으면 정말 좋겠다

라는 생각에서 나온 css in js와 달리 정말 css 자체를 js파일로 import하는 느낌이 들어 다양한 활용방식이 가능하면서도 코드 흐름을 해치지 않습니다.
Styling via className이 이 부분에 해당합니다.

즉 컴포넌트를 읽을 때 스타일 코드는 className안에만 존재하기 때문에 가독성면에서 우수합니다.

나름의 보일러 플레이트 제작 팁

자주 사용하는 코드는 유틸 함수를 만드는 것처럼 스타일도 전역적으로 사용이 가능합니다.

//common.css.ts

export const flex = recipe({
  base: {
  	display: 'flex'
  },
 variants: {
 	justify: {
      center: {
         justifyContent: 'center'
       }
       ...
    }
    ...
 }
})
//box.css.ts

export const box = style([flex({justify: 'center'}),{
...
}])

기본적인 사용은 이런식으로 자주 사용하게 될 스타일을 정의하고 합성하여 사용하는 방법.
스타일뿐만 아니라 자주 사용하는 애니메이션들을 정의해놓아도 편합니다.

물론 컴포넌트단에서도 사용이 가능합니다.

//index.tsx
import { flex } from ...
or import * as utils from ...

<div className={cx(styles.something, utils.flex())} />

반응형 또한 타입핑이 다른 css in js에 비해 많아서 시범적으로 함수형태로 사용하고 있습니다. 미리 약속된 프로퍼티에 한해서는 sprinkles로 반응형을 구현해도 무방합니다.

// responsive.css.ts
import { StyleRule } from '@vanilla-extract/css';

//style function for breakpoints
export const bpStyle = (
  bp: 'mobile' | 'tablet' | 'desktop',
  rule: StyleRule,
) => {
  let breakpoint;
  switch (bp) {
    case 'mobile':
      breakpoint = '320px';
      break;
    case 'tablet':
      breakpoint = '768px';
      break;
    case 'desktop':
      breakpoint = '1024px';
      break;
  }

  return {
    '@media': {
      [`screen and (min-width: ${breakpoint})`]: rule,
    },
  };
};

const something = style([
  bpStyle('mobile', {color: 'red'},
  {
  ...
  }])

위 항목들에서 주목할 점은 css를 정의하는 파일도 ts이기 때문에 사실상 css in ts이지만 형태가 컴포넌트와 분리되어있는 형태라는 점입니다.

또한 배열 형태로 스타일링 합성을 하는 기능은 제가 생각하기에 또 하나의 강력한 기능중에 하나입니다.

이런식으로 많은 것들을 하나 둘 정의해놓으면 나중에 점점 스타일링이 편해지는 것을 느낄 수 있었습니다.

Cons

Forbids nested arbitrary selectors (ie: & > span)

가장 큰 문제점입니다.
네스팅이 css와 달리 자식에서 부모를 지정해줘야합니다. 스타일링은 해당 엘리먼트에 관련된 것만 정의가 되어야합니다. 따라서 부모에서 불특정한 자식 스타일링은 불가하고 자식 스타일에서 부모를 조회하는 형식을 택해야합니다.

평상시에는 큰 문제가 되지 않습니다.

const parentStyle = style({})

const childStyle = style({
  [`${parentStyle} &`]: {
  	color: 'red'
  }
})

정말 문제는 라이브러리를 커스텀 스타일하고 싶을 때 입니다.

<Library />
// 실제로 안에 다음과 같은 요소가 담겨있다고 가정해봅시다

<div className='library'>
  <span className='library_span'>hello</span>
</div>

여기서 vanilla extract의 style으로는 span을 스타일링 해주는 것이 어렵습니다..
어쩔 수 없이 자식이 선택 가능한 globalStyle로 정의를 해주어야 합니다. 혹은 인접 형제 선택자 + 클래스 선택자로 지정하는 방법도 있습니다.

globalStyle('.library_span', {
  backgroundColor: `${vars.color.white}`,
});

No styles/component co-location (placed in external .css.ts file)

이 부분은 오히려 저에게는 장점으로 다가왔습니다. 스타일링이 같은 파일 내부에 존재하는 것이 코드 가독성을 해친다고 생각했습니다. 또한 엘리먼트 이름이 Wrapper Container SomethingBox 이런식으로 바뀌는 것이 구조를 파악하기 더 어려웠습니다.

결국 기술에는 pros와 con이 존재하기 마련이지만 저에게는 필요한 기능들과 장점들이 단점을 덮을만큼 존재해서 선택하게 되었고 혹여 이 글을 읽고 이득이 더 크다고 판단하게 된다면 vanilla extract의 도입을 적극 권장합니다.

함께보면 좋은 글들

profile
You only have to right once

1개의 댓글

comment-user-thumbnail
2023년 10월 25일

const variants = { ... }
로 쓰면 작성할때 타입 추론 도움못받을텐데

RecipeVariants 여기서 내부 키타입(color, size...) 추론하는 방법은 없나요?

답글 달기