컴포넌트의 다양한 경우를 정리할 수 있도록 도와주는 시각화 도구(tool)
예를 들어, 버튼 컴포넌트가 있으면 상태(데이터)에 따라서 UI나 액션이 달라질 수 있다.
variant 값이 primary
이면 일반버튼, dashed
이면 점선이 있는 버튼으로 버튼 UI를 표현할 수 있다.
점점 늘어나는 컴포넌트들을 한눈에 정리하기 힘들때,
스토리북은 이런 컴포넌트들을 시각적으로 정리 + 구현 코드를 문서화
디자이너와 협업할때 컴포넌트의 UI가 잘 구현되었는지 피드백을 받을 수 있으며,
다른 개발자와 협업할 때는 컴포넌트를 어떻게 구현했는지 + 사용방법을 스토리북을 통해 보여줄 수 있다.
아토믹 디자인 패턴을 사용한다면 스토리북은 더 강력한 툴이 될 수 있다.
파일명 컨벤션: <컴포넌트명>.stories.tsx
React + Typescript + Vite + Tailwind CSS + pnpm 기반 코드입니다.
// npm
npx storybook@latest init
// yarn
yarn dlx storybook@latest init
// pnpm
pnpm dlx storybook@latest init
설치하면 package.json에 scripts 부분에 storybook과 build-storybook 스크립트가 추가된 것 확인 가능.
각 스크립트는 스토리북을 로컬서버로 실행시키거나 스토리북을 빌드(원격 서버에 배포)해주는 역할을 한다.
폴더구조는 (yarn 기준) 대략 밑처럼 이루어집니다.
.
├── .storybook
├── node_modules
├── public
├── src
├── .gitignore
├── .index.html
├── LICENSE
├── package.json
├── yarn.lock
├── vite.config.js
└── README.md
스토리북 설치를 완료하면, .storybook
폴더 하위에 main.ts
와 preview.ts
파일이 생성됩니다.
main.ts
파일에서 스토리북에 대한 전반적인 설정을 할 수 있습니다.
// .storybook/main.ts
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
// 스토리 파일의 위치를 지정하는 배열. 여기서는 src 폴더 내 .mdx와 .stories.js, .stories.ts 등 확장자를 가진 파일들을 스토리로 불러옵니다.
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
// Storybook에 추가할 애드온 목록
addons: [
'@storybook/addon-onboarding', // Storybook을 처음 사용하는 사용자를 돕기 위한 온보딩 애드온
'@storybook/addon-essentials', // 기본적으로 필요한 기능 모음으로, Controls, Actions, Docs, Viewport 등이 포함
'@chromatic-com/storybook', // Chromatic과 연동하여 비주얼 테스팅과 UI 리뷰 기능을 추가하는 애드온
'@storybook/addon-interactions', // 상호작용 테스트 기능을 제공하여 스토리에서 사용자의 동작을 시뮬레이션할 수 있도록 지원
'@storybook/addon-postcss', // Tailwind CSS와 같은 PostCSS 기반의 CSS 프레임워크를 사용할 때 필요
],
// Storybook에서 사용할 프레임워크 및 빌더 설정
framework: {
name: '@storybook/react-vite', // React + Vite 기반의 Storybook 설정을 사용
options: {}, // 추가 옵션을 지정할 수 있지만 여기서는 기본 설정을 사용
},
};
export default config;
preview.ts
는 preview(미리보기) 화면에 대한 설정을 적용할 수 있습니다.
// .storybook/preview.ts
import "../src/index.css"; // Tailwind CSS 스타일 파일을 import
/** @type { import('@storybook/react').Preview } */ // Storybook의 Preview 타입을 지정하여 타입 자동 완성을 활성화
const preview = {
parameters: { // Storybook에서 사용할 전역 파라미터 설정
controls: { // 컨트롤 패널 설정으로, 각 props에 맞는 컨트롤러를 자동 지정
matchers: { // 특정 패턴을 가진 props를 자동으로 매핑
color: /(background|color)$/i, // props 이름에 'background' 또는 'color'가 포함되면 색상 선택기를 적용
date: /Date$/i, // props 이름이 'Date'로 끝나면 날짜 선택기를 적용
},
},
},
};
export default preview;
preview.ts
를 통해 UI가 어떻게 렌더링될 것인지 설정할 수 있습니다. preview.ts
내부에 CSS를 import하거나 Javascript를 로드할 수도 있는데,normalize.css
같은 CSS를 import 하면 브라우저 별로 조금씩 다른 CSS를 정리해 볼 수 있습니다. (크로스 브라우징)// npm
npm run storybook
// npm
yarn storybook
// pnpm
pnpm run storybook
기본으로 http://localhost:6006/ 포트에서 스토리북이 실행됩니다.
EXAMPLE
에 Button, Header, Page가 있습니다.src/stories
안에 있습니다 → 필요없으면 삭제해도 문제없습니다.<컴포넌트명>.stories.tsx
템플릿
import { Meta, StoryObj } from '@storybook/react';
import MyComponent from './MyComponent';
// 메타 데이터. 제너릭에 Button 컴포넌트의 타입을 넘겨준다.
const meta: Meta<typeof MyComponent> = {
title: 'Category/MyComponent', // 사이드바에 표시할 카테고리
component: MyComponent, // 컴포넌트
// 특정 스토리나 컴포넌트에 대해 다양한 설정을 적용하는 객체
parameters: {
layout: 'centered', // Storybook에서 컴포넌트의 레이아웃을 중앙에 배치
},
tags: ['autodocs'], // 컴포넌트에 대한 문서를 자동으로 생성
};
// 메타 데이터를 디폴트로 export
export default meta;
// 스토리 타입. StoryObj의 제너릭에 컴포넌트의 타입을 넘겨준다.
type Story = StoryObj<typeof MyComponent>;
// 하나의 스토리. 스토리는 named export 해준다.
// 스토리 이름도 사이드바 카테고리에 표시된다.
export const Default: Story = {
// 컴포넌트에 필요한 argumets
args: {
// propsname: value,
},
};
매번 이 템플릿을 작성하는건 반복 노가다이므로 snippet을 만들어서 쓰자.
Button.tsx
// src/Button.tsx
type ButtonProps = {
label: string;
onClick?: () => void;
variant?: "blue" | "gray";
};
const Button = ({ label, onClick, variant = "blue" }: ButtonProps) => {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-md text-white font-semibold ${
variant === "blue" ? "bg-blue-500 hover:bg-blue-600" : "bg-gray-500 hover:bg-gray-600"
}`}
>
{label}
</button>
);
};
export default Button;
Button.stories.tsx
// src/Button.stories.tsx
import { Meta, StoryObj } from "@storybook/react";
import Button from "./Button";
const meta: Meta<typeof Button> = {
title: "Category/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
onClick: { action: "clicked" },
variant: {
control: "select",
options: ["blue", "gray"],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Blue: Story = {
args: {
label: "Blue Button",
variant: "blue",
},
};
export const Gray: Story = {
args: {
label: "Gray Button",
variant: "gray",
},
};
화면
CATEGORY
부분에 Button 부분이 새로 생긴것을 볼 수 있습니다.Task.tsx
type TaskProps = {
task: {
id: string;
title: string;
state: "DEFAULT" | "PINNED" | "ARCHIVED";
};
onArchiveTask: (id: string) => void;
onPinTask: (id: string) => void;
};
const Task = ({ task: { id, title, state }, onArchiveTask, onPinTask }: TaskProps) => (
<div
className={`flex justify-between p-4 border ${
state === "ARCHIVED" ? "bg-gray-200" : "bg-white"
}`}
>
<label className="flex items-center">
<input
type="checkbox"
checked={state === "ARCHIVED"}
className="text-blue-500"
onClick={() => onArchiveTask(id)}
/>
<span className={`${state === "ARCHIVED" ? "line-through" : ""}`}>{title}</span>
</label>
{state !== "ARCHIVED" && (
<button
onClick={() => onPinTask(id)}
className={`p-1 ${
state === "PINNED"
? "text-blue-500 hover:text-blue-800"
: "text-gray-400 hover:text-gray-800"
} `}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M6 2a1 1 0 00-1 1v12a1 1 0 001.707.707L10 13.414l3.293 3.293A1 1 0 0015 15V3a1 1 0 00-1-1H6z" />
</svg>
</button>
)}
</div>
);
export default Task;
Task.stories.tsx
import Task from "./Task";
import { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof Task> = {
title: "Category/Task",
component: Task,
tags: ["autodocs"],
argTypes: {
onArchiveTask: { action: "onArchiveTask" },
onPinTask: { action: "onPinTask" },
},
};
export default meta;
type Story = StoryObj<typeof Task>;
export const Default: Story = {
args: {
task: {
id: "1",
title: "기본 업무",
state: "DEFAULT",
},
},
};
export const Pinned: Story = {
args: {
task: {
id: "2",
title: "고정된 업무",
state: "PINNED",
},
},
};
export const Archived: Story = {
args: {
task: {
id: "3",
title: "완료한 업무",
state: "ARCHIVED",
},
},
};
화면
필요한 이유: 로컬에서만 확인하는것을 넘어, 팀원 모두 UI 문서를 언제 어디서나 쉽게 접근할 수 있게 함
먼저 깃허브에 레포를 만들고, 밑의 과정을 진행한다.
(편의를 위해서 브랜치를 따로 안파고 master 브랜치에서 바로 작업했습니다.)
git init
git add .
git commit -m "first commit"
git remote add origin https://github.com/<your username>/<프로젝트 명>.git
git push -u origin master
pnpm add -D chromatic
→ 크로마틱에 로그인 https://www.chromatic.com/
→ 만든 폴더 이름과 동일한 이름으로 프로젝트 만든다.
→ Choose GitHub repo
를 클릭하고 저장소를 선택해주세요
pnpx chromatic --project-token=<project-token>
→ 완료되면 배포된 스토리북의 https://random-uuid.chromatic.com 링크를 받는다.
배포 완료 화면
매번 수정 후 수동으로 pnpx chromatic --project-token=<project-token>
하기 귀찮으면 자동화 ㄱㄱ
프로젝트의 기본 폴더에 .github
라는 새로운 디렉토리를 만들고 그 안에 workflows
라는 디렉토리를 만들어주세요. 그리고 그 안에 chromatic.yml
이라는 파일을 생성해주세요.
chromatic.yml
# Workflow name
name: "Chromatic Deployment"
# Event for the workflow
on: push
# List of jobs
jobs:
chromatic:
name: "Run Chromatic"
runs-on: ubuntu-latest
# Job steps
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
#👇 Adds Chromatic as a step in the workflow
- uses: chromaui/action@latest
# Options required for Chromatic's GitHub Action
with:
#👇 Chromatic projectToken, see https://storybook.js.org/tutorials/intro-to-storybook/react/ko/deploy/ to obtain it
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
CHROMATIC_PROJECT_TOKEN
설정 방법GITHUB_TOKEN
설정 방법CHROMATIC_PROJECT_TOKEN
을 입력하고, Value에 Chromatic에서 얻은 Project Token을 붙여 넣습니다.이후 밑의 작업을 순서대로 진행합니다.
git add .
git commit -m "GitHub action setup"
git push origin master
성공 여부 확인
chromatic.yml
에러 수정하느라 다른겁니다 ㅎㅎchromatic.yml
파일이 최종이라 걱정 안하셔도 됩니다.TaskList.tsx
import Task from "./Task";
type Ttask = {
id: string;
title: string;
state: "DEFAULT" | "PINNED" | "ARCHIVED";
};
type TaskListProps = {
loading: boolean;
tasks: Ttask[];
onPinTask: (id: string) => void;
onArchiveTask: (id: string) => void;
};
export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }: TaskListProps) {
const events = {
onPinTask,
onArchiveTask,
};
if (loading) {
return <div>로딩 중...</div>;
}
if (tasks.length === 0) {
return <div>업무가 없음!</div>;
}
return (
<div className="space-y-2">
{tasks.map((task) => (
<Task key={task.id} task={task} {...events} />
))}
</div>
);
}
TaskList.stories.tsx
import { Meta, StoryObj } from "@storybook/react";
import TaskList from "./TaskList";
import * as TaskStories from "./Task.stories";
const meta: Meta<typeof TaskList> = {
component: TaskList,
title: "Category/TaskList",
decorators: [(story) => <div>{story()}</div>],
tags: ["autodocs"],
args: {
...(TaskStories.Default.args || {}),
},
};
export default meta;
type Story = StoryObj<typeof TaskList>;
export const Default: Story = {
args: {
tasks: [
{ ...(TaskStories.Default.args?.task || {}), id: "1", title: "Task 1", state: "DEFAULT" },
{ ...(TaskStories.Default.args?.task || {}), id: "2", title: "Task 2", state: "DEFAULT" },
{ ...(TaskStories.Default.args?.task || {}), id: "3", title: "Task 3", state: "DEFAULT" },
{ ...(TaskStories.Default.args?.task || {}), id: "4", title: "Task 4", state: "DEFAULT" },
{ ...(TaskStories.Default.args?.task || {}), id: "5", title: "Task 5", state: "DEFAULT" },
{ ...(TaskStories.Default.args?.task || {}), id: "6", title: "Task 6", state: "DEFAULT" },
],
},
};
export const WithPinnedTasks: Story = {
args: {
tasks: [
...(Default.args?.tasks?.slice(0, 5) || []),
{ id: "6", title: "Task 6 (pinned)", state: "PINNED" },
],
},
};
export const Loading: Story = {
args: {
tasks: [],
loading: true,
},
};
export const Empty: Story = {
args: {
...Loading.args,
loading: false,
},
};
accept 전 / accept 후
화면
허정무 슛