wkd2ev

atlassian-design-system 코드를 분석하여 간단한 디자인 시스템 구축하기

링크

아틀라시안 디자인 시스템 https://atlassian.design/design-system

아틀라시안 디자인 시스템 코드 저장소 https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/master/design-system/

한국 디자인 시스템 가이드 https://www.krds.go.kr/html/site/index.html

예제링크 https://github.com/dkpark10/wkd2ev-post-example/blob/master/apps/app/src/app/my-design-system/page.tsx

에제 페이지#

개요#

한번은 디자인 시스템 구축 업무를 맡아보고 싶었으나, 여건상 기회가 닿지 않아 아쉬움이 남았습니다. 마침 좋은 참고자료가 있어 Atlassian Design System을 분석하고, 이를 토대로 직접 버튼 컴포넌트를 만들어보겠습니다.

디자인 토큰#

디자인 시스템을 코드로 구현하려면 디자인 토큰에 대한 이해가 필수입니다.


디자인 토큰(Design Tokens)은 디자인 시스템에서 반복적으로 사용되는 디자인 속성을 효율적으로 관리하기 위한 일종의 추상화된 값을 변수로 정의한 코드이다. 색상, 글자, 간격, 그림자 등과 같은 스타일의 속성을 정의하고, 이를 코드로 변환하여 디자인 시스템 전반에 걸쳐 일관된 스타일을 유지할 수 있게 도와준다. KRDS


디자인 토큰은 UI 구성정보 및 디자인 결정을 위한 작은 데이터 단위입니다. 핵심은 Raw 값을 직접 사용하지 않고, 의미 있는 이름으로 감싸는 것입니다.

디자인 토큰 사용이유?#

CSS 변수보다는 토큰 네이밍으로 좀 더 명확한 의미를 보여줄 수 있습니다.

1/* CSS 변수만 사용 */
2.button {
3  background-color: var(--blue-500);  /* 색상이 뭘 의미하는지 알 수 없음 */
4}
5
6/* 디자인 토큰 사용 */
7.button {
8  background-color: var(--ds-background-brand-bold);  /* "브랜드 강조 배경"임을 알 수 있음 */
9}

디자인 토큰의 핵심 가치는 다음과 같습니다.

  1. 의미 전달: blue-500은 색상값일 뿐이지만, background-brand-bold는 용도를 명시합니다.
  2. 테마 대응: 다크 모드에서 --blue-500을 직접 바꾸면 의도치 않은 곳에 영향을 줄 수 있지만, 시맨틱 토큰은 테마별로 다른 값을 안전하게 매핑합니다.
  3. 변경 용이성: "경고 색상을 주황에서 빨강으로 바꿔주세요"라는 요청에 토큰 정의 한 곳만 수정하면 됩니다.

토큰 계층 구조#

디자인 토큰은 보통 3계층으로 구성됩니다.

1┌─────────────────────────────────────────────────────────────┐
2│  Component Token (컴포넌트 토큰)3│  예: button.background.primary                               │
4│  → 특정 컴포넌트에서만 사용                                    │
5├─────────────────────────────────────────────────────────────┤
6│  Semantic Token (시맨틱 토큰)7│  예: color.background.brand.bold                             │
8│  → 의미/용도를 나타냄                                         │
9├─────────────────────────────────────────────────────────────┤
10│  Primitive Token (프리미티브 토큰)11│  예: --ds-blue-500: #0052CC                                  │
12│  → 실제  (절대 직접 사용하지 않음)13└─────────────────────────────────────────────────────────────┘

Atlassian Design System은 주로 PrimitiveSemantic 2계층을 사용하며, Component 토큰은 각 컴포넌트 내부에서 정의합니다.

dt

위 이미지의 아이콘 색상을 선택하는 과정에서 직접 색상값 #22C55E 이나 변수 $green-500 를 사용하는 대신, color.icon.success 디자인 토큰을 적용할 수 있습니다. 이렇게 하면 "성공"이라는 의미가 코드에 명시적으로 드러나며, 추후 색상 변경 시에도 토큰 정의만 수정하면 됩니다.

이 글은 코드 레벨에서 디자인 시스템을 구축하는 것에 초점을 맞추고 있습니다. 토큰의 개념적 정의와 사용 이유에 대해서는 KRDS 디자인 토큰 가이드를 참고하세요.

토큰 읽는법#

atlassian design system 에서 정의하는 디자인 토큰에 대해 3분류로 구분되어 있습니다. color.background.information.pressed 토큰에 대해 아래와 같이 정의하고 있습니다.

