Published on

Creating an addon-like experience in Storybook using parameters and globals

10-12 min read

Whenever using Storybook, you might face a situation where you want to support certain functionality for your stories, such as internationalization, theming, right to left mode etc.

In such situations, developers normally go for Storybook addons, which are tools that can be installed to enhance the development experience in Storybook, or add support for certain functionalities. They can normally be activated via parameters like so:

MyComponent.stories.js
Copy

export const MyStory = {
parameters: {
theme: {
default: 'dark',
},
i18n: {
lang: 'en',
},
},
}

Or they might introduce a new button in the toolbar which can be changed at runtime:

Addon in toolbar

Some addons are maintained by the Storybook core team, but most are created and maintained by the community. They're great! But sometimes either your use case is rather specific and you can't find addons for that, or you simply don't want to depend on a third-party addon.

If that's the case, you could probably create an addon of your own.

Creating an addon might be too much for me

Storybook has great tutorials, including one that shows you how to create a Storybook addon. However, this involves understanding more about Storybook's internals APIs, how to register addons, create presets, handle the addon panel if necessary, etc.

If you do create your own addon, that's awesome because you can do so much with it. Even better, you can make it public and end up helping many developers in a similar situation! In fact, Storybook provides a template for you to create addons with, called addon-kit.

But.. sometimes your scenario is so simple that you don't want to go through all of that.

Thankfully, Storybook has a few concepts that we can use to create an addon-like experience, without actually creating an addon:

  1. Decorators
  2. Story context
  3. Parameters
  4. Globals

The use case

To illustrate the concepts clearly, I'll be showing you how to create an addon-like experience to provide RTL (right to left) mode to your stories. You can then use this knowledge to build pretty much anything else you want.

💡 RTL (Right To Left) is a locale property indicating that text is written from right to left. It's common in languages like Arabic and Hebrew.

I'll be using the Page story as example, which comes from the boilerplate that Storybook generates when first installed:

Alright, let's get to it.

Storybook decorators

If you have been using Storybook for a while, you most probably have used or created decorators. Decorators are basically wrappers that receive a Story component and enhance it in a certain way. They are used to either provide visual enhancements or functionality (e.g. theming, state management, etc.).

Here's an example of a simple decorator for RTL mode. It wraps the story in a <div> element adding dir="rtl" to it:

Page.stories.jsx
Copy

// define decorator
const withRTL = (StoryFn) => (
<div dir="rtl">
<StoryFn />
</div>
)
export default {
title: 'Example/Page',
component: Page,
// apply decorator to all stories in this file
decorators: [withRTL],
}
// define story
export const Default = {}

⚠️ If you don't use React, I got you covered. Keep reading and at the end I'll show you how to achieve the same thing in a different way.

And here's how it looks like:

The decorator might be great for a specific story, but what if you want to make this scalable? You probably don't want to repeat the same code in every file. Thanks to the way data is inherited in Storybook, you can move the decorator up to .storybook/preview.js and make it global, so it applies to every single story:

.storybook/preview.js
Copy

// Code moved from Page.stories.jsx
const withRTL = (StoryFn) => {
return (
<div dir="rtl">
<StoryFn />
</div>
)
}
// Whatever decorators are added here will be applied to all stories
export const decorators = [withRTL]

But then, every story will be in RTL mode. You might want to create a way to opt-out of this behavior. Or even, to opt-in, depending on what you're trying to achieve.

So what do you do now? There should be a way to define some metadata from a story, so you can decide whether or not to apply the decorator. Thankfully, decorators not only receive the story function as an argument, but also the story context, which will provide exactly what we need.

Exploring the story context

The context is an object that contains contextual data that is necessary for a story to function correctly. It pretty much defines what a story is made of: id, name, args, parameters, globals, and more.

If we update the decorator to access and log the context, like so:

.storybook/preview.js
Copy

const withRTL = (StoryFn, context) => {
// context will contain useful data that can be leveraged in the decorator
console.log(context)
return (
<div dir="rtl">
<StoryFn />
</div>
)
}

We get the following output:

What stands out from there are globals and parameters. Having access to such data in a decorator gives a lot of possibilities for creating a custom API to do whatever you like. Let's understand why.

Story parameters

Story parameters are a way to pass metadata to a story. They are normally used to define the behavior of some Storybook features or Storybook addons. However, you can also use parameters to pass any arbitrary data, which will make its way to the story context.

We can create our own API to communicate with the withRTL decorator, allowing users to opt-in to it, for example:


export const Default = {
parameters: {
// opt-in to RTL mode for this story
textDirection: 'rtl',
},
}

We can modify the withRTL decorator code to extract the parameters property from the story context, and make the decorator only apply the RTL mode if the story contains parameters.textDirection: "rtl":

.storybook/preview.js
Copy

const withRTL = (StoryFn, context) => {
const { parameters } = context
const defaultDirection = 'rtl'
const textDirection = parameters.textDirection || defaultDirection
// Set RTL only if passed as a parameter
if (parameters.rtl) {
return (
<div dir="rtl">
<StoryFn />
</div>
)
}
// else, render the story normally
return <StoryFn />
}

