NexusCS

Storybook

JavaScript
Quick reference for Storybook 8+ with CSF 3, TypeScript, and interaction testing.
react
ui
testing
documentation
components

Getting started

Installation

# Initialize (auto-detects framework)
npm create storybook@latest

# Dev server
npm run storybook

# Build static
npm run build-storybook

Quick Example

import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "./Button";

const meta = {
  component: Button,
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    primary: true,
    label: "Button",
  },
};

Configuration Files

// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  framework: "@storybook/react-vite",
  stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: ["@storybook/addon-docs", "@storybook/addon-essentials"],
};
export default config;
// .storybook/preview.ts
import type { Preview } from "@storybook/react-vite";

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
  decorators: [
    /* global decorators */
  ],
};
export default preview;

Writing Stories (CSF 3)

Basic Story

import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "./Button";

const meta = {
  component: Button,
  title: "Components/Button", // Optional
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    primary: true,
    label: "Button",
  },
};

Story Naming

export const Secondary: Story = {
  name: "Secondary Button", // Custom name
  args: {
    primary: false,
    label: "Button",
  },
};

Reusing Args

export const Large: Story = {
  args: {
    ...Primary.args,
    size: "large",
  },
};

export const LargeSecondary: Story = {
  args: {
    ...Secondary.args,
    size: "large",
  },
};

Default Args

const meta = {
  component: Button,
  args: {
    primary: false, // Default for all stories
    disabled: false,
  },
} satisfies Meta<typeof Button>;

// Stories inherit defaults
export const Simple: Story = {
  args: {
    label: "Click me", // Only override what changes
  },
};

Args & Controls

ArgTypes

const meta = {
  component: Button,
  argTypes: {
    backgroundColor: {
      control: "color",
    },
    size: {
      control: { type: "select" },
      options: ["small", "medium", "large"],
    },
    onClick: {
      action: "clicked",
    },
  },
} satisfies Meta<typeof Button>;

Control Types

Type Use Case
text String input
boolean Checkbox
number Number input
range Slider
color Color picker
date Date picker
object JSON editor
select Dropdown
radio Radio buttons
check Checkboxes
multi-select Multiple selection

Control Options

argTypes: {
  variant: {
    control: { type: 'radio' },
    options: ['primary', 'secondary', 'tertiary'],
  },
  features: {
    control: { type: 'check' },
    options: ['icon', 'badge', 'tooltip'],
  },
  padding: {
    control: { type: 'range', min: 0, max: 100, step: 5 },
  },
}

Disabling Controls

argTypes: {
  onClick: {
    control: false,  // Hide control
  },
  internal: {
    table: { disable: true },  // Hide from docs
  },
}

Custom Rendering

Custom Render Function

export const InContainer: Story = {
  args: { label: 'Button' },
  render: (args) => (
    <div style={{ padding: '3em', backgroundColor: '#f0f0f0' }}>
      <Button {...args} />
    </div>
  ),
};

Multiple Components

export const ButtonGroup: Story = {
  args: {
    label: 'Button',
  },
  render: (args) => (
    <>
      <Button {...args} primary />
      <Button {...args} />
      <Button {...args} disabled />
    </>
  ),
};

With Context

export const Themed: Story = {
  args: { label: 'Themed Button' },
  render: (args) => (
    <ThemeProvider theme="dark">
      <Button {...args} />
    </ThemeProvider>
  ),
};

Using Loaded Data

export const WithData: Story = {
  render: (args, { loaded: { user } }) => (
    <UserProfile {...args} user={user} />
  ),
};

Decorators

Story-Level Decorator

export const Primary: Story = {
  decorators: [
    (Story) => (
      <div style={{ margin: '3em' }}>
        <Story />
      </div>
    ),
  ],
};

Component-Level Decorator

const meta = {
  component: Button,
  decorators: [
    (Story) => (
      <ThemeProvider theme="dark">
        <Story />
      </ThemeProvider>
    ),
  ],
} satisfies Meta<typeof Button>;

Multiple Decorators

const meta = {
  component: Button,
  decorators: [
    (Story) => (
      <div className="wrapper"><Story /></div>
    ),
    (Story) => (
      <ThemeProvider><Story /></ThemeProvider>
    ),
  ],
} satisfies Meta<typeof Button>;

Execution order: Global → Component → Story (outermost → innermost)

Global Decorators