1color.background.information.pressed
2 |       |                |
3 |       |                |
4(1)     (2)              (3)
  1. (1) Foundation(color): 색상, 높이, 여백 등 디자인을 구성하는 기초 속성입니다.
  2. (2) Property(background): 해당 토큰이 적용될 UI 속성입니다. (테두리, 배경, 그림자)
  3. (3) Modifier(information.pressed): 해당 토큰의 목적에 대한 상세 액션정보입니다. ex)정보, 위험, 경고, 강조 모든 토큰이 Modifier를 가지고 있지 않습니다. color.text 는 기본 폰트 색상입니다.

atlassian-frontend-mirror 분석하기#

이제 실제 코드를 살펴보겠습니다. Atlassian은 자사 프론트엔드 코드를 atlassian-frontend-mirror에 공개하고 있어, 실제 프로덕션에서 어떻게 디자인 시스템을 구현했는지 직접 확인할 수 있습니다.

버튼 컴포넌트의 UI를 구성하기 위해 필요한 토큰은 최소 4가지입니다(디자인에 따라 더 요구될 수 있음).

  1. 색상 정보: 배경색, 텍스트 색상, 테두리 색상
  2. 보더 둥글기: 버튼 모서리의 radius 값
  3. 여백 정보: 내부 padding, 요소 간 gap
  4. 텍스트 정보: 폰트 크기, 굵기, 행간

tv

해당 css가 선언된 변수 파일 링크는 여기 있습니다.

색상 변수 https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/master/design-system/tokens/src/artifacts/themes/atlassian-light.tsx

여백 변수 https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/master/design-system/tokens/src/artifacts/themes/atlassian-spacing.tsx

텍스트 변수 https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/master/design-system/tokens/src/artifacts/themes/atlassian-typography.tsx

보더 둥글기 변수 https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/master/design-system/tokens/src/artifacts/themes/atlassian-shape-rounder.tsx

atlassian-light.tsx (primitive token 레벨)#

변수 설정이 되어 있는 파일입니다. primitive token 레벨에 해당된다고 볼 수 있습니다.

1html[data-theme~="shape:shape-rounder"] {
2	--ds-radius-xsmall: 0.125rem;
3	--ds-radius-small: 0.25rem;
4	--ds-radius-medium: 0.375rem;
5	--ds-radius-large: 0.75rem;
6	--ds-radius-xlarge: 1rem;
7	--ds-radius-full: 624.9375rem;
8	--ds-radius-tile: 25%;
9	--ds-border-width: 0.0625rem;
10	--ds-border-width-selected: 0.125rem;
11	--ds-border-width-focused: 0.125rem;
12}
13
14html[data-theme~="typography:typography"],
15[data-subtree-theme][data-theme~="typography:typography"] {
16	--ds-font-heading-xxlarge: normal 653 2rem/2.25rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
17	--ds-font-heading-xlarge: normal 653 1.75rem/2rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
18	--ds-font-heading-large: normal 653 1.5rem/1.75rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
19	--ds-font-heading-medium: normal 653 1.25rem/1.5rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
20	--ds-font-heading-small: normal 653 1rem/1.25rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
21	--ds-font-heading-xsmall: normal 653 0.875rem/1.25rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
22	--ds-font-heading-xxsmall: normal 653 0.75rem/1rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
23	...생략
24}
25
26html[data-theme~="spacing:spacing"],
27[data-subtree-theme][data-theme~="spacing:spacing"] {
28	--ds-space-0: 0rem;
29	--ds-space-025: 0.125rem;
30	--ds-space-050: 0.25rem;
31	--ds-space-075: 0.375rem;
32	--ds-space-100: 0.5rem;
33	--ds-space-150: 0.75rem;
34	--ds-space-200: 1rem;
35  ...생략
36}
37
38html[data-color-mode="light"][data-theme~="light:light"],
39[data-subtree-theme][data-color-mode="light"][data-theme~="light:light"],
40html[data-color-mode="dark"][data-theme~="dark:light"],
41[data-subtree-theme][data-color-mode="dark"][data-theme~="dark:light"] {
42	color-scheme: light;
43	--ds-text: #292A2E;
44	--ds-text-accent-lime: #4C6B1F;
45	--ds-text-accent-lime-bolder: #37471F;
46	--ds-text-accent-red: #AE2E24;
47	--ds-text-accent-red-bolder: #5D1F1A;
48	--ds-text-accent-orange: #9E4C00;
49	--ds-text-accent-orange-bolder: #693200;
50	--ds-text-accent-yellow: #7F5F01;
51	--ds-text-accent-yellow-bolder: #533F04;
52	--ds-text-accent-green: #216E4E;
53	...생략
54}
55
56html[data-color-mode="light"][data-theme~="light:dark"],
57[data-subtree-theme][data-color-mode="light"][data-theme~="light:dark"],
58html[data-color-mode="dark"][data-theme~="dark:dark"],
59[data-subtree-theme][data-color-mode="dark"][data-theme~="dark:dark"] {
60	color-scheme: dark;
61	--ds-text: #CECFD2;
62	--ds-text-accent-lime: #B3DF72;
63	--ds-text-accent-lime-bolder: #D3F1A7;
64	--ds-text-accent-red: #FD9891;
65	--ds-text-accent-red-bolder: #FFD5D2;
66	--ds-text-accent-orange: #FBC828;
67	--ds-text-accent-orange-bolder: #FCE4A6;
68	--ds-text-accent-yellow: #EED12B;
69	--ds-text-accent-yellow-bolder: #F5E989;
70	--ds-text-accent-green: #7EE2B8;
71	...생략
72}

