Create Card component story using Storybook with Nuxt

Created

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.

Card component story using Storybook with Nuxt
Card component story using Storybook with Nuxt

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. ❤️

Example: Card Component

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.

  1. padding - number
  2. primaryColor - string
  3. secondaryColor - string
  4. cardBg - string
  5. borderWidth - number
  6. borderRadius - string

Storybook and Nuxt

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

See commit: Configure storybook

Primary version of the Card component has three child components as below.

  1. Header
  2. Image
  3. Footer

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";

Unlocking Storybook’s superpower

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.

  • Describe component using export default
export default {
  title: "JS/Card",
  component: Card,
};
  • Describe story template
const PrimaryTemplate = () => ({});
  • And finally, describe the story using named exports
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.

Basic card story implemented without controls

With this in mind, let's implement basic story without any interactive controls.

  1. Import key component and the child components if applicable
  2. Define export default
  3. Define template for primary version of the story
  4. Named export primary story
// 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.

Story without any interactive controls
Story without any interactive controls

Pass args argument to activate controls

args 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. 👇

Pass `args` argument to activate controls
Pass `args` argument to activate controls

Add args variables

My 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>`
)}

Add argTypes to bind args variables

And 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),
  // ...
)}
Add `argTypes` to bind `args` variables
Add `argTypes` to bind `args` variables

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"

Versatile argTypes

We can use argTypes :

  • to restrict the values the props can accept by specifying custom types of control, such as radio button or drop-down
  • to group similar controls together
  • to hide unwanted default controls

Restrict control values using 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,
    },
  },
};
Restrict control values using `argTypes`
Restrict control values using `argTypes`

Group similar controls

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.

Group similar controls
Group similar controls

Hide unwanted default controls from the panel

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,
      },
    },
  },
};

Extract args and argTypes into a separate json

It'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.

Parameters and decorators

Use decorators to centre-align the card

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>',
    }),
  ],
};

Add readme on docs using parameters

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.👇

Add readme on docs using parameters
Add readme on docs using parameters

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,
      },
    },
  },
};

See commit: Add readme on docs using parameters

Both 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.

Display Code Blocks

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>`",
    },
  },
};

Multiple stories of a same component

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.👇

Multiple stories of a same component
Multiple stories of a same component

See commits:

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.

MDX Story

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.

See commits: Add MDX Stories

Conclusion

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.

· · ·