Vanilla Extract - Theming

흑우·2023년 12월 11일

vanilla-extract

목록 보기
2/4

Theming

테마는 확장 가능한 스타일 규약을 미리 정의해두는 것입니다. 이를 테면 primary라는 색상은 light theme에서는 #000000 색상이 될 수 있지만 dark theme에서는 #ffffff가 될 수 있습니다. 즉, 특정 테마를 하나 추가한다고 했을 때 테마에 정의해둔 색상 변수들은 새로운 테마의 색상들과 일대일 매칭되며 light theme에 borderColor라는 색상이 추가된다면 dark theme에도 borderColor라는 색상이 존재해야합니다.

createTheme

vanilla extract에서는 테마를 만드는 가장 직관적인 api인 createTheme를 제공합니다.

createTheme는 global scope에 css variables를 선언하는 것과 동일한 역할을 합니다.

다음 예제 코드를 보면 createTheme으로 선언한 스타일이 실제 css로 어떻게 변환되는지를 볼 수 있습니다.

// theme.css.ts
import { createTheme } from '@vanilla-extract/css';

export const [themeClass, vars] = createTheme({
  color: {
    brand: 'blue'
  },
  font: {
    body: 'arial'
  }
});
.theme_themeClass__z05zdf0 {
  --color-brand__z05zdf1: blue;
  --font-body__z05zdf2: arial;
}

theme contract

createTheme가 리턴하는 튜플의 두 번째 인덱스인 vars는 vanilla extract에서 theme contract라고 부릅니다.
이렇게 선언된 테마는 vars 변수로 외부에서 참조될 수 있으며, 또 다른 스타일을 선언하는데 사용되기도 합니다.

export const brandText = style({
	color: vars.color.brand,
  	fontFamily: vars.font.body,
});
.theme_brandText__z05zdf3 {
	color: var(--color-brand__z05zdf1);
    font-family: var(--font-body__z05zdf2);
}

brandText와 같은 type scale(typography style 종류)의 일종을 선언하는 예시 코드를 위에서 볼 수 있습니다.

위에서 vars 변수를 사용해 color style을 입히는 것을 볼 수 있는데요, 여기서 vars는 vanilla extract에서 theme contract라고 부릅니다. conpile된 js는 아래와 같은 모습을 뜁니다.

import './theme.css';

export const vars = {
  color: {
    brand: 'var(--color-brand__l520oi1)'
  },
  font: {
    body: 'var(--font-body__l520oi2)'
  }
};

export const themeClass = 'theme_themeClass__l520oi0';

근데 왜 theme contract라고 부를까요? 그 이유는 앞서 선언한 vars 변수가 또 다른 theme를 선언할 때 사용되기 때문입니다.

밑에서 새롭게 만들어진 otherThemeClass는 앞서 선언한 themeClass의 변수와 다른 이름을 가진 변수를 생성하는 것이 아닌 동일한 이름의 css variable에 다른 값을 가지게 됩니다.

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

export const [themeClass, vars] = createTheme({
  color: {
    brand: 'blue'
  },
  font: {
    body: 'arial'
  }
});

export const otherThemeClass = createTheme(vars, {
  color: {
    brand: 'red'
  },
  font: {
    body: 'helvetica'
  }
});
.theme_themeClass__z05zdf0 {
  --color-brand__z05zdf1: blue;
  --font-body__z05zdf2: arial;
}
.theme_otherThemeClass__z05zdf3 {
  --color-brand__z05zdf1: red;
  --font-body__z05zdf2: helvetica;
}

theme contract를 사용해서 또 다른 theme를 선언하게되면 누락된 테마 키/벨류를 추적할 수 있어 도움이 됩니다.

위에서 선언한 themeClass와 otherThemeClass는 아래와 같이 js로 컴파일 됩니다.

// Example result of the compiled JS
import './theme.css';

export const vars = {
  color: {
    brand: 'var(--color-brand__l520oi1)'
  },
  font: {
    body: 'var(--font-body__l520oi2)'
  }
};

export const themeClass = 'theme_themeClass__l520oi0';

export const otherThemeClass =
  'theme_otherThemeClass__l520oi3';

Code Splitting Themes

createTheme api를 사용해서 간편하게 theme를 선언할 수 있으나 단순함과 비교되는 하나의 트레이드오프가 있습니다.

createTheme를 사용해 vanilla theme를 먼저 만들었다면, 여기서 사용된 theme contract를 사용해 다른 테마를 생성하기 위해서는 이 바닐라 테마를 항상 참조시켜야합니다. light theme, dark theme 둘 중 하나만 사용하는 앱도 바닐라 테마에 해당하는 css를 항상 앱에서 로딩시켜야 한다는 단점이 있습니다.

다음처럼 darkTheme만 사용하여 스타일링을 하더라도 lightTheme에 선언했던 스타일까지 함께 불러와지는 것을 볼 수 있습니다.

._18sije30 {
	--_18sije31: #000;
    --_18sije32: #00f;
}

._18sije33 {
	--_18sije31: #fff;
    --_18sije32: #ff0;
}

createThemeContract

이러한 문제점을 피하고 싶다면, createThemeContract api를 사용할 수 있습니다.
createTheme를 통해 선언된 코드로 인해 css가 생성됨과는 다르게 createThemeContract는 css를 생성하지 않습니다. 정적 타이핑을 위한 contract를 만든다고 생각할 수 있습니다.

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

export const vars = createThemeContract({
  color: {
    brand: ''
  },
  font: {
    body: ''
  }
});

No CSS created - 위의 vars를 생성하더라도 아무런 css도 생성되지 않습니다.

createThemeContract로 파생된 theme contract를 사용해 생성한다면 바닐라 테마로 만들었던 darkTheme와 다르게 앱에서 사용되는 특정 테마만 css 코드에 포함시킬 수 있게 됩니다.

예제 코드를 통해 살펴보겠습니다.
먼저 createThemeContract로 theme를 생성할 때 사용할 theme contract를 만듭니다. createTheme를 사용해 만들었던 바닐라 테마와는 다르게 스타일 값을 사용한 것이 아닌 타입 추론만 가능하게 '' 값만 넣은 것을 알 수 있습니다.

// contract.css.ts
import { createThemeContract } from '@vanilla-extract/css';

export const vars = createThemeContract({
  color: {
    brand: ''
  },
  font: {
    body: ''
  }
});

createThemeContract의 리턴 값인 vars를 사용해 테마를 선언할 때, createTheme에서 누락된 테마가 있는 것을 자동 추론 해주기 때문에 테마를 누락할 염려가 없습니다.

// redTheme.css.ts
import { createTheme } from '@vanilla-extract/css';
import { vars } from './contract.css.ts';

export const redThemeClass = createTheme(vars, {
  color: {
    brand: 'red'
  },
  font: {
    body: 'helvetica'
  }
});

위와 같이 redTheme만 앱에서 사용할 경우 createTheme만 사용할 때와는 다르게 번들링 파일에 redTheme에 대한 스타일 코드만 생성된 것을 확인할 수 있습니다.
css 코드 스플리팅이 정상적으로 적용된 것을 보여줍니다.

._18sije30 {
	--_18sije31: red;
    --_18sije32: black;
}

Dynamic Theming

vanilla exract의 Themeing 기법 중 가장 재밌는 부분을 소개합니다.

이를테면 디자인 시스템을 만드는 목적 뿐 아니라 여러 테마를 제공하는 특정 컴포넌트의 세트를 제공한다고 가정해봅시다.

이 경우 사용자 정의 테마에 따라 컴포넌트의 스타일만 미리 정의해둔 테마 변수에 따라 결정되고 결정되는 값은 사용자가 정할 수도 있습니다.

이를테면 아래와 같은 CocktailProvider가 있다고 가정합시다.
이 CocktailProvider는 미리 바텐더가 제공하는 기본 레시피도 있지만, 고유의 칵테일 조합법을 가지고 있는 사람또한 손쉽게 칵테일을 제조할 수 있게 도와줍니다.

export const App = () => (
	<CocktailProvider theme={darkTheme()} {...etc}>
  		{/*Your Cocktail*/}
	</CocktailProvider>
)

CocktailProvider에서는 theme 값을 사용자가 직접 주입함으로써 미리 만들어뒀던 컴포넌트에 색을 사용자가 원하는 대로 변경할 수 있습니다.

사용자가 원하는 칵테일의 색이 기본적으로 제공하는 푸른색의 계열이 아니라면, 다음처럼 컴파일 타임이 아닌 런타임에 유저에게 주입할 수 있는 선택지를 제공해야 합니다.

export const App = () => {
	<CocktailProvider
  		theme={darkTheme({
        	accentColor: '#7b3fe4',
          	accentColorForeground: 'white',
          	borderRadius: 'small',
          	fontStack: 'system',
          	overlayBlur: 'small',
        })}
        {...etc}
  		>
          {/*Your App*/}
 		</CocktailProvider />
}

앞서 봤던 createTheme api나 createThemeContract api는 제로 런타임을 표방하는 vanilla extract에 맞게 빌드 타임에 css를 생성했습니다. 하지만 위와 같은 런타임에 테마 주입이 필요한 경우에는 빌드 타임이 아닌 런타임에 스타일을 생성할 수 있어야 합니다.

동적으로 css variables를 생성하기 위해 @vanilla-exract/dynamic에서는 assignInlineVars api를 제공합니다. 런타임에 생성하지만 라이브러리 자체는 매우 가벼우며 성능 저하를 일으키지 않습니다.

먼저 앞서 봤던 createThemeContract를 이용해 theme contract를 만들어 줍니다.

export const themeVars = createThemeContract({
  color: {
    brand: null
  },
  font: {
  	body: null
  }
});

export const container = style({
	background: themeVars.color.brand,
  	fontFamily: themeVars.font.body
})

위 코드는 아래와 같이 css로 변환됩니다.

.theme_container__z05zdf2 {
  background: var(--color-brand__z05zdf0);
  font-family: var(--font-body__z05zdf1);
}

이를 Container 컴포넌트에서 사용해보겠습니다.

import { assignInlineVars } from '@vanilla-extract/dynamic';
import { container, themeVars } from './theme.css.ts';

interface ContainerProps {
  brandColor: string;
  fontFamily: string;
}
const Container = ({
  brandColor,
  fontFamily
}: ContainerProps) => (
  <section
    className={container}
    style={assignInlineVars(themeVars, {
      color: { brand: brandColor },
      font: { body: fontFamily }
    })}
  >
    ...
  </section>
);

아직 Container 컴포넌트에 적용되는 color와 font는 정해지지 않았습니다.
ContainerProps에 전달되는 brandColor와 fontFamily에 따라 정해집니다.

const App = () => (
  <Container brandColor="pink" fontFamily="Arial">
    ...
  </Container>
);

Reference

profile
흑우 모르는 흑우 없제~

0개의 댓글