디자인 시스템 가이드에서는 변수를 선언한 rawValue의 사용을 금지하고 있습니다. 이를 이제 선언한 css 변수명들을 다시 한번 감싸주어 시맨틱 토큰 레벨에 해당되는 객체를 생성합니다.

token-names.tsx (semantic token 레벨)#

1const tokens = {
2	'color.text': '--ds-text',
3	'color.text.accent.lime': '--ds-text-accent-lime',
4	'color.text.accent.lime.bolder': '--ds-text-accent-lime-bolder',
5	'color.text.accent.red': '--ds-text-accent-red',
6	'color.text.accent.red.bolder': '--ds-text-accent-red-bolder',
7	'color.text.accent.orange': '--ds-text-accent-orange',
8	'color.text.accent.orange.bolder': '--ds-text-accent-orange-bolder',
9  ...생략 
10}
11
12export type CSSTokenMap = {
13	'color.text': 'var(--ds-text)';
14	'color.text.accent.lime': 'var(--ds-text-accent-lime)';
15	'color.text.accent.lime.bolder': 'var(--ds-text-accent-lime-bolder)';
16	'color.text.accent.red': 'var(--ds-text-accent-red)';
17	'color.text.accent.red.bolder': 'var(--ds-text-accent-red-bolder)';
18	'color.text.accent.orange': 'var(--ds-text-accent-orange)';
19	'color.text.accent.orange.bolder': 'var(--ds-text-accent-orange-bolder)';
20  ...생략 
21};
22
23export type CSSToken = CSSTokenMap[keyof CSSTokenMap];

이제 시맨틱 토큰까지 완성되었으니 버튼컴포넌트를 분석해보겠습니다. 버튼컴포넌트의 UI 구성정보는 위에 작성한것처럼 색상 정보, 보더 둥글기, 여백 정보, 텍스트 정보를 최소로 필요로 합니다. 이제는 해당 버튼이 어떠한 역할을 하는지 현재 상태가 어떤지에 대한 정보가 필요합니다.

기본적인 유형(Appearance)과 상태(State)는 다음과 같이 구성됩니다.

유형 (Appearance)

  • Primary: 주요 액션 (페이지당 1개 권장)
  • Warning: 주의가 필요한 액션
  • Danger: 삭제 등 위험한 액션
  • Default: 일반적인 보조 액션
  • Subtle: 시각적으로 덜 강조되는 액션

상태 (State)

  • Disabled: 클릭 불가능한 상태
  • Selected: 토글 등에서 선택된 상태
  • Loading: 비동기 작업 진행 중

여기서는 버튼을 예시로 들었지만 text를 받는 input element 같은경우 유형으로 입력값의 성공, 실패, 유효하지 못한 값 입력 등이 있을 것이고 상태로는 입력불가, hover event 등 존재할 것입니다.

이제 실제 Atlassian의 버튼 컴포넌트 코드를 살펴보겠습니다. 아래는 핵심 부분만 추출한 코드입니다.

button-based.tsx#

