이 글은 Storybook의 Design System for Developers 튜토리얼을 중심으로, 개인적인 학습을 목표로 Design System을 구축해 나가는 과정을 담아 작성되었습니다.
Storybook의 기초적인 사용법에 대해서 까지는 다루고 있지 않습니다.
디자인 시스템은 재사용 가능한 UI를 중심으로 구축되며, 스타일 가이드 / 컴포넌트 라이브러리 / 문서화 / 그리고 코드 샘플 등 다양한 요소를 포함한다.
이를 활용해 프로젝트에서 일관된 UI를 유지
하고, 개발 시간을 절약
하며, 신규 개발자가 프로젝트에 쉽게 참여
하도록 도울 수 있다.
📈 팀/프로젝트 규모 증가할수록
📉 의사소통의 어려움
📉 기존의 UI 패턴이 문서화 되지 않거나 유실
📉 새로운 기능 개발 시 마다 동일 컴포넌트 반복 개발
위와 같이 프로젝트를 진행하다 보면 쉽게 마주할 수 있는 문제들을 바탕으로, 디자인 시스템은 중복된 개발을 줄이고 일관된 UI를 유지하도록 해주는 중요한 역할을 한다.
이로써 개발자들은 이미 검증된 UI 컴포넌트를 활용해 개발 과정을 단순화할 수 있고, 프로젝트를 가속화 시킬 수 있다.
'재사용 가능한 UI'라는 개념은 전혀 새로운 것이 아니다. 오래 전부터도 스타일 가이드, UI Kits, 위젯 등의 형태로 존재해 왔다. 이들 역시 UI의 일관성을 유지하고 개발자들이 더 쉽게 작업하도록 도움을 주었지만, 현재의 개념과는 약간의 차이가 있다.
오늘날 재사용 가능한 UI는 좀 더 모듈화되고 표준화된 형태를 띠고 있다. 컴포넌트들이 인터페이스를 시각적으로나 기능적으로나 캡슐화하는 개념으로 발전하고 있으며, 개발자들은 마치 레고 블록을 조립하는 것처럼 각각의 컴포넌트를 조합해 손쉽게 UI를 개발할 수 있다.
일관성 있는 UI를 유지하고 시간과 비용 절약에도 도움이 될 수 있지만, 디자인 시스템이 모든 문제를 해결해주진 않는다. 특히, 작은 규모의 팀이 하나의 앱을 개발하는 데 여기에 디자인 시스템을 도입한다면 이 때의 생산성 측면의 이점은 크지 않을 수 있다. 이 경우 유지 관리나 통합에 드는 비용이 디자인 시스템의 장점을 상쇄시켜 버릴 수도 있다.
하지만 여러 프로젝트나 팀에서 동일한 UI를 공유해야 할 때는 디자인 시스템이 그 가치를 발휘할 수 있다. 큰 규모의 기업이나 다수의 프로젝트를 관리하는 경우, 디자인 시스템은 일관된 UI를 유지하고 개발 시간을 단축시키며, 개발자와 디자이너 간의 협업을 강화하는데도 도움이 된다.
디자인 시스템은 일관된 UI와 UX를 제공하고, 개발자와 디자이너 간의 협업을 원활하게 만들어준다. 디자인 시스템이 가져다 주는 주된 효과는 다음과 같다.
여러 프로젝트나 팀에서 동일한 UI를 사용해 일관된 디자인 패턴과 컴포넌트를 제공한다.
반복적으로 사용되는 UI를 한 곳에 모아 재사용 가능하게 만들어 개발 시간을 단축하고 효율성을 높인다.
브랜드 색상이나 간격, 타이포그래피, 아이콘, 레이아웃 등 디자인 요소에 대한 일관된 지침을 제공한다.
개발자 및 디자이너가 프로젝트에 대해 잘 이해하고 협업할 수 있도록 컴포넌트에 대한 정보를 문서화한다.
스타일 가이드나 각 컴포넌트에 대한 설명과 사용 지침 등의 내용이 포함될 수 있다.
디자인 시스템에서는 이 모든 것이 한 데 어우러져 패키지 관리자를 통해 패키지화 되고, 버전 관리 되며, 소비자의 앱에 배포된다. 따라서 이를 적극적으로 활용해 프론트엔드 개발에 중요한 다양한 기반을 마련할 수 있다.
더불어 향후 개발 생산성과 유지 보수성을 강화하며, 팀 간 협업을 효율적으로 만들어 나가고, 최종적으로는 사용자에게 일관된 경험을 제공할 수 있다.
그러면 이러한 디자인 시스템을 구축하기 위해 백지 상태에서 처음부터 직접 구축해야 할까?
다행히도 UI 컴포넌트를 디자인하고 구축하고 구성하는 데 특화되어 있는 도구들을 활용해 디자인 시스템을 구축해 볼 수 있다.
그 외 여러 툴도 있지만, 이번 포스팅에서는 Storybook을 활용한 구축 방법에 대해 익혀 보려고 한다.
특히 격리된 환경(애플리케이션 바깥)에서 UI 컴포넌트를 개발하고 디자인하는데 도움을 주며, 컴포넌트의 상태를 시뮬레이션 하거나 다양한 상황에서 테스트 해볼 수 있는 기능을 지원한다.
이를 통해 전체 앱을 실행하지 않고도 도달하기 어려운 상태나 엣지 케이스에 대한 테스트를 진행할 수 있으며, 앱의 비즈니스 로직이나 컨텍스트와는 분리된 환경에서 UI 컴포넌트를 구축하는 데 도움이 될 수 있다.
또한 React 뿐만 아니라 Vue, Angular, Svelte 등의 프레임워크와도 함께 잘 동작한다.
이외에도 스토리북에 대한 좀 더 자세한 사항과 예제 코드에 대해서는 Tutorials - Intro to Storybook에서 내용을 확인할 수 있다.
그럼 지금부터는 실제로 스토리북을 사용해 어떻게 디자인 시스템을 구축할 수 있는지 살펴보고, 실제로 구현해 보면서 학습해 보도록 하자.
본격적으로 디자인 시스템을 구축하기 이전에, 우리는 어떤 요소들 그리고 어떤 컴포넌트를 디자인 시스템에 추가할 것인지 기준을 가지고 정해야 한다.
Component Inventory
기법은 디자인 시스템 구축에 있어 중요한 단계 중 하나로, 가장 많이 사용되는 컴포넌트를 식별하기 위한 작업이다.
쉽게 말해 UI 사용 패턴을 일반화하기 위해 앱의 모든 컴포넌트를 분해하고 이를 기반으로 시각화/문서화 하는 과정이다. 화면의 각 요소를 개별적인 컴포넌트로 분해하여 수집하고, 각 컴포넌트 별 역할이나 속성 그리고 상태 등을 분류하여, 향후 디자인 시스템을 구축할 때 필요한 요소를 파악한다.
또한 좀 더 구체적인 분류 기준이 필요하다면 다음과 같은 지침을 따를 수 있다.
· UI 패턴이 3번 이상 사용되면 재사용 가능한 UI 컴포넌트로 전환한다.
· UI 컴포넌트가 3개 이상의 프로젝트/팀에서 사용되는 경우 디자인 시스템에 포함시킨다.
기본 UI 요소
: 버튼, 인풋, 체크박스, 라디오 버튼 등
(Ex. 버튼 크기, 버튼 색상, 활성/비활성 상태 등 상태의 다양한 표현)
레이아웃 요소
: 그리드, 네비게이션 바, 헤더, 푸터 등
(Ex. 다양한 화면 크기에 대응하는 반응형 레이아웃 구현)
특수한 UI 요소
: 모달, 드롭다운, 탭, 알림 등의 사용자 경험을 향상시킬 수 있는 다양한 기능
테마 및 스타일 가이드
: 컬러 팔레트, 타이포그래피, 아이콘 등으로 일관성을 유지하고 시각적 요소를 표현
이렇게 분류된 여러 빌딩 블록은 이후 디자인 시스템에 포함되어, 여러 앱에서 수많은 고유 기능과 레이아웃 조합하기 위해 다양한 방식으로 구성될 수 있다.
포함되는 컴포넌트는 비즈니스 로직이나 데이터 로드 방식에 상관 없이, props에만 반응하는 순수하고 표현적인(presentational) 컴포넌트만 포함
되어야 한다.
나중에 언젠가 디자인 시스템에 포함되리라 여길지라도, 재사용할 수 없는 일회성 컴포넌트는 제외
한다. 최대한 필요하지 않은 코드까지 관리할 필요가 없도록 만든다.
지금까지 디자인 시스템의 이론에 대해 살펴보았으니 이제 실전으로 들어가 보자.
기술 스택은 Vite, React, TypeScript 그리고 컴포넌트 스타일링에 Tailwind CSS와 clsx, tailwind-merge를 활용해보기로 했다.
프로젝트 환경 구성은 다음 공식 문서들을 참고하였다. 지금 단계에서는 우선 빠르게 컴포넌트만 만들어 볼 목적으로, 별도로 설정 내용을 커스터마이징 하지는 않고 최대한 기본 구성을 따랐다. (이 부분에 대해서는 추후 디자인 시스템 배포 라는 주제로 좀 더 상세히 다뤄 볼 예정이다.)
주요 버전 정보:
- react:^18.2.0
- typescript:^5.2.2
- storybook:^8.0.10
- vite:^5.2.0
# vite react 프로젝트 구성
npm create vite@latest
✔ Project name: … react-design-system
✔ Select a framework: › React
✔ Select a variant: › TypeScript
cd react-design-system
# Storybook 설치
npx storybook@latest init
# Tailwind 관련 라이브러리 설치
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install class-variance-authority tailwind-merge
# Storybook addon
npx storybook@latest add @storybook/addon-styling-webpack
export default {
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
/* 나머지 설정들.. */
}
/* Tailwind의 각 레이어에 대한 지시문을 추가 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 스토리북에서 테일윈드 클래스를 사용 가능하도록 추가 */
import "../src/index.css";
아래 설정은 선택 사항으로, 스토리북에서 컴포넌트 props type에 문자열 리터럴 유니온 타입을 좀더 구체적으로 표시하기 위해 설정한 내용이다. (활성화 시 기본 설정보다는 약간 느리지만 구문 분석의 정확도 향상 / 비활성화 시 모든 prop 종류 확인 어려움)
참고 링크:
- https://github.com/storybookjs/storybook/issues/12028#issuecomment-2088068738
- https://storybook.js.org/docs/api/main-config-typescript#reactdocgen
const config: StorybookConfig = {
/* 나머지 설정들.. */
typescript: {
reactDocgen: 'react-docgen-typescript',
}
}
어떤 UI 요소를 가장 처음으로 만들어 볼까 고민하다가, 버튼을 먼저 만들기로 결정하였다.
버튼은 대부분에 앱에서 사용 빈도가 높고, 필수적인 구성요소라고 봐도 거의 무방하기 때문에 디자인 시스템에 포함되기 좋은 예시라고 생각하였다.
버튼은 아래 이미지와 같이 다양한 Variation을 가질 수 있다. 분석해 보면 다음과 같이 그 특징을 분류해 볼 수 있다.
색상
: primary color, secondary color, ...모양
: border가 존재하는지? pill(알약 형태의 둥근) 버튼 형태인지? ...사이즈
: small, medium, large, ...상태
: hover 상태, disabled 상태, loading 상태, ...기타
: 아이콘을 포함하는 버튼인지? 링크 버튼인지? 따라서 버튼 컴포넌트는 이러한 각각의 case를 모두 대응할 수 있게 구현되어야 한다.
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";
const buttonStyles = cva("w-fit rounded-md justify-center items-center inline-flex cursor-pointer", {
variants: {
variant: {
primary: "bg-slate-900 hover:bg-slate-700 active:bg-slate-600",
danger: "bg-red-600 hover:bg-red-700 active:bg-red-800",
outlined: "border border-slate-200 bg-white hover:bg-slate-50 active:bg-slate-100",
subtle: "bg-slate-100 hover:bg-slate-200 active:bg-slate-300",
ghost: "bg-white/opacity-0 hover:bg-slate-100 active:bg-slate-200",
link: "bg-white/opacity-0 hover:underline",
},
size: {
sm: "h-8 px-3 gap-x-1",
md: "h-10 px-4 gap-x-1.5",
lg: "h-12 px-5 gap-x-2",
},
pill: {
true: "rounded-full",
},
loading: {
true: "cursor-wait",
},
disabled: {
true: "opacity-50 hover:bg- active:bg- cursor-not-allowed",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
});
const buttonTextStyles = cva(`font-normal font-sans my-auto`, {
variants: {
variant: {
primary: "text-white",
danger: "text-white",
outlined: "text-slate-900",
subtle: "text-slate-900",
ghost: "text-slate-900",
link: "text-slate-900",
},
size: {
sm: "text-xs",
md: "text-sm",
lg: "text-lg",
},
loading: {
true: "cursor-wait",
},
disabled: {
true: "cursor-not-allowed",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
});
type ButtonVariant = "primary" | "danger" | "outlined" | "subtle" | "ghost" | "link";
type ButtonSize = "sm" | "md" | "lg";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
type DivProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof buttonStyles> & {
/** 버튼의 생김새 */
variant?: ButtonVariant;
/** 둥근 모양인지 여부 */
pill?: boolean;
/** 버튼의 크기 */
size?: ButtonSize;
/** 버튼 비활성화 여부 */
disabled?: boolean;
/** 버튼에 표시할 내용 */
label?: string;
/** 로딩 중 여부 */
loading?: boolean;
/** 추가 클래스 */
className?: string;
/** 클릭 시 호출할 함수 */
onClick?: (e?: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => void;
};
const Button: React.FC<ButtonProps & DivProps> = ({
variant = "primary",
pill = false,
size = "md",
disabled = false,
label = "",
loading = false,
className,
onClick,
...props
}) => {
return (
<div
className={twMerge(
buttonStyles({ variant, size, pill, loading, disabled }),
className,
)}
onClick={!loading && !disabled ? onClick : undefined}
>
<button
className={twMerge(buttonTextStyles({ variant, size, loading, disabled }))}
disabled={disabled}
{...props}
>
{label}
</button>
</div>
);
};
export default Button;
import type { Meta, StoryObj } from "@storybook/react";
import Button from "./Button";
import { fn } from "@storybook/test";
// ① Default export
const meta: Meta<typeof Button> = {
title: "Primitives/Button",
tags: ["autodocs"],
component: Button,
parameters: {
layout: "centered",
},
args: { onClick: fn() },
};
export default meta;
// ② Defining story
type Story = StoryObj<typeof Button>;
export const Example: Story = {
args: {
label: "Button",
},
};
이후 터미널에서 npm run storybook
을 실행하고 브라우저 http://localhost:6006/ 주소로 접근해보면, 방금 만든 디자인 시스템 컴포넌트를 스토리북 문서에서 확인해 볼 수 있게 된다.
또한 Controls 애드온의 효과로, 컴포넌트를 코드 상에서 직접 수정해서 확인해 볼 필요 없이 GUI를 통해 실시간으로 여러 인수를 편집해 변화하는 모습을 확인할 수 있어 무척이나 편리하다.
이런 식으로, Example
과 같이 기본적인 스토리 뿐만 아닌 Variant button 및 Sized button와 같은 케이스에 대해서도 미리 만들어 두고 싶어서 아래와 같이 이어서 작성 해준다.
export const BasicButton: Story = {
decorators: [
(Story, context) => (
<div className="flex space-x-4">
<Story args={{ ...context.args, label: "Default" }} />
<Story args={{ ...context.args, label: "Destructive", variant: "danger" }} />
<Story args={{ ...context.args, label: "Cancel", variant: "outlined" }} />
<Story args={{ ...context.args, label: "Subtle", variant: "subtle" }} />
<Story args={{ ...context.args, label: "Ghost", variant: "ghost" }} />
<Story args={{ ...context.args, label: "Link", variant: "link" }} />
</div>
),
],
};
export const ButtonSize: Story = {
decorators: [
(Story, context) => (
<div className="flex space-x-4 space-y-4 items-baseline">
<Story args={{ ...context.args, label: "Small", size: "sm" }} />
<Story args={{ ...context.args, label: "Medium" }} />
<Story args={{ ...context.args, label: "Large", size: "lg" }} />
</div>
),
],
};
이번엔 Buttons/Docs
가 아닌 Buttons/Basic Button
페이지를 열어보면, 위에서 했던 것처럼 컴포넌트에 전달되는 인수를 바꿔 보고 값에 따라 컴포넌트가 어떤 모습을 띄는지 눈으로 확인할 수 있다.
그런데, 여기까지만 봤을 때 버튼 컴포넌트에 무언가 빠져 있는 느낌이 든다. 지금은 텍스트로만 이루어진 버튼에 대해서만 작성되었는데, 아이콘과 텍스트가 결합된 버튼도 필요하고 아이콘 으로만 이루어진 버튼도 추가로 필요하다.
따라서 이를 충족시키기 위한 Icon 컴포넌트도 만들어 보자.
컴포넌트에서 SVG 형식의 아이콘을 사용할건데, 이를 위해서는 프로젝트에 약간의 설정을 추가해줘야 한다. 아래 나열되는 내용들을 새로 만들어 작성하거나 설치해준다.
참고 링크:
- https://react-svgr.com/docs/rollup/
- https://stackoverflow.com/questions/70309561/unable-to-import-svg-with-vite-as-reactcomponent
npm i -D @svgr/rollup @rollup/plugin-url
/// <reference types="vite/client" />
/// <reference types="./types.d.ts" />
declare module "*.svg" {
import * as React from "react";
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "@svgr/rollup"; // 추가
import url from "@rollup/plugin-url"; // 추가
export default defineConfig({
plugins: [react(), url(), svgr()], // 추가
});
이제 디자인 시스템에서 사용할 SVG 아이콘 파일을 src/stories/assets/icon
위치에 놓고 다음과 같은 형태로 작성해준다.
import { ReactComponent as Delete } from "./delete.svg";
import { ReactComponent as Loader } from "./loader.svg";
import { ReactComponent as Mail } from "./mail.svg";
import { ReactComponent as Plus } from "./plus.svg";
import { ReactComponent as Send } from "./send.svg";
export default {
delete: Delete,
loader: Loader,
mail: Mail,
plus: Plus,
send: Send,
};
지금까지의 코드에 따르면 현재 보유하고 있는 IconType
의 종류는 "delete", "loader", "mail", "plus", "send" 이렇게 5가지가 있다.
간단하게 아이콘의 종류와 타입, 크기 등을 전달 받아 SVG 아이콘을 React Coponent로 보여주는 Icon 컴포넌트를 만들었다.
import { cx } from "class-variance-authority";
import icons from "../shared/assets/icons";
import { twMerge } from "tailwind-merge";
export type IconType = keyof typeof icons;
type IconVariant = "primary" | "outlined";
type IconSize = "sm" | "md" | "lg";
interface IconProps {
/** 아이콘 종류 */
name: IconType;
/** 아이콘 타입 */
variant?: IconVariant;
/** 아이콘 크기 */
size?: IconSize;
/** 적용할 클래스명 */
className?: string;
}
const Icon = ({ name, variant = "outlined", size = "md", className }: IconProps) => {
const iconClasses = twMerge(
cx("fill-none stroke-2", {
"stroke-white": variant === "outlined",
"stroke-slate-900": variant === "primary",
"w-4": size === "sm",
"w-5": size === "md",
"w-6": size === "lg",
}),
className,
);
const SVGIcon = icons[name];
return <SVGIcon className={iconClasses} />;
};
export default Icon;
추가로, 프로젝트에서 사용 가능한 아이콘들을 모두 모아서 한 데에 보여주면 좀 더 한눈에 들어오지 않을까?
이럴 때 사용하기 좋은 기능으로 스토리북의 IconGallery가 있다. 이를 활용해 모든 아이콘을 쉽게 문서화하여 깔끔하게 그리드 형태로 만들어서 보여줄 수 있다. 참고로 아래 파일은 Markdown 문법과 JavaScript/JSX를 혼합해 문서를 작성하기 위한 포맷인 mdx 구문으로 작성되었다.
import { Meta, IconGallery, IconItem } from "@storybook/blocks";
import { Icon as IconExample } from "./Icon";
<Meta title="Design System/Icons" />
# Icons
<IconGallery>
<IconItem name="delete">
<IconExample name="delete" variant="primary" />
</IconItem>
<IconItem name="loader">
<IconExample name="loader" variant="primary" />
</IconItem>
<IconItem name="mail">
<IconExample name="mail" variant="primary" />
</IconItem>
<IconItem name="send">
<IconExample name="send" variant="primary" />
</IconItem>
<IconItem name="plus">
<IconExample name="plus" variant="primary" />
</IconItem>
</IconGallery>
이제 아까 전 작성했던 Button 컴포넌트를 조금 수정해 아이콘도 포함하여 보여 줄 수 있도록 만들어 보자. 수정한 최종 코드는 다음과 같다.
import { twMerge } from "tailwind-merge";
import { Icon, type IconType } from "../Icon";
import { cva, type VariantProps } from "class-variance-authority";
const buttonStyles = cva("w-fit rounded-md justify-center items-center inline-flex cursor-pointer", {
variants: {
variant: {
primary: "bg-slate-900 hover:bg-slate-700 active:bg-slate-600",
danger: "bg-red-600 hover:bg-red-700 active:bg-red-800",
outlined: "border border-slate-200 bg-white hover:bg-slate-50 active:bg-slate-100",
subtle: "bg-slate-100 hover:bg-slate-200 active:bg-slate-300",
ghost: "bg-white/opacity-0 hover:bg-slate-100 active:bg-slate-200",
link: "bg-white/opacity-0 hover:underline",
},
size: {
sm: "h-8 px-3 gap-x-1",
md: "h-10 px-4 gap-x-1.5",
lg: "h-12 px-5 gap-x-2",
},
onlyIcon: {
true: {},
},
pill: {
true: "rounded-full",
},
loading: {
true: "cursor-wait",
},
disabled: {
true: "opacity-50 hover:bg- active:bg- cursor-not-allowed",
},
},
compoundVariants: [
{
size: "sm",
onlyIcon: true,
class: "px-2",
},
{
size: "md",
onlyIcon: true,
class: "px-2.5",
},
{
size: "lg",
onlyIcon: true,
class: "px-3",
},
],
defaultVariants: {
variant: "primary",
size: "md",
},
});
const buttonTextStyles = cva(`font-normal font-sans my-auto`, {
variants: {
variant: {
primary: "text-white",
danger: "text-white",
outlined: "text-slate-900",
subtle: "text-slate-900",
ghost: "text-slate-900",
link: "text-slate-900",
},
size: {
sm: "text-xs",
md: "text-sm",
lg: "text-lg",
},
loading: {
true: "cursor-wait",
},
disabled: {
true: "cursor-not-allowed",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
});
type ButtonVariant = "primary" | "danger" | "outlined" | "subtle" | "ghost" | "link";
type ButtonSize = "sm" | "md" | "lg";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
type DivProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof buttonStyles> & {
/** 버튼의 생김새 */
variant?: ButtonVariant;
/** 둥근 모양인지 여부 */
pill?: boolean;
/** 버튼의 크기 */
size?: ButtonSize;
/** 버튼 비활성화 여부 */
disabled?: boolean;
/** 버튼에 표시할 내용 */
label?: string;
/** 아이콘 종류 */
icon?: IconType;
/** 아이콘만 표시하는지 여부 */
onlyIcon?: boolean;
/** 로딩 중 여부 */
loading?: boolean;
/** 추가 클래스 */
className?: string;
/** 클릭 시 호출할 함수 */
onClick?: (e?: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => void;
};
const Button: React.FC<ButtonProps & DivProps> = ({
variant = "primary",
pill = false,
size = "md",
disabled = false,
label = "",
icon,
onlyIcon = false,
loading = false,
className,
onClick,
...props
}) => {
const iconVariant = variant === "primary" || variant === "danger" ? "outlined" : "primary";
const iconName = icon || (loading ? "loader" : null);
return (
<div
className={twMerge(
buttonStyles({ variant, size, onlyIcon, pill, loading, disabled }),
className,
)}
onClick={!loading && !disabled ? onClick : undefined}
>
{iconName && (
<div className="relative">
{
<Icon
name={iconName}
variant={iconVariant}
size={size}
{...(loading && { className: "animate-spin" })}
/>
}
</div>
)}
{!onlyIcon && (
<button
className={twMerge(buttonTextStyles({ variant, size, loading, disabled }))}
disabled={disabled}
{...props}
>
{label}
</button>
)}
</div>
);
};
export default Button;
그런 다음 스토리에도 아이콘 버튼 컴포넌트를 추가한다.
// 기존 내용...
export const IconButton: Story = {
decorators: [
(Story, context) => (
<div className="flex space-x-4">
<Story args={{ ...context.args, label: "Login with Email", icon: "mail" }} />
<Story args={{ ...context.args, variant: "outlined", label: "Send", icon: "send" }} />
<Story args={{ ...context.args, label: "Loading", loading: true, disabled: true }} />
<Story args={{ ...context.args, icon: "plus", onlyIcon: true, pill: true }} />
<Story args={{ ...context.args, icon: "plus", variant: "subtle", onlyIcon: true }} />
</div>
),
],
};
이제 아래와 같이 처음에 목표로 했던 모든 스타일의 버튼을 만들 수 있게 되었다.
이번 포스팅에서는 디자인 시스템의 개요에 대해 먼저 알아보고, 디자인 시스템에 포함되는 요소들에는 어떤 것들이 있는지, 그리고 마지막에는 스토리북이라는 도구를 활용해 디자인 시스템을 직접 구축해보는 방법에 대해서도 직접 실습을 해보며 연구해 보았다.
사실 지금처럼 Button 컴포넌트 하나만 만들어 놓고 디자인 시스템이라 부를 수는 없다. 이 버튼 요소를 시작으로, 실제 기업이나 조직에서 사용할 수 있는 스케일로 만드려면 수~많은 요소들을 더 만들어야 할 것이다.ㅎㅎ
또한 지금까지의 단계에서는 컴포넌트가 어떻게 동작하는지 혹은 어떻게 일관성을 동작하는지 확인할 수 있었다면 여기서 그치는 게 아니라 향후에는 유지보수를 위해 원격 저장소에 이 시스템을 커밋하고, 컴포넌트 테스트도 진행하며, 패키지 배포를 자동화 하는 방법에 대해서도 고려해야 한다.
스토리북 공식 가이드에서는 이러한 일련의 과정을 Workflow for design systems라 지칭하고 있다. 이에 맞춰 이 포스팅의 이어지는 글로는 빌드 이외의 나머지 워크플로우에 대해 다뤄 볼 예정이다.
여담으로는 MUI의 문서가 참고가 많이 됐다. 문서화의 중요성과 디자인 시스템 요소들을 나누고 상황 별 케이스에 대응할 수 있도록 적절하게 공통 컴포넌트를 만든다는 게 참 어려운 일인 것 같다고 다시 한번 느꼈다.