And the story code then becomes:

Page.stories.jsx
Copy

export default {
title: 'Example/Page',
component: Page,
// Decorators are now global, so this is not needed anymore
// decorators: [withRTL],
}
export const Default = {
parameters: {
textDirection: 'rtl',
},
}

As a result, we retain the same experience as before, but now it can be enabled by simply defining a parameter, just like how you communicate with some addons you use (e.g. viewports, backgrounds).

This is a great way to enable/disable certain decorator states for a component. However, this is slightly limited because it can only happen in code, which means if you want to change the setting, you need to update the code.

If you wanted a way to change the decorator state at runtime, Storybook has a global state that can be used, and that is also part of the story context.

Storybook globals

Storybook has a global state that stores data which is used by addons, and gets persisted across stories. Such data is also available in the story context.

If you're using addon-essentials (most Storybook projects use it nowadays), you can create custom toolbars for your project, which will add data to the global state of Storybook, and change it as you interact with your newly created toolbar. We can leverage this to create a runtime experience that allows users to switch from LTR to RTL in any story they want, just by clicking on a button.

Creating a custom toolbar with addon-toolbars

In order to create a toolbar item of our own, we can follow the documentation here and export a globalTypes object in .storybook/preview.js:

.storybook/preview.js
Copy

export const globalTypes = {
// This will generate a globals.textDirection property
textDirection: {
name: 'Text direction',
description: 'Direction of the text',
defaultValue: 'rtl',
toolbar: {
// See this link for the list of available icons that you can use:
// https://storybook.js.org/docs/react/faq#what-icons-are-available-for-my-toolbar-or-my-addon
icon: 'arrowrightalt',
items: [
{ value: 'ltr', icon: 'arrowrightalt', title: 'left to right' },
{ value: 'rtl', icon: 'arrowleftalt', title: 'right to left' },
],
},
},
}

Now when checking Storybook, we see that there is a new button in the toolbar:

As we interact with it, nothing really happens. That makes sense! We didn't wire our decorator to use those global values yet. Let's do that.

Setting the text direction via globals

Looking back at the withRTL decorator, we can now extract globals from the context, which should contain the textDirection property that we defined via globalTypes. The component will re-render every time the globals change, and we can check whether the value is rtl or ltr to opt-in to the different modes. We should still support the parameter we created, and it should respect it as higher priority than the global state:

.storybook/preview.js
Copy

const withRTL = (StoryFn, context) => {
const { parameters, globals } = context
const defaultDirection = 'rtl'
const textDirection = parameters.textDirection || globals.textDirection || defaultDirection
// Set RTL only if passed as a parameter or toggled via toolbar
if (textDirection === 'rtl') {
return (
<div dir="rtl">
<StoryFn />
</div>
)
}
return <StoryFn />
}

Now we can successfully change the theme without having to make any code changes! And because we are respecting the parameters defined in the Default story, the globals won't do anything. However, when going to any other story, we are able to toggle the reading modes:

This is great because you probably want stories that will always represent certain state (e.g. RTL, dark mode, etc.), but you want the flexibility to experiment freely in other stories.

The best of both worlds

Now that we have a way to change the reading mode both via parameters and globals, we should probably create a new story for the RTL mode, and leave the Default story without parameters:

Page.stories.jsx
Copy

export const Default = {}
export const DefaultRTL = {
parameters: {
textDirection: 'rtl',
},
}
// ...other stories

As a result, we can toggle the RTL mode in other stories at runtime by using the toolbar, but we have an explicit story for the RTL mode which will always have it activated. This makes it easier to understand what the story represents, and it also makes it easier to test. Plus, if you're using tools like Chromatic for visual regression testing, you will make sure that you're testing what matters.

Applying the decorator logic for non-React projects

This all sounds super nice, but what if I'm using Storybook with Angular, where I can't wrap components with JSX? Or what if I don't want to change markup, but execute something programmatically?

Thankfully, changing reading mode can also be done programmatically via the document.body.dir property. Storybook's @storybook/client-api package provides react-like hooks that can be used in any framework (Angular, Vue, Svelte, etc.), and we can use the useEffect hook to apply the reading direction once the component renders:

.storybook/preview.js
Copy

import { useEffect } from '@storybook/client-api'
const withRTL = (StoryFn, context) => {
const { parameters, globals } = context
const defaultDirection = 'rtl'
const textDirection = parameters.textDirection || globals.textDirection || defaultDirection
// this hook will be called when the component renders and when the globals change
useEffect(() => {
// Set RTL only if passed as a parameter or toggled via toolbar
document.body.dir = textDirection
}, [textDirection])
return Story()
}

Bonus: Rendering both modes at the same time

Sometimes you want to visualize things side by side (or stacked on each other), so they're easy to compare or visualize. To make things even more interesting, we can leverage the use of decorators to render the same story twice, in a grid, and apply the necessary styles to position them as we want.

First, we add new options to globalTypes, called "side-by-side" and "stacked".

.storybook/preview.js
Copy