1const styles = cssMap({
2	base: {
3		display: 'inline-flex',
4		boxSizing: 'border-box',
5		width: 'auto',
6		maxWidth: '100%',
7		position: 'relative',
8		alignItems: 'baseline',
9		justifyContent: 'center',
10		columnGap: token('space.050'),
11		borderRadius: token('radius.small', '3px'),
12		borderWidth: 0,
13		flexShrink: 0,
14		height: '2rem',
15		font: token('font.body'),
16		fontWeight: token('font.weight.medium'),
17		paddingBlock: token('space.075'),
18		paddingInlineEnd: token('space.150'),
19		paddingInlineStart: token('space.150'),
20		textAlign: 'center',
21		transition: 'background 0.1s ease-out',
22		verticalAlign: 'middle',
23		'&::after': {
24			borderRadius: 'inherit',
25			inset: token('space.0'),
26			borderStyle: 'solid',
27			borderWidth: token('border.width'),
28			pointerEvents: 'none',
29			position: 'absolute',
30		},
31	},
32	... 생략
33});
34
35const defaultStyles = cssMap({
36	root: {
37		backgroundColor: token('color.background.neutral.subtle'),
38		color: token('color.text.subtle'),
39		'&::after': {
40			content: '""',
41			borderColor: token('color.border'),
42		},
43		'&:visited': {
44			color: token('color.text.subtle'),
45		},
46		'&:hover': {
47			color: token('color.text.subtle'),
48		},
49		'&:active': {
50			// @ts-expect-error
51			color: token('color.text.subtle'),
52		},
53		'&:focus': {
54			color: token('color.text.subtle'),
55		},
56	},
57	... 생략
58});
59
60const ButtonBase = React.forwardRef((
61	{
62		appearance: propAppearance,
63		...unsafeRest
64	}) => {
65		const appearance = propAppearance;
66		const { className: _className, css: _css, as: _as, style: _style, ...saferRest } = unsafeRest;
67
68		return (
69			<button
70				{...saferRest}
71				className={cx(
72					styles.base,
73					appearance === 'default' && defaultStyles.root,
74					...생략
75				)}
76			>
77				{children}
78			</button>
79		);
80	},
81);
82
83export default ButtonBase;

버튼 컴포넌트는 appearance prop으로 다양한 variant를 받아 해당하는 className들을 병합합니다.

여기서 눈여겨볼 점이 두 가지 있습니다.

  1. cssMap: Atlassian이 자체 개발한 zero-runtime CSS 라이브러리 @compiled/react의 함수입니다. 빌드 타임에 CSS를 추출하여 런타임 오버헤드가 없습니다.

  2. token 함수: 모든 스타일 값이 token() 으로 감싸져 있습니다.

1function token<T extends keyof Tokens>(path: T, fallback?: string): CSSTokenMap[T] {
2  const token: Tokens[keyof Tokens] = tokens[path];
3
4  const tokenCall = fallback ? `var(${token}, ${fallback})` : `var(${token})`;
5
6  return tokenCall as CSSTokenMap[T];
7}

token 함수의 역할은 단순합니다. 시맨틱 토큰 이름을 받아서 CSS 변수 문자열을 반환합니다.

1token('color.text.accent.orange.bolder') // -> 'var(--ds-text-accent-orange-bolder)'

이 단순한 래퍼 함수는 정말 중요합니다.

  1. 타입 안전성: 시맨틱 토큰 객체의 키를 TypeScript 타입으로 정의하면, 존재하지 않는 토큰을 사용할 때 컴파일 에러가 발생합니다.
  2. 자동완성: 에디터에서 token('color.')까지 입력하면 사용 가능한 모든 색상 토큰이 자동완성으로 표시됩니다.
  3. 일관성 강제: CSS 변수를 직접 작성하는 것보다 오타 가능성이 현저히 낮아집니다.

수백 개의 토큰을 다루는 대규모 디자인 시스템에서 이 타입 안전성은 필수입니다.

type-safe

결국 컴포넌트 구현의 핵심은 prop → style 매핑입니다. appearance, state, spacing 같은 prop을 받아서 해당하는 className 조합을 반환하는 것


그러고보니 위에 코드는 어딘가 정말 많이 닮았습니다.

바로 shadcn/ui의 기본 구성정보가 정말 똑같습니다.

https://ui.shadcn.com/docs/components/radix/button#

1import * as React from "react"
2import { cva, type VariantProps } from "class-variance-authority"
3import { Slot } from "radix-ui"
4import { cn } from "@/lib/utils"
5const buttonVariants = cva(
6  "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
7  {
8    variants: {
9      variant: {
10        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
11        outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
12        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
13        ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
14        destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
15        link: "text-primary underline-offset-4 hover:underline",
16      },
17      size: {
18        default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
19        xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
20        sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
21        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
22        icon: "size-8",
23        "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
24        "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
25        "icon-lg": "size-9",
26      },
27    },
28    defaultVariants: {
29      variant: "default",
30      size: "default",
31    },
32  }
33)
34
35function Button({
36  className,
37  variant = "default",
38  size = "default",
39  asChild = false,
40  ...props
41}) {
42  return (
43    <button
44      data-slot="button"
45      data-variant={variant}
46      data-size={size}
47      className={cn(buttonVariants({ variant, size, className }))}
48      {...props}
49    />
50  )
51}

shadcn/ui는 cva + tailwind 조합으로 버튼을 구성한다는 점을 제외하고는 메인 컨셉은 똑같습니다. 결국 props의 조합으로 스타일 조합의 반환입니다.

