Storybookのstoriesの書き方、CSF3で迷わない実践手順
Storybookのstoriesをどう書くか毎回迷う人へ。CSF3、args/controls、autodocs、a11y、interaction test、ビジュアル回帰までコピペで動く形で解説。
「Storybook入れたはいいけど、storiesファイルの中、結局なに書けばいいの?」
僕が最初にぶつかったのがこれでした。Button.stories.tsx を開いて、export default に何を入れて、export const Primary の中身は args なのか render なのか、satisfies って要るのか要らないのか。公式を読んでも書き方が3世代くらい混ざっていて、コピペしたコードが赤線まみれになる。
正直、最初の数週間は「とりあえず動くやつ」をコピペして、型エラーを消すために any を撒いていました。今思うと最悪です。存在しない props を args に書いても誰も止めてくれないし、デザイナーがレビューしたい状態が1つも載っていない、ただの飾りカタログができあがっていました。
この記事は、その遠回りを全部終えた僕が「最初からこう書けばよかった」とまとめたものです。Storybook 10 / React + TypeScript + Vite を前提に、stories の書き方(CSF3)を軸に、args・controls・autodocs・a11y・操作テスト・ビジュアル回帰まで、コピペで動く形だけ置いていきます。デザインシステム全体の設計は別記事に分けたので、ここは純粋に「stories をどう書くか」に集中します。
この記事の要点
- stories の正解は CSF3。
metaをsatisfies Meta<typeof Component>で固め、各 story は{ args }だけ書くのが基本形。これで存在しないpropsを書くと型が即止める。 argsは story の入力値、controlsはそれを Storybook 上で触るツマミ。argTypesでselectやbooleanを指定すると、デザイナーが状態を手で切り替えられる。autodocsはtags: ['autodocs']を付けるだけで、stories とpropsの型からドキュメントページを自動生成する仕組み。完璧な仕様書を勝手に書く魔法ではない。play関数を書くと「入力して送信できるか」まで story の中で検証できる。テストユーティリティはstorybook/test(@は付かない。v8時代の@storybook/testから変わった)。- 見た目の崩れは人の目より差分検出が速い。Chromatic 等のビジュアル回帰テストを PR に挟むと、意図しない崩れだけが浮かび上がる。
- 設計全体はClaude Codeでデザインシステムを作るへ。この記事は stories の書き方そのものに絞る。
stories・args・controls、まず言葉を3つだけ揃える
最初に用語を3つだけ片付けます。ここがごちゃつくと、あとが全部ぼやけます。
| 言葉 | やさしく言うと | 具体例 |
|---|---|---|
| story | コンポーネントの「ある一つの状態」 | 押せる状態のボタン、エラー中のフォーム |
| args | その状態を作る入力値(props の値) | { variant: 'primary', loading: true } |
| controls | Storybook 画面で args を手で変えるツマミ | variant を select で切り替えるUI |
ボタン1個でも、「通常」「無効」「読み込み中」「長い文言」と状態はいくつもあります。その一つひとつが story です。そして各 story を「同じ props に違う値を入れたもの」として書けるようにしたのが CSF3 の args という考え方です。
ここを render: () => <Button variant="primary" /> のように毎回手書きしていたのが、昔の僕の間違いでした。それだと controls のツマミが効かないし、autodocs もうまく拾えません。値で表現できる状態は、必ず args で書く。これが最初の分かれ道です。
CSF3 でstoriesを書く、これがテンプレ
説明より実物です。まず素のボタンを用意します。Storybook 専用の架空 props を足さないのが鉄則で、アプリで本当に使う props だけを出します。
// src/components/Button.tsx
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'outline';
type ButtonSize = 'sm' | 'md' | 'lg';
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
};
const variantStyle: Record<ButtonVariant, CSSProperties> = {
primary: { background: '#2563eb', color: '#ffffff', borderColor: '#2563eb' },
secondary: { background: '#0f172a', color: '#ffffff', borderColor: '#0f172a' },
outline: { background: '#ffffff', color: '#0f172a', borderColor: '#94a3b8' },
};
const sizeStyle: Record<ButtonSize, CSSProperties> = {
sm: { minHeight: 32, padding: '0 12px', fontSize: 14 },
md: { minHeight: 40, padding: '0 16px', fontSize: 15 },
lg: { minHeight: 48, padding: '0 20px', fontSize: 16 },
};
export function Button({
children,
variant = 'primary',
size = 'md',
loading = false,
disabled,
style,
...props
}: ButtonProps) {
return (
<button
{...props}
disabled={disabled || loading}
aria-busy={loading || undefined}
style={{
...variantStyle[variant],
...sizeStyle[size],
borderWidth: 1,
borderStyle: 'solid',
borderRadius: 6,
fontWeight: 700,
cursor: disabled || loading ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.55 : 1,
...style,
}}
>
{loading ? '送信中...' : children}
</button>
);
}
ここからが本題の stories です。meta を satisfies Meta<typeof Button> で固めると、args に存在しない props を書いた瞬間に TypeScript が止めてくれます。各 story は args の差分だけ。これがコピペして使える最小テンプレです。
// src/components/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'], // このコンポーネントの自動ドキュメントを作る
args: {
// 全 story 共通の初期値。各 story はここからの差分だけ書く
children: '無料で試す',
variant: 'primary',
size: 'md',
},
argTypes: {
// controls のツマミの形を指定する
variant: { control: 'select', options: ['primary', 'secondary', 'outline'] },
size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] },
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
// 差分ゼロ = meta の args そのまま
export const Primary: Story = {};
export const Secondary: Story = {
args: { variant: 'secondary', children: '料金を見る' },
};
export const Loading: Story = {
args: { loading: true },
};
// 値で表せない「並べて見たい」状態だけ render を使う
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<Button size="sm">小</Button>
<Button size="md">中</Button>
<Button size="lg">大</Button>
</div>
),
};
ポイントは Primary が {} で済んでいることです。meta.args を継いでいるから、初期状態は一行も書かなくていい。render を使うのは AllSizes のように「複数を並べて比較したい」ときだけ。残りは全部 args の差分で表現します。書き方が CSF3 で統一されると、Claude Code に「stories を増やして」と頼んだときの出力もぶれなくなります。
controls と argTypes で「触れるカタログ」にする
args を書いただけだと、Storybook 画面の下に Controls パネルが出て、children や variant をその場でいじれます。文字列やbooleanは自動で推論してくれますが、variant のような選択肢は argTypes で control: 'select' を明示しないと、ただのテキスト入力になってしまいます。
共通設定は .storybook/preview.ts に置きます。色や日付っぽい props 名を自動でカラーピッカーや日付入力にするマッチャーを入れておくと、コンポーネントごとに書く手間が減ります。
// .storybook/preview.ts
import type { Preview } from '@storybook/react-vite';
const preview: Preview = {
tags: ['autodocs'], // 全コンポーネントで autodocs を既定にする
parameters: {
controls: {
matchers: {
color: /(background|color)$/i, // background / color で終わる props はカラーピッカーに
date: /Date$/i, // Date で終わる props は日付入力に
},
},
a11y: {
test: 'todo', // a11y 違反を検出だけして、まずは落とさない設定
},
},
};
export default preview;
ここで効くのが「デザイナーがコードを触らずに状態を確認できる」ことです。variant を select で切り替え、loading を on にして、長い文言を children に打ち込む。エンジニアに「ローディング中ってどう見えるの?」と聞かなくても、ツマミ一つで再現できる。これが controls の本当の価値で、stories を args で書いておく一番の見返りです。
autodocs は「型から自動でドキュメント」、過信はしない
tags: ['autodocs'] を付けると、Storybook が stories と props の型情報を読んで、ドキュメントページを1枚自動生成します。ButtonProps のコメントや型がそのまま表に並ぶので、variant に何を渡せるか、loading のデフォルトは何か、が一覧になります。
ただし名前に騙されないでください。autodocs は「完璧な仕様書を勝手に書く魔法」ではありません。やってくれるのは、あなたが書いた stories と型を整形して見せるところまで。説明文が空っぽなら、ドキュメントも空っぽです。なので僕は、props の型に短いコメントを足し、meta に parameters.docs.description.component で1〜2行の概要を書くようにしています。それだけで、自動生成ページが「読める仕様書」に変わります。
逆に言えば、stories が薄いと autodocs も薄い。ここでも「状態を args できちんと並べる」が効いてきます。
play 関数で「押せるか」までstoriesに閉じ込める
見た目が出るだけでは、フォームの本当の不安は消えません。「メール形式が変なら弾けるか」「送信したら成功メッセージが出るか」。これを story の中で確認できるのが play 関数です。story が描画された直後にユーザー操作を再現し、結果を expect で検証します。
まず検証対象のフォームを用意します。
// src/components/SignupForm.tsx
import type { FormEvent } from 'react';
import { useState } from 'react';
import { Button } from './Button';
export type SignupFormData = {
name: string;
email: string;
plan: 'free' | 'team';
};
type SignupFormProps = {
plan?: SignupFormData['plan'];
onSubmit?: (data: SignupFormData) => Promise<void> | void;
};
export function SignupForm({ plan = 'free', onSubmit }: SignupFormProps) {
const [message, setMessage] = useState('');
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const data: SignupFormData = {
name: String(form.get('name') ?? ''),
email: String(form.get('email') ?? ''),
plan,
};
if (!data.email.includes('@')) {
setMessage('正しいメールアドレスを入力してください。');
return;
}
await onSubmit?.(data);
setMessage('ありがとうございます。設定ガイドをお送りします。');
}
return (
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: 12, maxWidth: 360 }}>
<label>
名前
<input name="name" required style={{ display: 'block', width: '100%', minHeight: 36 }} />
</label>
<label>
メール
<input name="email" type="email" required style={{ display: 'block', width: '100%', minHeight: 36 }} />
</label>
<Button type="submit">ガイドを受け取る</Button>
{message ? <p role="status">{message}</p> : null}
</form>
);
}
stories 側で play を書きます。テストユーティリティの import 先は storybook/test です。ここ、@storybook/test と書いて延々ハマったことがあるので強調します。v8 の頃は @ 付きでしたが、今は @ なしの storybook/test に統一されました。操作とアサーションには必ず await を付けます。
// src/components/SignupForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn } from 'storybook/test';
import { SignupForm } from './SignupForm';
const meta = {
title: 'Components/SignupForm',
component: SignupForm,
args: {
plan: 'team',
onSubmit: fn(), // 呼ばれたかを後で検証するためのモック関数
},
} satisfies Meta<typeof SignupForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
// メール形式が不正なら、送信されずエラー文言が出る
export const InvalidEmail: Story = {
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByLabelText('名前'), 'Masa');
await userEvent.type(canvas.getByLabelText('メール'), 'masa.example.com');
await userEvent.click(canvas.getByRole('button', { name: 'ガイドを受け取る' }));
await expect(canvas.getByRole('status')).toHaveTextContent('正しいメール');
},
};
// 正しく入力すれば onSubmit が呼ばれ、成功文言が出る
export const SuccessfulSubmit: Story = {
play: async ({ canvas, userEvent, args }) => {
await userEvent.type(canvas.getByLabelText('名前'), 'Masa');
await userEvent.type(canvas.getByLabelText('メール'), '[email protected]');
await userEvent.click(canvas.getByRole('button', { name: 'ガイドを受け取る' }));
await expect(args.onSubmit).toHaveBeenCalledWith({
name: 'Masa',
email: '[email protected]',
plan: 'team',
});
await expect(canvas.getByRole('status')).toHaveTextContent('設定ガイド');
},
};
注目してほしいのは、play の引数で canvas と userEvent を直接受け取っている点です。少し前までは within(canvasElement) で囲んで自分で userEvent を import していましたが、今は context から渡ってきます。コードが短くなり、書き間違いも減りました。この play がそのまま後述のテスト実行でもアサーションとして走るので、「ドキュメント兼テスト」になります。
a11y と controls と play を1つのstoriesに重ねる
ここまでの要素は別々のものではなく、同じ story に重ねられます。1つのフォーム story が「見た目の確認」「controls での状態切り替え」「a11y チェック」「操作テスト」を同時に担う。これがコンポーネント駆動開発の気持ちよさです。
.storybook/main.ts で a11y アドオンと vitest アドオンを有効化しておきます。framework を @storybook/react-vite に揃えるのも大事で、ここがずれるとアドオンの解決で詰まります。
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-vitest'],
staticDirs: ['../public'],
};
export default config;
a11y アドオンは、role や label の欠落、コントラスト不足を story ごとに検出します。さっきの SignupForm で <label> を省いていたら、a11y パネルが赤くなる。play で getByLabelText が見つからずテストも落ちる。つまり「ラベルを付けないと、見た目以外の2か所で怒られる」状態を作れます。アクセシビリティの詳しい詰め方はClaude Codeでアクセシビリティ対応を進めるにまとめました。
ビジュアル回帰テストで「気づかない崩れ」を止める
操作テストは「動くか」を守りますが、「ピクセル単位で崩れていないか」は別の話です。CSS を1行いじったら、関係ないカードの余白がずれていた——これは expect では拾いにくい。ここでビジュアル回帰テスト(VRT)が効きます。stories のスクリーンショットを前回と比較し、差分が出た story だけをレビューに上げる仕組みです。
Storybook の stories をそのまま VRT に流せるのが Chromatic です。PR ごとに全 story を撮影し、見た目が変わった story だけハイライトしてくれます。CI に組み込む雛形がこれです。
# .github/workflows/storybook.yml
name: storybook
on:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test-storybook # play 関数を含む操作テスト
- run: npm run build-storybook # stories / MDX の壊れを検出
- name: Publish to Chromatic
if: ${{ secrets.CHROMATIC_PROJECT_TOKEN != '' }}
run: npx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}
Chromatic を使わない日でも、build-storybook だけは CI に入れてください。MDX の閉じ忘れや stories の import ミスを、レビュー前に機械が見つけてくれます。test-storybook の Vitest 連携の詳しいチューニングはVitestを実戦投入するが踏み込んでいます。
僕がstoriesで踏んだ落とし穴3つ
正直に書きます。最初の stories は地雷原でした。
1つ目は、Storybook 専用のダミー props を生やしたこと。toneForStorybook みたいな props を足したら見栄えは整いましたが、本番コードには存在しないので、story が「嘘の状態」を見せていました。デザイナーが OK を出した状態が、本番では再現できない。今は「args に書ける props は本番にあるものだけ」を絶対ルールにしています。
2つ目は、play をハッピーパスだけにしたこと。送信成功の story しか書いておらず、メール形式エラーと二重送信を見落としました。後からユーザーに踏まれて気づくやつです。最低でも「不正入力」「送信中」「無効状態」の story は分けて書く。失敗 story こそが効きます。
3つ目は、アニメーションと時刻を固定しなかったこと。ローディングのスピナーや new Date() をそのまま出していたら、Chromatic が毎回「差分あり」と言ってくる。本当の UI 変更が、無意味な差分に埋もれました。乱数・日付・外部画像・動きのある要素は、story の中で固定値にする。これでレビューが一気に静かになりました。
よくある質問
Q. CSF2 と CSF3、どっちで書けばいい?
A. 新規は迷わず CSF3 です。CSF2 は story を関数で書いていましたが、CSF3 は args を持つオブジェクトで書きます。記述量が減り、satisfies で型も効きます。古い記事のコピペで関数形式が混ざったら、オブジェクト形式に直すのが安全です。
Q. args と render、どう使い分ける?
A. 値で表せる状態は args、複数を並べて比較したい・特殊なラッパーが要るときだけ render。サイズ一覧のように「横に並べたい」story は render、単体の状態違いは args で十分です。
Q. テストユーティリティは @storybook/test? storybook/test?
A. 今は @ の付かない storybook/test です。v8 時代は @storybook/test でしたが変わりました。within を使わず、play の引数の canvas・userEvent を受け取る書き方が現行です。
Q. autodocs を付けたのにドキュメントが薄い。
A. autodocs は stories と型を整形するだけです。props の型にコメントを足し、meta の parameters.docs.description.component に概要を書き、状態の stories を増やすと中身が育ちます。
Q. ビジュアル回帰テストは必須?
A. 必須ではありませんが、CSS をよく触るチームほど見返りが大きいです。まずは build-storybook を CI に入れるところから。差分が気になり始めたら Chromatic を足す、で十分間に合います。
実際に試した結果
小さな React 構成で一周してみて、いちばん手戻りが減ったのは Button の見た目バリエーションではなく、SignupForm の失敗 story でした。成功だけ確認していた頃は、メール形式エラーと二重送信が後から漏れて出てきた。play で「不正入力」と「正常送信」を分け、CI で test-storybook と build-storybook を回すようにしたら、Claude Code に追加修正を頼む回数がはっきり減りました。
学びを一言にすると、stories は飾りカタログではなく「状態の契約書」だということです。CSF3 で args を並べ、controls で触れるようにし、play で操作を固定し、VRT で崩れを止める。この順番で1つのコンポーネントを仕上げると、二つ目からは驚くほど速くなります。stories の書き方で迷っていた過去の自分に、まずこの記事を渡したいです。
コンポーネント単体ではなく、トークンや配色も含めた設計全体を整えたい人はClaude Codeでデザインシステムを作るへ。依頼の型から固めたいなら無料チートシート、チームでテンプレ化するなら教材・テンプレート一覧が早いです。公式の最新仕様はStorybook の stories ドキュメントで確認しておくと、世代の混ざったコピペに振り回されずに済みます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。