// .storybook/preview.ts
const preview: Preview = {
  decorators: [
    (Story) => (
      <div style={{ margin: '3em' }}>
        <Story />
      </div>
    ),
  ],
};

Parameters

Layout Parameters

export const Primary: Story = {
  parameters: {
    layout: "centered", // 'centered' | 'fullscreen' | 'padded'
  },
};

Background Parameters

export const OnDark: Story = {
  parameters: {
    backgrounds: {
      default: "dark",
      values: [
        { name: "dark", value: "#333" },
        { name: "light", value: "#fff" },
      ],
    },
  },
};

Docs Parameters

export const Primary: Story = {
  parameters: {
    docs: {
      description: {
        story: "Primary button variant",
      },
    },
  },
};

Global Parameters

// .storybook/preview.ts
const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
};

TypeScript

Type Safety with satisfies

import type { Meta, StoryObj } from "@storybook/react-vite";

// ✅ Use satisfies for type safety
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary = {
  args: { invalidProp: true }, // ❌ TypeScript error!
} satisfies Story;

Custom Args Types

type PagePropsAndCustomArgs = React.ComponentProps<typeof Page> & {
  footer?: string;
};

const meta = {
  component: Page,
  render: ({ footer, ...args }) => (
    <Page {...args}>
      <footer>{footer}</footer>
    </Page>
  ),
} satisfies Meta<PagePropsAndCustomArgs>;

Generic Components

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map(renderItem)}</ul>;
}

const meta = {
  component: List<string>,
  args: {
    items: ['one', 'two', 'three'],
    renderItem: (item) => <li key={item}>{item}</li>,
  },
} satisfies Meta<typeof List<string>>;

Avoid Type Assertion

// ❌ Bad: 'as' loses type checking
export const Bad = {
  args: { invalidProp: true }, // No error!
} as Story;

// ✅ Good: 'satisfies' catches errors
export const Good = {
  args: { invalidProp: true }, // TypeScript error!
} satisfies Story;

Interaction Testing

Play Functions

import { expect, userEvent, within } from "@storybook/test";

export const FilledForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    const emailInput = canvas.getByLabelText("email", {
      selector: "input",
    });
    await userEvent.type(emailInput, "email@example.com", {
      delay: 100,
    });

    const submitButton = canvas.getByRole("button");
    await userEvent.click(submitButton);

    await expect(canvas.getByText("Success!")).toBeInTheDocument();
  },
};

Composing Play Functions

export const Combined: Story = {
  play: async (context) => {
    await FirstStory.play!(context);
    await SecondStory.play!(context);

    // Additional interactions
    const canvas = within(context.canvasElement);
    await userEvent.click(canvas.getByText("Next"));
  },
};

Testing Interactions

export const LoginFlow: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Fill form
    await userEvent.type(canvas.getByLabelText("Username"), "user");
    await userEvent.type(canvas.getByLabelText("Password"), "pass");

    // Submit
    await userEvent.click(canvas.getByRole("button", { name: /log in/i }));

    // Assert result
    await expect(canvas.getByText("Welcome!")).toBeInTheDocument();
  },
};

Queries

// By role (preferred)
canvas.getByRole("button", { name: /submit/i });

// By label
canvas.getByLabelText("Email");

// By text
canvas.getByText("Hello World");

// By test ID
canvas.getByTestId("custom-element");

Loaders

Async Data Loading

export const WithData: Story = {
  loaders: [
    async () => ({
      user: await fetchUser(),
    }),
  ],
  render: (args, { loaded: { user } }) => (
    <UserProfile {...args} user={user} />
  ),
};

Multiple Loaders

export const Dashboard: Story = {
  loaders: [
    async () => ({
      user: await fetchUser(),
    }),
    async () => ({
      posts: await fetchPosts(),
    }),
  ],
  render: (args, { loaded: { user, posts } }) => (
    <Dashboard {...args} user={user} posts={posts} />
  ),
};

Global Loaders

// .storybook/preview.ts
const preview: Preview = {
  loaders: [
    async () => ({
      currentUser: await getCurrentUser(),
    }),
  ],
};

MDX Documentation

Basic MDX Story

import { Meta, Canvas, Controls } from "@storybook/blocks";
import * as ButtonStories from "./Button.stories";

<Meta of={ButtonStories} />

# Button

Button component for user actions.

<Canvas of={ButtonStories.Primary} />
<Controls of={ButtonStories.Primary} />

Custom MDX Layout

import { Meta, Canvas, Story } from "@storybook/blocks";
import * as Stories from "./Button.stories";

