Skip to content

Storybook integration

The @cappa/plugin-storybook package bridges Storybook with Cappa’s screenshot pipeline. It fetches stories from a running Storybook instance, opens each story in Playwright, and captures images using the same comparison workflow as other targets. This guide walks through installing the plugin, connecting it to Storybook, and fine-tuning how screenshots are generated.

Add the Storybook plugin alongside the Cappa core packages inside your project:

Terminal window
pnpm add -D @cappa/plugin-storybook

Next, register the plugin inside cappa.config.ts so that the CLI knows how to discover stories. The storybookUrl should point at the running Storybook server.

import { defineConfig } from '@cappa/core';
import { cappaPluginStorybook } from '@cappa/plugin-storybook';
export default defineConfig({
outputDir: 'screenshots',
plugins: [
cappaPluginStorybook({
storybookUrl: 'http://localhost:6006',
}),
],
});

You can add additional plugin options to filter the stories that are captured:

  • includeStories – Optional predicate (story) => boolean. If provided, only stories for which it returns true are included. The argument is a StoryFilterContext with id, title, name, and filePath (the path "title/name", e.g. "Button/Primary").
  • excludeStories – Optional predicate (story) => boolean. If provided, stories for which it returns true are excluded (applied after includeStories).
  • defaultViewport – Override the default Playwright viewport when a story does not supply its own settings.
  • storybook – Render options applied to every story URL: viewMode, args, globals, query, fullscreen, and singleStory. Set globals here to pick the baseline that variants override — see Globals.

You can use minimatch or regex inside your predicates for glob-like behavior:

import { defineConfig } from '@cappa/core';
import {
cappaPluginStorybook,
type StoryFilterContext,
} from '@cappa/plugin-storybook';
import { minimatch } from 'minimatch';
export default defineConfig({
outputDir: 'screenshots',
plugins: [
cappaPluginStorybook({
storybookUrl: 'http://localhost:6006',
includeStories: (s: StoryFilterContext) => minimatch(s.id, 'button--*'),
excludeStories: (s: StoryFilterContext) => s.filePath.startsWith('Input/'),
}),
],
});

To quiet noisy console output while the plugin runs, toggle the global logConsoleEvents option inside cappa.config.ts:

export default defineConfig({
logConsoleEvents: false,
plugins: [
cappaPluginStorybook({
storybookUrl: 'http://localhost:6006',
}),
],
});

The plugin ships with a Storybook addon that exposes story-level configuration to Cappa. Enable it by adding @cappa/plugin-storybook to the Storybook addons list:

.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
addons: ['@cappa/plugin-storybook'],
};
export default config;

The addon registers a global decorator that collects parameters for each story. If you need to adjust Storybook’s runtime when Cappa is in control, you can use the exported isCappa helper in .storybook/preview.ts:

.storybook/preview.ts
import { isCappa } from '@cappa/plugin-storybook/browser';
if (isCappa()) {
// Disable animations, load mock data, etc.
}

Start Storybook in one terminal and keep it running so the plugin can connect to the storybookUrl. From another terminal, run the Cappa capture command from the directory that contains your config:

Terminal window
# Terminal 1
pnpm storybook
# Terminal 2
pnpm cappa capture

Cappa will fetch the list of stories, load each one in Playwright, and write screenshots, diffs, and reports to the configured output directory. Pair it with cappa review and cappa approve to follow the normal approval workflow.

Keep in mind that the dev server of storybook might introduce delays in the story loading. It is therefore recommended to build storybook before running cappa capture, and then run cappa on the resulting static files.

You can use start-server-and-test to start Storybook and Cappa in one command.

Terminal window
pnpm start-server-and-test storybook "pnpm cappa capture"

cappa capture --watch — and the review UI’s watch toggle — re-capture on file change, and this plugin can be precise about it. Storybook’s story index records the importPath of every story, so saving a story file re-captures only the stories that file declares:

↻ 2 tasks · src/components/Button.stories.tsx changed

Saving anything else — the component a story renders, a token file, a decorator — cannot be attributed from the index, so the plugin says so and every story is re-captured. That is deliberate: a mapping that guessed would quietly miss the regression you are hunting.

By default the watcher additionally watches **/*.stories.* and **/*.story.* on top of the project sources it watches anyway. Point watchPaths somewhere else when your stories globs do not reach:

cappaPluginStorybook({
storybookUrl: 'http://localhost:6006',
watchPaths: ['packages/ui/src/**/*.stories.tsx'],
});

Note that a story added since the last discovery re-runs the whole plugin rather than capturing nothing — the index cannot know about it yet, and doing nothing on a saved file is the more confusing answer.

Story-specific parameters can override how Cappa captures each story. Define them in your story file under parameters.cappa.