버튼 만들기#

이론은 충분합니다. 이제 직접 버튼을 만들어 보겠습니다.

기술 스택 선택:

  • @compiled/react는 zero runtime css의 특성상 번들러의 추가 설정이 필요하고 번거롭습니다.
  • Tailwind CSS + CVA(Class Variance Authority) 조합을 사용합니다. 이 조합은 이미 많은 프로젝트에서 검증되었고, 설정이 간단합니다.

목표는 단순히 shadcn/ui를 복제하는 것이 아닙니다. 타입 안전한 token 함수를 통해 디자인 토큰의 이점을 살리면서, Tailwind의 생산성을 함께 가져가는 것입니다.


먼저 primitive token 레벨을 지정해줍니다. 이는 atlassian-light.tsx파일을 css에 옮긴 것에 불과합니다.

index.css (primitive token 레벨)#

1html[data-theme~="shape:shape-rounder"] {
2	--ds-radius-xsmall: 0.125rem;
3	--ds-radius-small: 0.25rem;
4}
5
6html[data-theme~="typography:typography"],
7[data-subtree-theme][data-theme~="typography:typography"] {
8	--ds-font-heading-xxlarge: normal 653 2rem/2.25rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
9	--ds-font-heading-xlarge: normal 653 1.75rem/2rem "Atlassian Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, "Helvetica Neue", sans-serif;
10	...생략
11}
12
13html[data-theme~="spacing:spacing"],
14[data-subtree-theme][data-theme~="spacing:spacing"] {
15	--ds-space-0: 0rem;
16	--ds-space-025: 0.125rem;
17  ...생략
18}
19
20html[data-color-mode="light"][data-theme~="light:light"],
21[data-subtree-theme][data-color-mode="light"][data-theme~="light:light"],
22html[data-color-mode="dark"][data-theme~="dark:light"],
23[data-subtree-theme][data-color-mode="dark"][data-theme~="dark:light"] {
24	color-scheme: light;
25	--ds-text: #292A2E;
26	--ds-text-accent-lime: #4C6B1F;
27	...생략
28}
29
30html[data-color-mode="light"][data-theme~="light:dark"],
31[data-subtree-theme][data-color-mode="light"][data-theme~="light:dark"],
32html[data-color-mode="dark"][data-theme~="dark:dark"],
33[data-subtree-theme][data-color-mode="dark"][data-theme~="dark:dark"] {
34	color-scheme: dark;
35	--ds-text: #CECFD2;
36	--ds-text-accent-lime: #B3DF72;
37	...생략
38}

tokens.json (semantic token 레벨)#

semantic token 레벨의 객체를 json으로 만들어 줍니다.

1{
2  "color.text": "--ds-text",
3  "color.text.accent.lime": "--ds-text-accent-lime",
4  "color.text.accent.lime.bolder": "--ds-text-accent-lime-bolder",
5  "color.text.accent.red": "--ds-text-accent-red",
6  "color.text.accent.red.bolder": "--ds-text-accent-red-bolder",
7  "color.text.accent.orange": "--ds-text-accent-orange",
8	...생략
9}

button.tsx#

