How Storybook Story Composition Works with composeStories and composeStory
Storybook's portable-stories API transforms CSF (Component Story Format) modules into fully-composed story functions using composeStories for bulk operations and composeStory for individual stories, enabling execution outside the Storybook UI in test frameworks like Jest, Vitest, or Playwright.
Storybook's story composition system allows developers to reuse stories written in CSF format across different environments without the Storybook UI. According to the storybookjs/storybook source code, this functionality is implemented in the core preview API and provides a bridge between static story definitions and executable test functions.
The Core Composition Functions
The portable-stories API centers on two complementary utilities located in code/core/src/preview-api/modules/store/csf/portable-stories.ts. These functions convert static CSF exports into callable ComposedStoryFn objects that include rendering logic, args, globals, and play helpers.
composeStory
composeStory handles the composition of a single story annotation. Beginning at line 90 in portable-stories.ts, this function accepts a story's annotations, the component's meta (default export), and optional project-level annotations. It returns a ComposedStoryFn that encapsulates the story's execution context, including methods like load(), play(), and run().
composeStories
composeStories provides bulk processing for entire CSF modules. Implemented starting at line 74 in the same file, this utility iterates over named exports, filters non-story members using isExportStory, and maps each valid story through a composition function (defaulting to composeStory). It returns an object keyed by story names with composed functions as values.
How composeStory Works Internally
The composition process follows a deterministic normalization and preparation pipeline. In code/core/src/preview-api/modules/store/csf/portable-stories.ts, composeStory executes these distinct phases:
-
Input validation – Throws immediately if story annotations are undefined (lines 97-100).
-
Title resolution – Falls back to
DEFAULT_STORY_TITLEwhen component meta lacks a title (lines 104-106). -
Annotation normalization – Processes three layers of configuration:
- Component meta via
normalizeComponentAnnotations(line 106-107) - Story name resolution using
exportsNameorstoryAnnotations.storyName(lines 108-113) - Story annotation via
normalizeStory(lines 115-119) - Project annotations via
normalizeProjectAnnotations, merging global defaults and supplied configuration (lines 121-126)
- Component meta via
-
Story preparation – Calls
prepareStoryto create aPreparedStorycontaining args, loaders, and the play function (lines 128-132). -
Globals construction – Merges global-type defaults, initial globals, and story-specific globals (lines 134-140).
-
Telemetry setup – Instantiates a
ReporterAPIfor tracking (line 142). -
Context initialization – Builds a
StoryContextsupplying hooks, globals, args, and canvas, marking it as portable via__isPortableStory = true(lines 144-165). This context powers theplay,run, and callable story functions. -
Helper attachment – Exposes
load(runs loaders and beforeEach hooks),play(invokes the play function), andrun(renders viarunStory) throughObject.assign(lines 25-66).
The returned object conforms to the ComposedStoryFn type defined in code/core/src/types/modules/composedStory.ts (lines 40-55), providing a type-safe interface for test consumption.
How composeStories Processes CSF Modules
composeStories automates batch composition while maintaining individual story integrity. The implementation extracts the default export as metaExport (component meta) and processes remaining exports through this workflow:
- Export extraction – Captures the default export and individual story exports (lines 79-82).
- Meta resolution – Uses
getCsfFactoryAnnotationsto obtain story and meta annotations, defaulting to the first encountered component meta if none is explicit (lines 84-88). - Story filtering – Removes non-story exports using
isExportStory(lines 90-92). - Composition mapping – Applies the supplied
composeStoryFn(defaulting to corecomposeStory) to each story and assigns results to the output object under their export names (lines 93-95).
This design supports the common pattern of import * as stories from './Component.stories' while ensuring only valid stories undergo composition.
Practical Code Examples
Composing a Single Story
Use composeStory when you need granular control over one specific story:
import { composeStory } from '@storybook/react';
import * as meta from './Button.stories';
const Primary = composeStory(meta.CSF3Primary, meta.default);
// Execute in your test environment
await Primary.load();
await Primary.play({ canvasElement: document.body });
The Primary function now exposes .play(), .run(), and .load() methods, and can be called directly with custom args merged into the initial configuration.
Composing All Stories in a Module
Use composeStories for bulk operations across entire story files:
import { composeStories } from '@storybook/react';
import * as allStories from './Button.stories';
const { Primary, Secondary, WithLabel } = composeStories(allStories, {
// Optional project-level annotations
globals: { theme: 'dark' },
});
// Execute specific story interactions
await Secondary.load();
await WithLabel.play();
This approach automatically handles args, globals, and project configuration for every exported story, returning a plain object mapping story names to their composed functions.
Custom Composition for Test Frameworks
Override the default composition behavior to inject framework-specific setup:
import {
composeStories,
composeStory,
type ComposeStoryFn,
} from '@storybook/react';
import * as module from './Button.stories';
const customCompose: ComposeStoryFn = (story, meta, project) => {
const composed = composeStory(story, meta, project);
const originalPlay = composed.play;
// Inject viewport setup before play execution
composed.play = async (ctx) => {
await setViewport('iphone6');
await originalPlay?.(ctx);
};
return composed;
};
const { Primary } = composeStories(module, {}, customCompose);
await Primary.play(); // Executes with forced viewport
By passing a custom composeStoryFn to composeStories, you maintain the core normalization logic while adding test-specific behaviors like viewport manipulation or mock setup.
Summary
- Story composition converts static CSF exports into executable functions via
composeStoryandcomposeStoriesincode/core/src/preview-api/modules/store/csf/portable-stories.ts. composeStory(line 90) processes individual stories through validation, normalization vianormalizeComponentAnnotationsandnormalizeStory, preparation viaprepareStory, and helper attachment (load,play,run).composeStories(line 74) filters exports usingisExportStoryand batches composition across entire modules, returning a map ofComposedStoryFnobjects.- The resulting functions include a portable context (
__isPortableStory = true) that enables execution outside the Storybook UI with full support for args, globals, and play functions. - Renderers like React, Vue, and Svelte re-export these core utilities from
code/renderers/**/portable-stories.tswith framework-specific type definitions.
Frequently Asked Questions
What is the difference between composeStories and composeStory?
composeStory processes a single story annotation and its meta, returning one ComposedStoryFn suitable for isolated testing or dynamic selection. composeStories iterates over all named exports in a CSF module, filters non-stories using isExportStory, and composes each valid story, returning an object mapping story names to their composed functions. Use composeStory for individual story manipulation and composeStories for bulk import patterns common in test suites.
How do I use composed stories in test frameworks like Jest or Vitest?
Import composeStories from your framework-specific package (e.g., @storybook/react), pass your stories module and optional project annotations, then destructure the specific stories you need. Each composed story provides load(), play(), and run() methods that execute within your test environment. The composed functions maintain full access to args, globals, and the canvas element, allowing you to test component interactions outside the Storybook UI.
Can I customize the story composition process?
Yes. Both functions accept customization parameters. composeStory accepts optional project annotations to override globals and configuration. composeStories accepts a third parameter composeStoryFn where you can supply a custom implementation wrapping the core composeStory. This allows injection of test-specific behaviors like viewport setup, mock initialization, or telemetry reporting while preserving the normalization and preparation logic defined in code/core/src/preview-api/modules/store/csf/portable-stories.ts.
Where is the portable-stories API implemented in the Storybook source code?
The core implementation resides in code/core/src/preview-api/modules/store/csf/portable-stories.ts, with composeStories starting at line 74 and composeStory at line 90. Type definitions for ComposedStoryFn and ComposeStoryFn are located in code/core/src/types/modules/composedStory.ts. Framework-specific renderers (React, Vue, Svelte) re-export these utilities from code/renderers/[framework]/src/portable-stories.ts, often providing type-safe overloads while delegating to the core composition logic.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →