Interactive demos and examples, documentation, code samples, tests and component itself: when all these elements are put together in a single place, this place can be your very first step towards creating a wholesome design system that invites front-end developers to try out your component library and inspire them to create something beautiful out of it.
Storybook helps us do exactly that! This post documents how I created a story of a Card
UI component using Storybook for my future-self. As a Nuxt ambassador, I chose to use Storybook with my all time favourite framework, Nuxt. ❤️
As an example, I will zoom in on the Card
component that I created a while back for my Infinite Masonry with Nuxt article. This component has three different interesting states that I would like to document: Primary, SVG and Gradient.
Card Component has six props and they all help control the look and feel of the card. Card component is created with Tailwind CSS, so any Tailwind CSS classes should be a valid props value. Since I would like to manipulate these props dynamically, it's a good idea to note their type while I'm listing them down.
number
string
string
string
number
string
In my existing Nuxt project, I started by installing @nuxtjs/storybook package.
npm install @nuxtjs/storybook
Then I configured Storybook in nuxt.config.js
for it to work with Nuxt. I began with basic configuration like below👇 to get started.
// nuxt.config.js
export default {
// other config items
storybook: {
port: 4000,
stories: [],
webpackFinal(config) {
return config;
},
},
};
When explicit paths are not defined in stories
array👆, Storybook looks for stories in the same folder as your components. That means, I can now start documenting my Card component stories by creating Card.stories.js
in the same folder as my Card component.
Then I updated package.json
with Storybook commands, so that I can run my stories locally and deploy them on static hosting.
"storybook": "npx nuxt storybook",
"deploy:sb": "rm -rf storybook-static && nuxt storybook build && cd storybook-static && surge ./ --domain https://infinite-masonry-sb.surge.sh/"
As a final housekeeping item, I updated .gitignore
and I added these two folders into my .gitignore
file.
// .gitignore
# Storybook
.nuxt-storybook
storybook-static
Primary version of the Card component has three child components as below.
I will need these child components when I'll define the template of the Primary story, so it makes sense to import them.
// Card.stories.js
import Card from "./Card.vue";
// Primary Card
import CardImage from "./CardImage.vue";
import CardHeader from "./CardHeader.vue";
import CardFooter from "./CardFooter.vue";
Let's understand how the story is made. A story is a function that describes how to render different states of your UI components. The way I see is that the story has three key elements.
export default {
title: "JS/Card",
component: Card,
};
const PrimaryTemplate = () => ({});
export const Primary = PrimaryTemplate.bind({});
Once we understand this concept of story formation, you can write your stories in multiple ways as Storybook is super flexible like that.
With this in mind, let's implement basic story without any interactive controls.
// Card.stories.js
// Describe card component
export default {
title: "JS/Card",
component: Card,
};
// Define template for Primary Story
const PrimaryTemplate = () => ({
components: { Card, CardHeader, CardFooter, CardImage },
template: `
<card
padding="3"
primary-color="green-400"
class="w-72"
>
<!-- ... -->
</card>
`,
});
// named export Primary to create respective story
export const Primary = PrimaryTemplate.bind({});
Above setup should give us a story without any interactive controls.
args
argument to activate controlsargs
can be used to dynamically change props, slots, styles, inputs, etc. This allows us live edit components in Storybook.
// card.stories.js
// Define template for Primary Story
const PrimaryTemplate = (args) => ({
// ...
});
As soon as we pass args
parameter into our story template👆, the message (i.e. Story isn't configured …) should disappear and we should see all the card props come alive in the control panel with their default values as specify in our (Vue) Card
component. 👇
args
variablesMy main objective of documenting Card
stories is to show users different ways to tweak the props to compose their own card design. This means that we should be able to tweak the default props values. So, let's update component section of our story to include args
object with new default values.
// card.stories.js
// Describe card component
export default {
// ...
args: {
primaryColor: "green-400",
padding: 3,
borderWidth: 2,
borderRadius: "2xl",
cardBg: "gray-100",
},
};
At this point, we are able to edit the values of these props in the control panel, but they are not reactive! In other words, they are not bound to the component.
To make the component react to control panel changes, we can bind variables of args
object into the component template like below using :
syntax.
// card.stories.js
// Define template for Primary Story
const PrimaryTemplate = (args) => ({
template: `
<card
:padding="padding"
:primary-color="primaryColor"
:border-radius="borderRadius"
:border-width="borderWidth"
class="w-72"
>
<!-- ... -->
</card>`
)}
argTypes
to bind args
variablesAnd finally, use argTypes
and pass it as a props
to make all of them variables reactive.
// card.stories.js
// Define template for Primary Story
const PrimaryTemplate = (args, { argTypes }) => ({
// ...
props: Object.keys(argTypes),
// ...
)}
You can add additional controls at this point that other child components might be using for this particular story, such as invert
and name
controls for Footer component.
:invert="invert"
:name="name"
We can use argTypes
:
argTypes
Primitive input types like text, number and boolean are inferred automatically as per props definition used in our Vue component. But input fields like drop-down and radio button selections require explicit configuration using argTypes
.
argTypes
specify the behaviour of args
and help you constrain the values that args
can accept.
Let's convert our borderRadius
and borderWidth
props control into drop-down and radio buttons respectively. That way users won't have to guess their possible values.
// card.stories.js
// Describe card component
export default {
// ..
argTypes: {
borderRadius: {
control: {
type: "select",
options: ["2xl", "3xl", "lg", "md"],
},
defaultValue: "3xl",
},
borderWidth: {
control: {
type: "radio",
options: [0, 2, 4, 8],
},
defaultValue: 2,
},
},
};
We can use table.category
option inside argTypes
to group props with similar category.
// card.stories.js
// Describe card component
export default {
// ..
argTypes: {
invert: {
table: {
category: "Footer",
},
},
name: {
table: {
category: "Footer",
},
},
},
};
Most common scenario I can think of is to group content-related controls, look-and-feel-related control or even different child-component-related control together and probably control their visibility depending on which story they are being used for.
You can use argTypes
to hide unwanted default props, such as header
, footer
and default
slots.
// card.stories.js
// Describe card component
export default {
// ..
argTypes: {
header: {
table: {
disable: true,
},
},
footer: {
table: {
disable: true,
},
},
default: {
table: {
disable: true,
},
},
},
};
args
and argTypes
into a separate jsonIt's good idea to extract all the variables related to the primary story of the card into its own .json
file because our card.stories.js
could easily becomes three-times bigger as we start to add SVG and Gradient stories into it.
Also, make sure to validate your json
file contents, as you may run into issues with invalid json
structure.
decorators
helps you control how your story is rendered. Most typical use-case of decorators is to wrap stories with additional HTML markup.
Out of the box, our card component is rendered on the left-hand side of the Canvas and the Docs page. But let's say we want to centre-aligned our component for all stories.
Since we want to apply this behaviour to all stories of a component, it makes sense to apply this change at component-level.👇
// card.stories.js
// Describe card component
export default {
// ...
// decorators at component level
decorators: [
() => ({
template:
'<div style="display: flex; align-items: center; justify-content: center;"><story /></div>',
}),
],
};
Another most common requirement for documentation is the ability to add our own custom text on the doc section of the Storybook for our Card
component that might look something like this.👇
parameters
help you control the behaviour of Storybook features and addons. By default we don’t get any content, but we can pass arbitrary text or a markdown file into docs.description.component
to add content right on top of the Docs
section.
import readme from "./readme.md";
// card.stories.js
// Describe card component
export default {
// ...
// parameters at component level
parameters: {
docs: {
description: {
component: readme,
},
},
},
};
parameters
and decorators
have everything to do with the Storybook addons. Addons helps us extend Storybook's functionalities. There are different types of addons available for Storybook and you can see the full list of addons here.Another one of the fundamental element of documentation is the code samples. With Storybook, we can display code samples using docs.source.code
like this.👇
// card.stories.js
// named export primary story
export const Primary = PrimaryTemplate.bind({});
// add source code for individual story using parameters
// parameters at story level
Primary.parameters = {
docs: {
source: {
code: "`<Card> ... </Card>`",
},
},
};
For my card component, I have two more interesting stories that I'd like to document: SVG and Gradient. So, the final version of the card stories would look something like this.👇
The story-outline for above scenario would look something like this.👇
// card.stories.js
export default {
title: "JS/Card",
component: Card,
};
// Primary
// Define template for Primary Story
const PrimaryTemplate = () => ({});
// named export Primary to create respective story
export const Primary = PrimaryTemplate.bind({});
// SVG
// Define template for SVG Story
const SVGTemplate = () => ({});
// named export Primary to create respective story
export const Primary = SVGTemplate.bind({});
// Gradient
// Define template for SVG Story
const GradientTemplate = () => ({});
// named export Primary to create respective story
export const Primary = GradientTemplate.bind({});
Normally, you would have just one template that will be reused across multiple stories. But in my example, I have created three different templates since I would like to document highly customised versions of the same Card
component.
Similar collection of Card stories can be written using MDX syntax as well. As second part of this article, I have published another post where I compare Docspage and MDX syntax using this same example.
Storybook helps you document and optimise component stories. Because you see all the props, slots and inputs upfront in the Storybook UI, it helps you think in terms of component architecture where you can group look-and-feel related props vs content related props.
It helps you determine and test whether the information should be passed down from parent to child components or does it make more sense for child component to exist on its own.
Depending on your project requirements, you are able to dynamically compose various combinations of a single component in isolation without editing Vue file/s like I have done in this example with Primary, SVG and Gradient stories. Storybook emphasis not only on telling your component's story, but also showing the end product as intended by the designers.
My Storybook journey so far has helped me discover potential edge cases, design related issues and architectural issues of my component that couldn't have surfaced otherwise.