Storybook 8 advances component development with built-in interaction testing via play functions — const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")) simulates user interactions that run in the story. args define story inputs; argTypes configure controls with options, descriptions, and defaults. decorators wrap stories in context providers like React Query, i18n, and theme. composeStories from @storybook/react imports stories into Vitest tests. @storybook/test provides expect, within, userEvent, and waitFor directly. The a11y addon runs axe audits on every story. autodocs: "tag" generates API docs from TypeScript types and JSDoc comments. parameters.backgrounds defines theme variants. Claude Code generates Storybook stories with play functions, decorator patterns, interaction tests, a11y configurations, and the composeStories integration for Vitest.
CLAUDE.md for Storybook Advanced
## Storybook Stack
- Version: storybook >= 8.1, @storybook/react >= 8.1, @storybook/test >= 8.1
- Story: Meta<typeof Component> + StoryObj<typeof Component> — CSF3 format
- Play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); ... }
- userEvent: await userEvent.click/type/selectOptions — from @storybook/test
- Args: args: { label: "Click me" } — argTypes: { label: { control: "text" } }
- Decorator: decorators: [(Story) => <QueryProvider><Story /></QueryProvider>]
- Test: composeStories(import("./Button.stories")) — in Vitest
- a11y: parameters: { a11y: { config: { rules: [{ id: "color-contrast", enabled: true }] } } }
- Docs: tags: ["autodocs"] — generates docs page from types + JSDoc
Component Stories with Play Functions
// components/ui/Button.stories.tsx — interaction testing
import type { Meta, StoryObj } from "@storybook/react"
import { within, userEvent, expect, waitFor } from "@storybook/test"
import { Button } from "./Button"
const meta: Meta<typeof Button> = {
title: "UI/Button",
component: Button,
tags: ["autodocs"],
parameters: {
layout: "centered",
// a11y addon configuration
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "button-name", enabled: true },
],
},
},
},
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "ghost", "danger"],
description: "Visual style variant",
},
size: {
control: "select",
options: ["sm", "md", "lg"],
},
isLoading: {
control: "boolean",
},
isDisabled: {
control: "boolean",
},
onClick: { action: "clicked" },
},
}
export default meta
type Story = StoryObj<typeof Button>
// Basic story
export const Default: Story = {
args: {
children: "Click me",
variant: "primary",
size: "md",
},
}
// Story with click interaction test
export const ClickInteraction: Story = {
args: {
children: "Submit",
variant: "primary",
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button", { name: "Submit" })
// Verify initial state
expect(button).toBeEnabled()
expect(button).not.toHaveAttribute("aria-busy", "true")
// Click the button
await userEvent.click(button)
// Verify onClick was called (from args.onClick action)
expect(args.onClick).toHaveBeenCalledOnce()
},
}
// Loading state test
export const LoadingState: Story = {
args: {
children: "Processing",
isLoading: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button")
expect(button).toBeDisabled() // Loading buttons should be disabled
expect(button).toHaveAttribute("aria-busy", "true") // Or check for spinner
expect(canvas.getByText("Processing")).toBeInTheDocument()
},
}
// All variants
export const AllVariants: Story = {
render: () => (
<div className="flex gap-3 flex-wrap">
{(["primary", "secondary", "ghost", "danger"] as const).map(variant => (
<Button key={variant} variant={variant}>{variant}</Button>
))}
</div>
),
}
Form Story with Validation
// components/forms/LoginForm.stories.tsx — complex form interaction testing
import type { Meta, StoryObj } from "@storybook/react"
import { within, userEvent, expect, waitFor, fn } from "@storybook/test"
import { LoginForm } from "./LoginForm"
const meta: Meta<typeof LoginForm> = {
title: "Forms/LoginForm",
component: LoginForm,
parameters: { layout: "centered" },
args: {
onSubmit: fn(), // fn() — tracked mock function from @storybook/test
},
}
export default meta
type Story = StoryObj<typeof LoginForm>
export const EmptySubmit: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
// Submit empty form
const submitButton = canvas.getByRole("button", { name: /sign in/i })
await userEvent.click(submitButton)
// Verify validation errors appear
await waitFor(() => {
expect(canvas.getByText(/email is required/i)).toBeInTheDocument()
expect(canvas.getByText(/password is required/i)).toBeInTheDocument()
})
},
}
export const InvalidEmail: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText(/email/i), "notanemail")
await userEvent.type(canvas.getByLabelText(/password/i), "password123")
await userEvent.click(canvas.getByRole("button", { name: /sign in/i }))
await waitFor(() => {
expect(canvas.getByText(/invalid email/i)).toBeInTheDocument()
})
},
}
export const SuccessfulSubmit: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
await userEvent.type(canvas.getByLabelText(/email/i), "[email protected]")
await userEvent.type(canvas.getByLabelText(/password/i), "SecurePass123!")
await userEvent.click(canvas.getByRole("button", { name: /sign in/i }))
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "SecurePass123!",
})
})
},
}
export const KeyboardNavigation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
// Tab through form
await userEvent.tab()
expect(canvas.getByLabelText(/email/i)).toHaveFocus()
await userEvent.tab()
expect(canvas.getByLabelText(/password/i)).toHaveFocus()
await userEvent.tab()
expect(canvas.getByRole("button", { name: /sign in/i })).toHaveFocus()
// Submit with Enter
await userEvent.keyboard("{Enter}")
},
}
Decorators for Context
// .storybook/decorators.tsx — reusable story decorators
import type { Decorator } from "@storybook/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { ThemeProvider } from "@/components/ThemeProvider"
// Wrap with React Query
export const withQueryClient: Decorator = (Story) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
return (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
)
}
// Wrap with Router (for stories using Link/useNavigate)
export const withRouter: Decorator = (Story, context) => {
const path = context.parameters.router?.path ?? "/"
return (
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="*" element={<Story />} />
</Routes>
</MemoryRouter>
)
}
// Wrap with theme
export const withTheme: Decorator = (Story, context) => {
const theme = context.globals.theme ?? "light"
return (
<ThemeProvider forcedTheme={theme}>
<div className={theme === "dark" ? "dark" : ""}>
<Story />
</div>
</ThemeProvider>
)
}
// .storybook/preview.ts — global decorators and parameters
import type { Preview } from "@storybook/react"
import { withQueryClient, withRouter, withTheme } from "./decorators"
import "@/styles/globals.css"
const preview: Preview = {
decorators: [withTheme, withQueryClient, withRouter],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
backgrounds: {
default: "light",
values: [
{ name: "light", value: "#ffffff" },
{ name: "dark", value: "#0f172a" },
],
},
viewport: {
viewports: {
mobile: { name: "Mobile", styles: { width: "375px", height: "812px" } },
tablet: { name: "Tablet", styles: { width: "768px", height: "1024px" } },
desktop: { name: "Desktop", styles: { width: "1440px", height: "900px" } },
},
},
},
}
export default preview
composeStories for Vitest
// components/ui/Button.test.tsx — run stories as unit tests
import { composeStories } from "@storybook/react"
import { render } from "@testing-library/react"
import { describe, it, expect } from "vitest"
import * as stories from "./Button.stories"
const { Default, LoadingState, ClickInteraction } = composeStories(stories)
describe("Button", () => {
it("renders primary button", () => {
const { getByRole } = render(<Default />)
expect(getByRole("button")).toBeInTheDocument()
})
it("shows loading state", () => {
const { getByRole } = render(<LoadingState />)
expect(getByRole("button")).toBeDisabled()
})
it("runs play function interaction test", async () => {
const { container } = render(<ClickInteraction />)
// Execute the play function from the story
await ClickInteraction.play!({ canvasElement: container })
})
})
For the Ladle alternative when a faster, simpler story runner with Vite-native builds and no Webpack dependency is preferred for a small component library — Ladle supports MDX and CSF stories with a minimal addon ecosystem but lacks Storybook’s interaction testing ecosystem, see the component story guide. For the Histoire alternative when Vue or Svelte component stories are the primary use case — Histoire is the Vue/Svelte equivalent of Storybook with a similar variant/story API, better suited for those frameworks than Storybook’s React-centric approach, see the component documentation guide. The Claude Skills 360 bundle includes Storybook Advanced skill sets covering play functions, decorators, and composeStories integration. Start with the free tier to try component story generation.