export const globalTypes = {
// This will generate a globals.textDirection property
textDirection: {
name: 'Text direction',
description: 'Direction of the text',
defaultValue: 'ltr',
toolbar: {
// See this link for the list of available icons that you can use:
// https://storybook.js.org/docs/react/faq#what-icons-are-available-for-my-toolbar-or-my-addon
icon: 'arrowrightalt',
items: [
{ value: 'ltr', icon: 'arrowrightalt', title: 'left to right' },
{ value: 'rtl', icon: 'arrowleftalt', title: 'right to left' },
{ value: 'side-by-side', icon: 'sidebaralt', title: 'both side by side' },
{ value: 'stacked', icon: 'bottombar', title: 'both stacked' },
],
},
},
}

Then, we update the decorator to account the newly added scenarios, rendering the story twice inside of a grid:

.storybook/preview.js
Copy

const withRTL = (StoryFn, context) => {
const { parameters, globals } = context
const defaultDirection = 'rtl'
const textDirection = parameters.textDirection || globals.textDirection || defaultDirection
if (textDirection === 'side-by-side' || textDirection === 'stacked') {
const isStacked = textDirection === 'stacked'
const styles = {
grid: {
display: 'grid',
gridTemplateColumns: isStacked ? '1fr' : 'repeat(auto-fit, minmax(0px, 1fr))',
height: isStacked ? 'auto' : '100vh',
},
gridItem: {
outline: '1px solid #eee',
},
}
return (
<div style={styles.grid}>
<div style={styles.gridItem}>
<StoryFn />
</div>
<div style={styles.gridItem} dir="rtl">
<StoryFn />
</div>
</div>
)
}
// Set RTL only if passed as a parameter or toggled via toolbar
if (textDirection === 'rtl') {
return (
<div dir="rtl">
<StoryFn />
</div>
)
}
return <StoryFn />
}

When selecting the new option in the toolbar, we are able to see both the RTL and the LTR version of the story:

Bonus 2: Adding keyboard shortcuts

The addon-toolbars provides a way to set keyboard shortcuts via the toolbar.shortcuts property for switching to the next/previous value, or resetting its state. Shortcuts improve both user experience and productivity, so it's pretty cool to know you can achieve that.

.storybook/preview.js
Copy

export const globalTypes = {
textDirection: {
name: 'Text direction',
description: 'Direction of the text',
defaultValue: 'ltr',
toolbar: {
// See this link for the list of available icons that you can use:
// https://storybook.js.org/docs/react/faq#what-icons-are-available-for-my-toolbar-or-my-addon
icon: 'arrowrightalt',
items: [
{ value: 'ltr', icon: 'arrowrightalt', title: 'left to right' },
{ value: 'rtl', icon: 'arrowleftalt', title: 'right to left' },
{ value: 'side-by-side', icon: 'sidebaralt', title: 'both side by side' },
{ value: 'stacked', icon: 'bottombar', title: 'both stacked' },
],
shortcuts: {
next: {
label: 'Go to next reading mode',
keys: ['shift', 'R'],
},
previous: {
label: 'Go to previous reading mode',
keys: ['R'],
},
reset: {
label: 'Reset reading mode',
keys: ['control', 'R'],
},
},
},
},
}

Now try it out, it's pretty neat being able to switch modes very quickly. This could work really well for theme switching, for instance.

💡 You can check all possible shortcut values in this file.

Bonus 3: Taking snapshots with Chromatic

If you're using a visual regression testing tool like Chromatic, you can change the default reading mode in the decorator from lrt to side-by-side or stacked when the story is rendered via Chromatic. This way, you can visualize stories the normal way when accessing Storybook, and have snapshots taken for each and every story in both modes at the same time in Chromatic. Chromatic provides an isChromatic utility to help with that!

We can then update the decorator:

.storybook/preview.js
Copy

import isChromatic from 'chromatic/isChromatic'
const withRTL = (StoryFn, context) => {
const { parameters, globals } = context
const defaultDirection = isChromatic() ? 'side-by-side' : 'rtl'
const textDirection = parameters.textDirection || globals.textDirection || defaultDirection
// ... rest of code removed for brevity
}

And if you don't want to do this for all stories but rather per-story basis, you could use the isChromatic helper in a story parameter:

Page.stories.jsx
Copy

import isChromatic from 'chromatic/isChromatic'
export const Default = {
parameters: {
textDirection: isChromatic() ? 'side-by-side' : 'rtl',
},
}

💡 While this example uses reading modes, it can be used in conjunction with anything your creativity allows, in fact it works perfectly with multi-theming

Final result

You can find the code for the decorator in this repo, and here's a live embed so you can play around with the real thing:

Conclusion

Thank you so much for reading this through. You just learned how to combine the power of small concepts to achieve something greater. This tutorial was using reading modes as a use case, but you can use this knowledge potentially for solutions such as:

The possibilities are endless, and the best way to learn is to try it out. Keep this in your toolbelt, I'm sure it will be very useful to you.

I hope you enjoyed it, and if you have any questions, feel free to reach out on Twitter. If you want to know more about the potential of Storybook and how to use it effectively in React projects, I created a course that goes from the basics to complex features, with lots of pro tips in between!