Creating dynamic component variants in Storybook 8
This page covers a specific failure mode in component variant testing: dynamic variants that look correct in the Controls panel but fail to render — or render with state leaked from a previous story. It is part of the broader Storybook isolation workflows topic.
Problem statement
You define a variant control in a CSF3 story. The Storybook Controls panel updates when you change the select value, but the rendered component does not change — or it briefly flickers to a different variant from a story that ran earlier. The symptom is reproducible across page reloads and appears in both dev mode and the built static output.
Root cause
Storybook 8 migrated from the runtime storiesOf API to compile-time CSF3 exports. This architectural shift has two consequences that interact badly with dynamic variants:
- Missing
optionsarray. WhenargTypesdefines acontrol: 'select'without an explicitoptionslist, Storybook falls back to a plain text input. The text input emits a raw string, but the component may expect a union literal that TypeScript narrowed at compile time — so the prop silently becomesundefined. - Shared context providers. Because each story runs inside the same iframe session (not separate processes), a global context set by one story persists if its
Provideris not unmounted before the next story mounts. Vite HMR makes this worse by module-caching theme objects between hot reloads.
The argtable mapping layer is where both failures originate: if the prop-to-control contract is not fully explicit, the controls and the DOM diverge.
Minimal reproduction
// src/components/Button/Button.stories.ts — BROKEN
export default { component: Button };
export const Dynamic = {
args: { variant: 'primary' },
argTypes: {
// Missing options: [] — Storybook falls back to free-text input
variant: { control: 'select' },
},
};
Running this and switching the select to secondary emits "secondary" as a string but the component receives it as undefined because TypeScript erased the union. Add the diagnostic hook below to confirm:
// .storybook/useArgsMutationLogger.ts
import { useEffect } from 'react';
export const useArgsMutationLogger = (args: Record<string, unknown>) => {
useEffect(() => {
console.debug('[SB8 Variant] Args mutated:', JSON.stringify(args, null, 2));
}, [args]);
};
Expected console output when the select is broken:
[SB8 Variant] Args mutated: { "variant": "primary" }
[WARN] @storybook/addon-controls: argType 'variant' missing valid options mapping. Fallback to text input.
The warning confirms the control is not emitting a value the renderer recognises.
Step-by-step fix
Step 1 — Declare options explicitly in argTypes
// src/components/Button/Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
};
export default meta;
type Story = StoryObj<typeof Button>;
export const DynamicButton: Story = {
args: { variant: 'primary' },
argTypes: {
variant: {
// Listing options tells the CSF3 compiler which values are valid.
// Without this, the select control emits raw strings the TS component ignores.
control: { type: 'select' as const },
options: ['primary', 'secondary', 'ghost', 'destructive'],
table: { category: 'Variant Mapping' },
},
},
};
What this does: the options array is serialised into Storybook’s compiled story index. The select control emits only values from that list, which TypeScript can narrow correctly at runtime.
Step 2 — Extract a pure variant generator
Move prop computation out of the story template into a side-effect-free utility. This makes it testable and prevents HMR caching issues.
// src/utils/variantGenerator.ts
export type VariantPayload = {
variant: 'primary' | 'secondary' | 'ghost' | 'destructive';
theme?: 'light' | 'dark';
disabled?: boolean;
};
// Pure: no side effects, no module-level state
export const generateVariantProps = (payload: VariantPayload) => ({
className: `btn-${payload.variant}`,
'data-variant': payload.variant,
'data-theme': payload.theme ?? 'light',
disabled: payload.disabled ?? false,
});
Step 3 — Isolate context providers in preview.ts
Wrap every story in a fresh provider so no story inherits theme state from a previous render. This is the fix for the context-leakage failure mode.
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import { ThemeProvider } from '../src/theme/ThemeProvider';
const preview: Preview = {
decorators: [
(Story, context) => {
// context.args.theme comes from the story's own args — never from a previous story
const theme = (context.args.theme as 'light' | 'dark') ?? 'light';
return (
// Each story mounts its own Provider instance, which React unmounts when the story exits
<ThemeProvider value={theme}>
<Story />
</ThemeProvider>
);
},
],
};
export default preview;
Step 4 — Assert variant state with play functions
Use @storybook/test interaction tests to confirm the variant is actually applied to the DOM, not just to args.
import { userEvent, within, expect } from '@storybook/test';
import type { StoryObj } from '@storybook/react';
import { Button } from './Button';
export const InteractiveVariant: StoryObj<typeof Button> = {
args: { variant: 'primary' },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Confirm the initial variant is applied
const btn = canvas.getByRole('button');
await expect(btn).toHaveAttribute('data-variant', 'primary');
// Confirm the component reacts to an args change driven by the test
await userEvent.click(canvas.getByRole('button', { name: /switch variant/i }));
await expect(canvas.getByTestId('component-root'))
.toHaveAttribute('data-variant', 'secondary');
},
};
Verification
Build Storybook and run the test runner against the static output to confirm the play function assertions pass:
npx storybook build --output-dir storybook-static
npx http-server storybook-static -p 6006 &
npx wait-on http://localhost:6006
npx test-storybook --url http://localhost:6006
Expected output:
PASS src/components/Button/Button.stories.ts
✓ Dynamic Button (248ms)
✓ Interactive Variant (391ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
If the play function assertion on data-variant fails, return to Step 1 and verify the options array includes the value you are asserting against.
CI matrix validation
Scale variant coverage by iterating the full options list in a Playwright loop, then capturing visual regression baselines with Chromatic.
# .github/workflows/storybook-variants.yml
name: Variant Validation
on: [push, pull_request]
jobs:
test-runner:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx storybook build --output-dir storybook-static
- run: npx playwright install --with-deps chromium
- name: Serve and run test-storybook
run: |
npx http-server storybook-static -p 6006 &
npx wait-on http://localhost:6006
npx test-storybook --url http://localhost:6006 --browsers chromium
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: npm ci
- uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
onlyChanged: true
autoAcceptChanges: main
exitZeroOnChanges: false # fail the job when unreviewed changes exist
The Playwright visual-regression matrix covers each variant individually:
// tests/variant-matrix.spec.ts
import { test, expect } from '@playwright/test';
const variants = ['primary', 'secondary', 'ghost', 'destructive'] as const;
for (const variant of variants) {
test(`visual regression: button-${variant}`, async ({ page }) => {
await page.goto(
`/iframe.html?id=components-button--dynamic-button&args=variant:${variant}`
);
// Wait for Storybook's root element to be stable before snapshotting
await page.locator('#storybook-root').waitFor({ state: 'visible' });
await expect(page.locator('#storybook-root')).toHaveScreenshot(
`button-${variant}.png`,
{ threshold: 0.05, maxDiffPixelRatio: 0.02 }
);
});
}
CSF3 architecture — how variant rendering changed from v7
The shift from storiesOf to CSF3 exports is the root of most v8 dynamic-variant confusion. This diagram shows where the two pipelines diverge.
Edge cases and caveats
- React 18 concurrent mode. With
createRoot, React may batch multiple arg updates into a single render. If yourplayfunction asserts DOM state immediately afteruserEvent.click, addawait new Promise(r => setTimeout(r, 0))after the click to flush pending renders before the assertion. - Vite static analysis and dynamic
options. If you computeoptionsfrom an imported enum (Object.values(Variant)), Vite’s static analyser may not include the module in the compiled story bundle. Either inline the array or add the enum’s module tooptimizeDeps.includein your Vite config. - Multiple frameworks in a monorepo. If you run Storybook for both React and Vue from the same
.storybook/directory using@storybook/core’s multiframework support,preview.tsdecorators are shared. A React-specificThemeProviderin the decorator will throw in the Vue renderer. Guard withif (context.globals.framework === 'react')or use separate preview files.
FAQ
Why does switching variants in the Controls panel revert on the next HMR update?
Storybook 8 restores initialArgs (the args defined in the story export) on every hot reload. Any runtime state you set by clicking controls is local to the browser session and is discarded when Vite invalidates the story module. This is intentional — HMR resets ensure stories stay deterministic. If you need a variant to persist across reloads, set it in the exported args object, not by clicking the panel.
Can I generate stories programmatically from an API or design token source?
Yes. Use Storybook’s storyIndexers API (introduced in v8) to register a custom indexer that reads a JSON token file at build time and emits synthetic CSF entries. The entries appear in the sidebar like hand-authored stories and support the full args/argTypes contract. See the Storybook docs on experimental_indexers for the API signature.
How do I test a variant that requires a specific viewport size?
Add a parameters.viewport key to the story — for example, set parameters.viewport.defaultViewport to 'mobile1' on a MobileGhost story alongside its args. The @storybook/addon-viewport addon maps mobile1 to 360×640. Chromatic captures the snapshot at this size if you set diffThreshold and delay in your Chromatic configuration.
Does this approach work with Storybook’s autodocs?
Yes. When tags: ['autodocs'] is set in the story meta, Storybook generates a docs page that renders every named story export as a live example. The argTypes options and controls you define appear in the auto-generated props table, so the same schema that powers variant switching also drives documentation.
Related
- Component Variants — the parent page covering the full variant testing workflow, snapshot baselines, and CI matrix execution
- Argtable Mapping — mapping complex props to typed Storybook controls without schema drift
- Interaction Testing with the play Function — writing
playfunction assertions for click, focus, and keyboard interactions - Visual Regression Snapshot Strategies — baseline management, pixel-diff algorithms, and tolerance thresholds for CI gating
- Storybook Isolation Workflows — the full isolation workflow covering addons, decorators, and test runner configuration