1import { cva } from 'class-variance-authority';
2import {
3  forwardRef,
4  type PropsWithChildren,
5  type ComponentType,
6  type SVGProps as ReactSVGProps,
7  type ComponentPropsWithoutRef,
8} from 'react';
9import { token, rawToken } from '@/utils/token';
10import { cn } from '@/utils/cn';
11import Spinner from '@atlaskit/spinner';
12
13const buttonVariants = cva(
14  [
15    // layout
16    'inline-flex',
17    'box-border',
18    'w-auto',
19    'max-w-full',
20    'relative',
21    'items-baseline',
22    'justify-center',
23    'shrink-0',
24    // spacing
25    token('gap', 'space.050'),
26    // border
27    token('rounded', 'radius.small'),
28    // sizing
29    'h-8',
30    // typography
31    `[font-weight:${rawToken('font.weight.medium')}]`,
32    // padding
33    token('py', 'space.075'),
34    token('px', 'space.150'),
35    // alignment
36    'text-center',
37    'align-middle',
38    // interaction
39    'cursor-pointer',
40    'transition-[background]',
41    'duration-100',
42    'ease-out',
43    // ::after pseudo element
44    'after:rounded-[inherit]',
45    token('after:inset', 'space.0'),
46    'after:border-solid',
47    `after:border-[length:${rawToken('border.width')}]`,
48    'after:border-transparent',
49    'after:pointer-events-none',
50    'after:absolute',
51    `after:content-['']`,
52  ],
53  {
54    variants: {
55      appearance: {
56        default: [
57          // root
58          token('bg', 'color.background.neutral.subtle'),
59          token('text', 'color.text.subtle'),
60          'border',
61          token('border', 'color.border'),
62          // states
63          token('visited:text', 'color.text.subtle'),
64          token('hover:text', 'color.text.subtle'),
65          token('active:text', 'color.text.subtle'),
66          token('focus:text', 'color.text.subtle'),
67          // interactive
68          token('hover:bg', 'color.background.neutral.subtle.hovered'),
69          token('active:bg', 'color.background.neutral.subtle.pressed'),
70        ],
71        primary: [
72          // root
73          token('bg', 'color.background.brand.bold'),
74          token('text', 'color.text.inverse'),
75          // states
76          token('visited:text', 'color.text.inverse'),
77          token('hover:text', 'color.text.inverse'),
78          token('active:text', 'color.text.inverse'),
79          token('focus:text', 'color.text.inverse'),
80          // interactive
81          token('hover:bg', 'color.background.brand.bold.hovered'),
82          token('active:bg', 'color.background.brand.bold.pressed'),
83        ],
84        warning: [
85          // root
86          token('bg', 'color.background.warning.bold'),
87          token('text', 'color.text.warning.inverse'),
88          // states
89          token('visited:text', 'color.text.warning.inverse'),
90          token('hover:text', 'color.text.warning.inverse'),
91          token('active:text', 'color.text.warning.inverse'),
92          token('focus:text', 'color.text.warning.inverse'),
93          // interactive
94          token('hover:bg', 'color.background.warning.bold.hovered'),
95          token('active:bg', 'color.background.warning.bold.pressed'),
96        ],
97        danger: [
98          // root
99          token('bg', 'color.background.danger.bold'),
100          token('text', 'color.text.inverse'),
101          // states
102          token('visited:text', 'color.text.inverse'),
103          token('hover:text', 'color.text.inverse'),
104          token('active:text', 'color.text.inverse'),
105          token('focus:text', 'color.text.inverse'),
106          // interactive
107          token('hover:bg', 'color.background.danger.bold.hovered'),
108          token('active:bg', 'color.background.danger.bold.pressed'),
109        ],
110        discovery: [
111          // root
112          token('bg', 'color.background.discovery.bold'),
113          token('text', 'color.text.inverse'),
114          // states
115          token('visited:text', 'color.text.inverse'),
116          token('hover:text', 'color.text.inverse'),
117          token('active:text', 'color.text.inverse'),
118          token('focus:text', 'color.text.inverse'),
119          // interactive
120          token('hover:bg', 'color.background.discovery.bold.hovered'),
121          token('active:bg', 'color.background.discovery.bold.pressed'),
122        ],
123        subtle: [
124          // root
125          'bg-transparent',
126          token('text', 'color.text.subtle'),
127          // states
128          token('visited:text', 'color.text.subtle'),
129          token('hover:text', 'color.text.subtle'),
130          token('active:text', 'color.text.subtle'),
131          token('focus:text', 'color.text.subtle'),
132          // interactive
133          token('hover:bg', 'color.background.neutral.subtle.hovered'),
134          token('active:bg', 'color.background.neutral.subtle.pressed'),
135        ],
136      },
137      spacing: {
138        default: '',
139        compact: [
140          token('gap-x', 'space.050'),
141          'h-6',
142          token('py', 'space.025'),
143          token('px', 'space.150'),
144          'align-middle',
145        ],
146      },
147      loading: {
148        true: [
149          'cursor-progress',
150          token('px', 'space.500'),
151        ],
152        false: '',
153      },
154      selected: {
155        true: '',
156        false: '',
157      },
158      disabled: {
159        true: [
160          'cursor-not-allowed',
161          token('text', 'color.text.disabled'),
162          token('hover:text', 'color.text.disabled'),
163          token('active:text', 'color.text.disabled'),
164          'bg-transparent',
165          'hover:bg-transparent',
166          'active:bg-transparent',
167          `after:${token('border', 'color.border.disabled')}`,
168        ],
169        false: '',
170      },
171    },
172    compoundVariants: [
173      // selected + default/primary/subtle
174      {
175        selected: true,
176        appearance: ['default', 'primary', 'subtle'],
177        class: [
178          token('bg', 'color.background.selected'),
179          token('text', 'color.text.selected'),
180          `after:${token('border', 'color.border.selected')}`,
181          token('visited:text', 'color.text.selected'),
182          token('hover:text', 'color.text.selected'),
183          token('active:text', 'color.text.selected'),
184          token('focus:text', 'color.text.selected'),
185          token('hover:bg', 'color.background.selected.hovered'),
186          token('active:bg', 'color.background.selected.pressed'),
187        ],
188      },
189      // selected + warning
190      {
191        selected: true,
192        appearance: 'warning',
193        class: [
194          token('bg', 'color.background.selected'),
195          token('text', 'color.text.selected'),
196          token('hover:text', 'color.text.selected'),
197          token('active:text', 'color.text.selected'),
198          token('hover:bg', 'color.background.selected'),
199          token('active:bg', 'color.background.selected'),
200        ],
201      },
202      // selected + danger
203      {
204        selected: true,
205        appearance: 'danger',
206        class: [
207          token('bg', 'color.background.selected'),
208          token('text', 'color.text.selected'),
209          token('hover:text', 'color.text.selected'),
210          token('active:text', 'color.text.selected'),
211          token('hover:bg', 'color.background.selected'),
212          token('active:bg', 'color.background.selected'),
213        ],
214      },
215      // selected + discovery
216      {
217        selected: true,
218        appearance: 'discovery',
219        class: [
220          token('bg', 'color.background.selected'),
221          token('text', 'color.text.selected'),
222          token('hover:text', 'color.text.selected'),
223          token('active:text', 'color.text.selected'),
224          token('hover:bg', 'color.background.selected'),
225          token('active:bg', 'color.background.selected'),
226        ],
227      },
228    ],
229    defaultVariants: {
230      appearance: 'default',
231      spacing: 'default',
232    },
233  }
234);
235
236type Appearance =
237  'default'
238  | 'danger'
239  | 'primary'
240  | 'subtle'
241  | 'warning'
242  | 'discovery';
243
244interface IconProp extends ReactSVGProps<SVGSVGElement> {
245  glyph?: ComponentType<{
246    'data-testid'?: string;
247    'aria-label'?: string;
248    className?: string;
249  }>;
250}
251
252type ButtonProps = PropsWithChildren & ComponentPropsWithoutRef<"button"> & {
253  iconAfter?: IconProp;
254  iconBefore?: IconProp;
255  isLoading?: boolean;
256  isSelected?: boolean;
257  isDisabled?: boolean;
258  appearance?: Appearance;
259  spacing?: 'compact' | 'default';
260  className?: string;
261};
262
263export default forwardRef<HTMLButtonElement, ButtonProps>(function Button(
264  props,
265  ref
266) {
267  const {
268    children,
269    appearance,
270    spacing,
271    isLoading,
272    isDisabled,
273    className,
274    isSelected,
275    ...rest
276  } = props;
277
278  return (
279    <button
280      ref={ref}
281      disabled={isDisabled || false}
282      className={cn(
283        buttonVariants({ appearance, spacing, loading: isLoading, disabled: isDisabled, selected: isSelected }),
284        className
285      )}
286      {...rest}
287    >
288      {!isLoading && children}
289      {isLoading && (
290        <span
291          className={cn(
292            'flex',
293            'absolute',
294            'items-center',
295            'justify-center',
296            'overflow-hidden',
297            token('inset', 'space.0'),
298          )}
299        >
300          <Spinner appearance="invert" size="medium" />
301        </span>
302      )}
303    </button>
304  );
305});

