skip to content

A dropdown only looks wrong once it is open, and a form only shows its error styling after a failed submit. How do you get interaction-dependent states like these into a Storybook-driven visual baseline?

level: middleimportance: should knowfreq 45%

answer

  1. mount-and-stop misses user states
  2. play runs after render
  3. capture waits on the promise
  4. props are fast but skip reachability
  5. one interaction, one named state

basics

~20 s

Drive the interaction in the story's play function, which runs after render so the capture happens on the post-interaction frame — or model the state as a prop and give it its own story. Play proves reachability; props are faster.

solid answer

~50 s

There are two routes and they trade off differently. A story can define a `play` function that runs after the component mounts; it queries the rendered output and drives real interactions with `userEvent`, and the snapshot is taken once `play` has finished, so the open menu or the error state is what gets captured. Alternatively you model the state as data — pass the prop that puts the component in it — and the story renders straight into that state with no interaction at all. Play functions exercise the real code path, so they also prove the state is *reachable*; they cost run time and are sensitive to timing. Prop-driven stories are instant and deterministic, but they can render a state the real component can no longer get into, so a broken toggle passes. Use props when the state genuinely is a prop, and `play` when the state is produced by internal logic you want proven.

code

typescript · 21 lines
typescript
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Select } from './Select';

const meta: Meta<typeof Select> = { component: Select };
export default meta;

type Story = StoryObj<typeof Select>;

export const Closed: Story = {
  args: { label: 'Country', options: ['Estonia', 'Finland'] },
};

export const Open: Story = {
  args: Closed.args,
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: 'Country' }));
    await expect(canvas.getByRole('listbox')).toBeVisible();
  },
};

go deeper

for a junior

Know that a story can run interactions after it renders via a play function, and that the visual capture happens after that function completes.

for a middle

Explain both routes to an interaction state — play-driven and prop-driven — and say what each one proves and what it costs in run time and timing sensitivity.

for a senior

Show the choice rule you apply per state, and explain why a prop-forced state can pass while the real interaction that reaches it is broken.

for a principal

Own the budget: how many interactive stories a suite can carry before feedback time degrades, and where behavioural coverage should take that load instead.

## The gap being closed A plain story mounts a component and stops. That captures every state expressible as input, and none of the states a user produces: an open menu, a focused-and-invalid field, a tooltip on hover, a form after a failed submit, a wizard on step three. Those are exactly the states where visual bugs cluster, because they are the states nobody looks at during development. ## Route one: the play function A story can carry a `play` function, invoked after the story renders. It receives a context object including `canvasElement`, the DOM node the story rendered into, and the usual pattern is to scope queries to that node with `within(canvasElement)` and then drive interactions with `userEvent`. ```ts export const Open = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole('button', { name: 'Country' })); }, }; ``` The key property for visual testing is ordering: the snapshot for a story with a `play` function is taken after `play` resolves, so the image records the post-interaction frame. That is why `play` steps are written with `await` throughout — the promise chain is what the capture waits on. A `play` function that fires an interaction without awaiting it hands back control early, and the shutter can fire on the pre-interaction frame. A second, underrated benefit: a `play` function that reaches the state at all is evidence the state is reachable. If a refactor breaks the toggle, `play` fails to find the opened content and the story errors rather than quietly capturing a closed menu. ## Route two: model the state as data Many "interaction" states are really just props. A controlled component that takes `open`, `selected`, `invalid` or `step` can be rendered directly into the state with `args`, no interaction required. This is instantaneous, has nothing to await, and cannot fail for timing reasons. The blind spot is the mirror image of `play`'s benefit. A prop-driven `Open` story proves the open menu *looks* right; it says nothing about whether a user can still open it. If the component's internal state and the prop have drifted apart — or if the prop exists only for the story — the baseline is documenting a state the product cannot reach. ## Choosing between them A workable rule: - The state is genuinely part of the component's public API (a controlled `open`, a `variant`, an externally supplied error) → **args**. It is data; render it as data. - The state is produced by the component's own logic (an uncontrolled disclosure, validation triggered on submit, a multi-step flow) → **play**. Forcing it through a back door would be testing a fiction. - The state is expensive or timing-heavy to reach and its appearance is what you care about → **args**, plus a separate behavioural test that the interaction reaches it. Splitting the two concerns is often cheaper than one slow visual story. ## What interactive stories cost Every `play` function adds wall-clock time to the run, and a suite where most stories interact is a suite that takes minutes rather than seconds. They also introduce the only genuine timing surface a story-based visual suite has: content that animates in, content that arrives asynchronously, and assertions that resolve before the visual settles. Keeping `play` functions short and specific — one interaction to reach one state, not a scripted user journey — keeps that surface small. A `play` function that performs eight steps is really an end-to-end test wearing a story's clothes, and it will be slow, hard to attribute, and the first thing to break. ## Naming and granularity Because each state becomes its own baseline, name stories by the state rather than by the interaction: `Open`, `WithValidationErrors`, `StepTwo`. That keeps the diff report readable, and it makes it obvious in review which visual states of a component have a pinned appearance and which do not.

  • When would you prefer an args-driven story over a play function for the same visual state?
    When the state is genuinely a prop of a controlled component, and when run time matters. Args render instantly, have nothing to await and cannot fail for timing reasons, which makes them the right default for variants and externally supplied error states. Reserve `play` for states only the component's own logic can produce.
  • What goes wrong when a play function scripts a long user journey?
    It stops being a story and becomes a slow end-to-end test with a single screenshot at the end. Every step adds run time and another chance to fail for reasons unrelated to appearance, and when it does fail the diff no longer attributes to one state. Keep each interactive story to one interaction reaching one named state.

saying these in an interview costs you the question

  • Screenshots only the default rendered state and calls the component covered.
  • Assumes the snapshot is taken before the play function has finished.
  • Adds a fixed delay after clicking instead of awaiting the interaction.
  • Forces an open state with a prop the real component never sets.
  • Scripts a multi-step user journey inside a single story's play function.

context