
Play functions are little bits of code that run right after your component is rendered in Storybook.
They let you interact with your components and test out different scenarios, especially things that would normally need a user to click buttons or type stuff in.
It’s like having a little script that automates user actions so you can test them easily.
Setup the interactions addon
If you’re planning on using play functions, you should definitely install Storybook’s interactions addon first.
It’s a great companion and gives you a really helpful set of controls in the UI. You can pause, rewind, fast-forward, and even step through your interactions one by one.
Plus, it has a built-in debugger, which is super handy for finding and fixing any problems. Just run this command to install it and its dependencies.
npm install @storybook/test @storybook/addon-interactions --save-dev
Next, you’ll need to tell Storybook about the interactions addon. Do this by updating your Storybook configuration file, which is usually .storybook/main.js or .storybook/main.ts. Here's how it looks in .storybook/main.ts:
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
const config: StorybookConfig = {
framework: '@storybook/your-framework',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
// Other Storybook addons
'@storybook/addon-interactions', // 👈 Register the addon
],
};
export default config;
Writing stories with the play function
Storybook’s play functions are little bits of code that run after a story is done rendering.
When you combine them with the interactions addon, you can simulate user interactions and test things that you couldn’t test before without actually clicking and typing.
Imagine you’re building a registration form and want to check if the validation works. You could use a play function in your story to do just that, like this:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { RegistrationForm } from './RegistrationForm';
const meta: Meta<typeof RegistrationForm> = {
component: RegistrationForm,
};
export default meta;
type Story = StoryObj<typeof RegistrationForm>;
/*
* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emailInput = canvas.getByLabelText('email', {
selector: 'input',
});
await userEvent.type(emailInput, 'example-email@email.com', {
delay: 100,
});
const passwordInput = canvas.getByLabelText('password', {
selector: 'input',
});
await userEvent.type(passwordInput, 'ExamplePassword', {
delay: 100,
});
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
const submitButton = canvas.getByRole('button');
await userEvent.click(submitButton);
},
};
Once Storybook is done rendering the component, it automatically runs the code inside your play function.
This code can simulate someone filling out the form, interacting with the component, and so on. You don’t have to do anything! If you look at the Interactions panel, you’ll see a breakdown of each step that was executed.
Composing stories
Because Storybook uses the Component Story Format (CSF), which is based on ES6 modules, you can reuse and combine your play functions just like other Storybook features, such as args. For instance, if you want to test a particular workflow in your component, you can create separate, composable stories like this:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
/*
* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const FirstStory: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId('an-element'), 'example-value');
},
};
export const SecondStory: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId('other-element'), 'another value');
},
};
export const CombinedStories: Story = {
play: async ({ context, canvasElement }) => {
const canvas = within(canvasElement);
// Runs the FirstStory and Second story play function before running this story's play function
await FirstStory.play(context);
await SecondStory.play(context);
await userEvent.type(canvas.getByTestId('another-element'), 'random value');
},
};
Combining stories like this lets you simulate the complete workflow of your component. This makes it easier to find potential problems and also reduces the amount of repetitive code you have to write.
Working with events
Modern user interfaces are all about interaction — clicking buttons, choosing options, checking boxes, and so on. This makes for a much better user experience.
With Storybook’s play functions, you can bring that same level of interactivity to your stories.
Clicking a button is a typical example of a component interaction. Here’s how you’d simulate a button click in your story using a play function:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { fireEvent, userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
/* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const ClickExample: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
await userEvent.click(canvas.getByRole('button'));
},
};
export const FireEventExample: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
await fireEvent.click(canvas.getByTestId('data-testid'));
},
};
When Storybook loads the story and runs the play function, it will automatically click the button in your component, just like a real user would.
Besides clicks, you can use play functions to simulate other user actions too. For example, if your component has a dropdown menu, you can write a story like this to test different options:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
// Function to emulate pausing between interactions
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const ExampleChangeEvent: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const select = canvas.getByRole('listbox');
await userEvent.selectOptions(select, ['One Item']);
await sleep(2000);
await userEvent.selectOptions(select, ['Another Item']);
await sleep(2000);
await userEvent.selectOptions(select, ['Yet another item']);
},
};
Play functions aren’t just for simulating events; you can also use them to test asynchronous operations. Let’s say you’re building a component with validation (like checking if an email is valid or a password is strong enough).
You can add delays in your play function to mimic how a user interacts with the form and then check if the validation works correctly.
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
/* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const DelayedStory: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const exampleElement = canvas.getByLabelText('example-element');
// The delay option sets the amount of milliseconds between characters being typed
await userEvent.type(exampleElement, 'random string', {
delay: 100,
});
const AnotherExampleElement = canvas.getByLabelText('another-example-element');
await userEvent.type(AnotherExampleElement, 'another random string', {
delay: 100,
});
},
};
When Storybook loads this story, it will automatically fill in the inputs and trigger the component’s validation.
You can also use play functions to check if certain elements appear or disappear after a user interacts with the component. For example, if you want to test what happens when a user enters incorrect information, you could write a story like this:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, waitFor, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
/* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const ExampleAsyncStory: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const Input = canvas.getByLabelText('Username', {
selector: 'input',
});
await userEvent.type(Input, 'WrongInput', {
delay: 100,
});
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
const Submit = canvas.getByRole('button');
await userEvent.click(Submit);
await waitFor(async () => {
await userEvent.hover(canvas.getByTestId('error'));
});
},
};
Querying elements
You can also use queries (like searching by role or text content) to find elements within your play function. Here’s how:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
/* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const ExampleWithRole: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
await userEvent.click(canvas.getByRole('button', { name: / button label/i }));
},
};
When Storybook loads the story, the play function runs and looks for the element in the page. If the element isn’t there when the story first renders, the test will fail, making it easy to see what went wrong.
If the component isn’t visible right away (maybe because of something that happened earlier in the play function or because of some asynchronous action), you can tell your story to wait for the element to appear before trying to find it. Here’s how:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
/* See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const AsyncExample: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Other steps
// Waits for the component to be rendered before querying the element
await canvas.findByRole('button', { name: / button label/i });
},
};
Working with the Canvas
Normally, interactions in your play function start at the very top of the Storybook Canvas.
This works fine for small components like buttons or checkboxes, but it can be slow and inefficient for bigger, more complicated components (like entire forms or pages), especially if you have lots of stories.
To fix this, you can tell your interactions to start from the root of your component instead of the whole Canvas. Here’s how:
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { userEvent, within } from '@storybook/test';
import { MyComponent } from './MyComponent';
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
export const ExampleStory: Story = {
play: async ({ canvasElement }) => {
// Assigns canvas to the component root element
const canvas = within(canvasElement);
// Starts querying from the component's root element
await userEvent.type(canvas.getByTestId('example-element'), 'something');
await userEvent.click(canvas.getByRole('button'));
},
};
Making these changes to your stories can make them run faster and improve how errors are handled when you’re using the interactions addon.
If you liked this content I’d appreciate an upvote or a comment. That helps me improve the quality of my posts as well as getting to know more about you, my dear reader.
Muchas gracias!
Follow me for more content like this.
X | PeakD | Rumble | YouTube | Linked In | GitHub | PayPal.me | Medium
Down below you can find other ways to tip my work.
BankTransfer: "710969000019398639", // CLABE
BAT: "0x33CD7770d3235F97e5A8a96D5F21766DbB08c875",
ETH: "0x33CD7770d3235F97e5A8a96D5F21766DbB08c875",
BTC: "33xxUWU5kjcPk1Kr9ucn9tQXd2DbQ1b9tE",
ADA: "addr1q9l3y73e82hhwfr49eu0fkjw34w9s406wnln7rk9m4ky5fag8akgnwf3y4r2uzqf00rw0pvsucql0pqkzag5n450facq8vwr5e",
DOT: "1rRDzfMLPi88RixTeVc2beA5h2Q3z1K1Uk3kqqyej7nWPNf",
DOGE: "DRph8GEwGccvBWCe4wEQsWsTvQvsEH4QKH",
DAI: "0x33CD7770d3235F97e5A8a96D5F21766DbB08c875"