여기서 token함수를 둘로 나누었습니다. rawToken함수는 그대로 semantic token 값을 넣으면 primitive token 문자열을 반환하는 함수입니다. token 함수는 tailwind 문법에 맞춰 동적으로 클래스 이름을 지정하기 위한 함수입니다.

token.ts#

1import tokens from './tokens.json';
2
3// TypeScript 타입 생성
4type TokenKeys = keyof typeof tokens;
5
6export type CSSTokenMap = {
7  [K in TokenKeys]: `var(${(typeof tokens)[K]})`;
8};
9
10export type CSSToken = CSSTokenMap[keyof CSSTokenMap];
11
12const CSS_PREFIX = 'ds';
13const TOKEN_NOT_FOUND_CSS_VAR: '--ds-token-not-found' = `--${CSS_PREFIX}-token-not-found`;
14
15type Tokens = typeof tokens;
16
17export function rawToken<T extends keyof Tokens>(path: T, fallback?: string): CSSTokenMap[T] {
18  let token: Tokens[keyof Tokens] | typeof TOKEN_NOT_FOUND_CSS_VAR = tokens[path];
19
20  if (!token) {
21    token = TOKEN_NOT_FOUND_CSS_VAR;
22  }
23
24  const tokenCall = fallback ? `var(${token}, ${fallback})` : `var(${token})`;
25
26  return tokenCall as CSSTokenMap[T];
27}
28
29export function token<T extends keyof Tokens>(property: string, path: T, fallback?: string) {
30  return `${property}-[${rawToken(path, fallback)}]`;
31}