<Meta of={Stories} />

# Button Component

## Variants

<Canvas>
  <Story of={Stories.Primary} />
  <Story of={Stories.Secondary} />
</Canvas>

## Usage

```typescript
import { Button } from './Button';

<Button primary label="Click me" />
```

### Documentation Blocks

```mdx
import {
  Meta,
  Canvas,
  Controls,
  Description,
  Stories,
  ArgTypes,
} from '@storybook/blocks';

<Meta of={ButtonStories} />
<Description of={ButtonStories} />
<Canvas of={ButtonStories.Primary} />
<ArgTypes of={ButtonStories} />
<Stories />
```

## Advanced Patterns
<!-- h2-classes: -two-column -->

### Story Templates

```typescript
const Template: Story = {
  render: (args) => <Button {...args} />,
};

export const Small: Story = {
  ...Template,
  args: { size: 'small', label: 'Small' },
};

export const Medium: Story = {
  ...Template,
  args: { size: 'medium', label: 'Medium' },
};
```

### Component Variants

```typescript
export const AllVariants: Story = {
  render: () => (
    <>
      <Button variant="primary" label="Primary" />
      <Button variant="secondary" label="Secondary" />
      <Button variant="tertiary" label="Tertiary" />
    </>
  ),
};
```

### Responsive Stories

```typescript
export const Mobile: Story = {
  parameters: {
    viewport: {
      defaultViewport: 'mobile1',
    },
  },
};

export const Desktop: Story = {
  parameters: {
    viewport: {
      defaultViewport: 'desktop',
    },
  },
};
```

### Dark Mode

```typescript
export const DarkMode: Story = {
  decorators: [
    (Story) => (
      <div data-theme="dark">
        <Story />
      </div>
    ),
  ],
  parameters: {
    backgrounds: { default: 'dark' },
  },
};
```

## Gotchas
<!-- h2-classes: -two-column -->

### Always Spread Args

```typescript
// ❌ Bad: Fixed label breaks Controls
export const Bad: Story = {
  render: (args) => <Button label="Fixed" />,
};

// ✅ Good: Spread args
export const Good: Story = {
  render: (args) => <Button {...args} />,
};
```

### Use satisfies Not as

```typescript
// ❌ Bad: Type assertion silences errors
export const Bad = {
  args: { invalidProp: true },  // No error!
} as Story;

// ✅ Good: satisfies catches errors
export const Good = {
  args: { invalidProp: true },  // TypeScript error!
} satisfies Story;
```

### Decorator Order Matters

```typescript
// Execution: Global → Component → Story
// Rendering: Story (innermost) → Component → Global (outermost)

const meta = {
  decorators: [
    (Story) => <Outer><Story /></Outer>,  // Renders second
  ],
};

export const MyStory: Story = {
  decorators: [
    (Story) => <Inner><Story /></Inner>,  // Renders first
  ],
};
```

### MDX Meta Expects Stories

```typescript
// ❌ Bad: Meta expects story file
import { Button } from './Button';
<Meta of={Button} />

// ✅ Good: Import story file
import * as ButtonStories from './Button.stories';
<Meta of={ButtonStories} />
```

### ArgTypes for JSX Values

```typescript
// JSX values need custom argTypes
const meta = {
  component: Card,
  argTypes: {
    icon: {
      control: false,  // Can't serialize JSX
      description: 'Icon component',
    },
  },
};
```

### CSF 2 Deprecated

```typescript
// ❌ Deprecated: CSF 2 Template.bind({})
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = { label: 'Button' };

// ✅ Modern: CSF 3 args pattern
export const Primary: Story = {
  args: { label: 'Button' },
};
```

## Also see
<!-- h2-classes: -one-column -->

- [Storybook Documentation](https://storybook.js.org/docs) _(storybook.js.org)_
- [Writing Stories](https://storybook.js.org/docs/writing-stories) _(storybook.js.org)_
- [Args API](https://storybook.js.org/docs/writing-stories/args) _(storybook.js.org)_
- [Play Functions](https://storybook.js.org/docs/writing-stories/play-function) _(storybook.js.org)_
- [TypeScript Guide](https://storybook.js.org/docs/writing-stories/typescript) _(storybook.js.org)_
- [CSF API](https://storybook.js.org/docs/api/csf) _(storybook.js.org)_
- [ArgTypes API](https://storybook.js.org/docs/api/arg-types) _(storybook.js.org)_