Option Description Default
skip Skip capturing the story. false
delay Wait (in ms) before taking the screenshot. Cappa emits a debug log before waiting when debug logs are enabled. null (no delay)
fullPage Capture the full page instead of the viewport. Inherits from global screenshot.fullPage in cappa.config.ts when not set. true
mask Array of selectors to blur or hide in the screenshot. []
omitBackground Capture the page with a transparent background. false
viewport Override the viewport size for the story. Playwright default
args Storybook args to render the story with, merged over storybook.args from cappa.config.ts. {}
globals Storybook globals to render the story with, merged over storybook.globals from cappa.config.ts. {}
variants Additional screenshots to capture with overrides. []
diff Override the global diff configuration for this story. Supports both { type: "pixel", ... } and { type: "gmsd", ... }. Inherits global diff from cappa.config.ts
Button.stories.tsx
export const Primary = {
parameters: {
cappa: {
delay: 250,
mask: ['.tooltip'],
diff: {
// Switch this story to gmsd
type: "gmsd",
threshold: 0.2,
downsample: 1,
},
variants: [
{
id: 'mobile',
label: 'Mobile',
options: {
viewport: { width: 375, height: 812 },
// Per-variant diff override (can also switch algorithm)
diff: {
type: "pixel",
threshold: 0.25,
maxDiffPixels: 10,
},
},
},
{
id: 'dark-mode',
label: 'Dark mode',
options: { omitBackground: true },
},
],
},
},
};

parameters are usually typed as any in storybook. To provide better types for cappa parameters, you can overwrite storybook parameters like this:

stories/types.d.ts
import type { CappaStorybookOptions } from "@cappa/plugin-storybook";
import "@storybook/react-vite"; // replace with your framework
declare module "@storybook/react-vite" { // replace with your framework
interface Parameters {
cappa?: CappaStorybookOptions;
}
}

You can instruct cappa to capture multiple screenshots of a story, e.g. with different viewport:

Button.stories.tsx
export const Primary = {
parameters: {
cappa: {
variants: [
{
id: 'mobile',
label: 'Mobile',
options: { viewport: { width: 375, height: 812 } },
},
],
},
},
};

The id is used to identify the variant, and the label is used to display the variant in the report. The options are mostly the same as the options for the story, but they are applied to the variant.

The filename is the name of the file that will be created for the variant. If not provided, it will be generated from the story name and the variant id.

You can set the args a story renders with under parameters.cappa.args, and override them per variant through options.args. Both merge over the plugin-level storybook.args from cappa.config.ts, so each level only needs to name the args it changes.

Button.stories.tsx
export const Primary = {
parameters: {
cappa: {
variants: [
{
id: 'small',
label: 'Small',
options: {
args: {
size: 'small',
},
},
},
],
},
},
};

Args only work if your component also is supporting these props AND spreads the args to the component.

Storybook globals work the same way as args: set them for a story under parameters.cappa.globals, or per variant through options.globals, layered over the plugin-level storybook.globals.

Button.stories.tsx
export const Primary = {
parameters: {
cappa: {
variants: [
{
id: 'dark',
label: 'Dark',
options: {
globals: { theme: 'dark' },
},
},
],
},
},
};

Args and globals are the only render inputs Storybook decodes from the iframe URL, which is what makes them settable per screenshot — unlike parameters, which are fixed when a story is prepared.

Cappa builds each story URL during discovery, before the story has told it anything, so a story that sets its own args or globals is loaded a second time with the rebuilt URL. Only stories that set them pay for that reload, and it happens before cappa waits for animations to settle and for the play function, so the capture always waits on the render it is about to shoot.

Capturing every story in two themes is the main thing variant globals are for. Drive the theme from a global — either with @storybook/addon-themes, or by hand:

.storybook/preview.ts
const preview: Preview = {
initialGlobals: { theme: 'light' },
globalTypes: {
theme: {
toolbar: {
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
},
},
},
decorators: [
(Story, context) => {
document.documentElement.dataset.theme = context.globals.theme;
return Story();
},
],
};

Two details matter for screenshots specifically. Put the attribute or class on document.documentElement (or body) rather than a wrapper element, so CSS variables defined on :root actually change. And give the body an explicit background per theme — Storybook’s canvas is transparent by default, so a dark theme otherwise renders onto white.

Then pin the baseline theme in your config and add one variant for the other:

cappa.config.ts
export default defineConfig({
plugins: [
cappaPluginStorybook({
storybookUrl: 'http://localhost:6006',
storybook: { globals: { theme: 'light' } },
}),
],
});
Button.stories.tsx
export const Primary = {
parameters: {
cappa: {
variants: [
{ id: 'dark', label: 'Dark', options: { globals: { theme: 'dark' } } },
],
},
},
};

That produces two screenshots per story: example/button/primary.png in light and example/button/primary--dark.png in dark.

If your story has a play function, cappa will wait for it to complete before capturing the screenshot.

Button.stories.tsx
export const Primary = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
},
};

This allows you to take interactive screenshots of your stories.