token 함수의 사용 예시입니다.

1// rawToken: CSS 변수 문자열만 반환
2rawToken('font.weight.medium')
3// -> 'var(--ds-font-weight-medium)'
4
5// token: Tailwind 클래스 형태로 반환
6token('bg', 'color.background.neutral.subtle')
7// -> 'bg-[var(--ds-background-neutral-subtle)]'
8
9token('hover:bg', 'color.background.neutral.subtle.hovered')
10// -> 'hover:bg-[var(--ds-background-neutral-subtle-hovered)]'

이 접근 방식의 장점은 Tailwind의 모든 modifier를 그대로 사용할 수 있다는 것입니다. hover:, active:, focus:, after: 등의 pseudo-class/element를 첫 번째 인자에 포함시키면 됩니다.

여기서 Tailwind의 추가 설정이 필요합니다. token 함수는 런타임에서 실행됩니다. Tailwind JIT는 빌드타임에 정적 문자열만을 스캔하므로 token 함수의 반환값을 인식하지 못합니다. 이 문제를 해결하기 위해 Safelist를 사용하여 빌드타임에 Tailwind에게 생성할 클래스 이름을 명시적으로 지정해야 합니다.

주의: Safelist는 번들 크기를 증가시킬 수 있습니다. 프로덕션 환경에서는 실제 사용되는 토큰만 포함하도록 필터링하거나, 빌드 스크립트에서 정적 분석을 통해 사용 중인 토큰만 추출하는 방식을 고려해볼 수 있습니다.

tailwind.config.js#

1/** @type {import('tailwindcss').Config} */
2import tokens from './src/utils/tokens.json' with { type: 'json' };
3
4// safelist 생성
5const colorProps = ['bg', 'text', 'border', 'hover:bg', 'hover:text', 'hover:border', 'active:bg', 'active:text', 'active:border', 'focus:text', 'visited:text', 'after:border'];
6const spacingProps = ['p', 'px', 'py', 'pt', 'pr', 'pb', 'pl', 'm', 'mx', 'my', 'gap', 'inset', 'after:inset'];
7const radiusProps = ['rounded'];
8
9const safelist = [];
10
11Object.entries(tokens).forEach(([name, cssVar]) => {
12  if (name.startsWith('color.') || name.startsWith('elevation.surface')) {
13    colorProps.forEach(prop => safelist.push(`${prop}-[var(${cssVar})]`));
14  }
15  if (name.startsWith('space.')) {
16    spacingProps.forEach(prop => safelist.push(`${prop}-[var(${cssVar})]`));
17  }
18  if (name.startsWith('radius.')) {
19    radiusProps.forEach(prop => safelist.push(`${prop}-[var(${cssVar})]`));
20  }
21  if (name.startsWith('font.weight.')) {
22    safelist.push(`[font-weight:var(${cssVar})]`);
23  }
24  if (name.startsWith('border.width')) {
25    safelist.push(`after:border-[length:var(${cssVar})]`);
26    safelist.push(`border-[length:var(${cssVar})]`);
27  }
28});
29
30export default {
31  content: [
32    './index.html',
33    './src/**/*.{js,ts,jsx,tsx}',
34  ],
35  safelist,
36  theme: {
37    extend: {},
38  },
39  plugins: [],
40};

결론#

디자인 시스템은 결국 **"의미 있는 이름으로 스타일을 추상화하는 것"**입니다. #0052CC라는 값 대신 color.background.brand.bold라는 토큰을 사용하면 다음과 같은 이점이 있습니다.

  1. 의도가 명확해집니다 - 이 색상이 "브랜드를 강조하는 배경색"임을 코드만 봐도 알 수 있습니다.
  2. 변경에 유연해집니다 - 브랜드 색상이 바뀌어도 토큰 정의만 수정하면 됩니다.
  3. 일관성이 보장됩니다 - 같은 토큰을 사용하면 항상 같은 결과를 얻습니다.

Atlassian과 shadcn/ui의 유사성이 확인됩니다.

구분Atlassianshadcn/ui
CSS 솔루션@compiled/react (zero-runtime)Tailwind CSS
토큰 시스템자체 token() 함수CSS Variables + Tailwind
번들 영향빌드타임에 CSS 추출JIT로 사용된 클래스만 생성
타입 안전성TypeScript로 토큰 자동완성문자열 기반

shadcn/ui의 공식문서를 들어가면 The Foundation for your Design System 이라는 문구가 처음 보여집니다. 어떠한 css 라이브러리를 사용하더라도 이미 검증된 보일러 플레이트 위에 우리만의 토큰 체계를 얹는 다면 디자인 시스템 구축은 크게 어렵지 않을 것입니다.