Forem

Cover image for Documenting Web Components With Storybook
James Ives
James Ives

Posted on • Originally published at jamesiv.es

Documenting Web Components With Storybook

Documentation is a crucial part of any design system. There's the aspect of writing, maintaining, and ensuring that it doesn't drift from the codebase. It's a lot of work, and it's easy to let it slip. I've spent a lot of time over the last year and a half thinking about the right way to document components, and it took some time until I found a sustainable solution I was happy with. In this article, I want to discuss how you can easily document your Web Components with Storybook so that your documentation provides a good user and developer experience.

Custom Elements Manifest

One of the best tools available in Web Component development is the Custom Elements Manifest. It's a JSON representation of all your available components, covering all the attributes, methods, slots and events they support, powered by your JSDoc comments and TypeScript types. You can customize the manifest generation through plugins to support custom JSDoc comments, allowing you to power more pieces of your documentation through code comments; for example, you could set up a comment format to indicate if your component is experimental or stable or provide a way to add a link to your Figma files.

import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";

/**
 * The Button component, used for calling attention to actions.
 *
 * @customElement sb-button
 * @github https://github.com/starbucks/web-components
 * @figma https://figma.com
 *
 * @slot - The main content area of the button.
 * @csspart button - Allows you to style the button element.
 */
@customElement("sb-button")
export default class SBButton extends LitElement {
  /**
   * Determines the variant of the button.
   */
  @property({ type: String })
  public variant: "primary" | "secondary" = "primary";

  /**
   * Disables the button.
   */
  @property({ type: Boolean })
  public disabled?: boolean;

  /**
   * @inheritdoc
   */
  public render() {
    return html`
      <a
        part="button"
        ?disabled=${this.disabled}
        class="${this.variant === "primary" ? "primary" : "secondary"}"
      >
        <span><slot></slot> </span>
      </a>
    `;
  }

  static styles = css``;
}

declare global {
  interface HTMLElementTagNameMap {
    "sb-button": SBButton;
  }
}

Enter fullscreen mode Exit fullscreen mode

My favourite Custom Elements Manifest generator is the one by open-wc. The setup is straightforward: you create a config file and tell it where to find your components. It even has support for Lit out of the box.

import { jsDocTagsPlugin } from "@wc-toolkit/jsdoc-tags";

export default {
  globs: ["src/components/**/*.ts"],
  outdir: "public",
  litelement: true,
  dev: false,
  plugins: [
    jsDocTagsPlugin({
      tags: {
        figma: {
          description: "Link to the Figma design",
          type: "string",
          tagMapping: "figma",
        },
        github: {
          description: "Link to the GitHub repo",
          type: "string",
          tagMapping: "github",
        },
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

After you've generated the Custom Elements Manifest, you'll see how vast the dataset is. You're creating a payload that lets you query all the intricate details about your components, which is ideal for automatically generating documentation.

{
  "schemaVersion": "1.0.0",
  "readme": "",
  "modules": [
    {
      "kind": "javascript-module",
      "path": "src/components/sb-button/sb-button.stories.ts",
      "declarations": [
        {
          "kind": "variable",
          "name": "meta",
          "type": {
            "text": "Meta<SBButton>"
          },
          "default": "{ title: \"Components/Button\", component, args, argTypes, parameters: { actions: { handles: events, }, }, render: (args) => template(args), }"
        },
        {
          "kind": "variable",
          "name": "Secondary",
          "type": {
            "text": "Story"
          },
          "default": "{ args: { \"default-slot\": \"Secondary Button\", variant: \"secondary\", }, }"
        },
        {
          "kind": "variable",
          "name": "Primary",
          "type": {
            "text": "Story"
          },
          "default": "{ args: { \"default-slot\": \"Primary Button\", variant: \"primary\", }, }"
        }
      ],
      "exports": [
        {
          "kind": "js",
          "name": "default",
          "declaration": {
            "name": "meta",
            "module": "src/components/sb-button/sb-button.stories.ts"
          }
        },
        {
          "kind": "js",
          "name": "Secondary",
          "declaration": {
            "name": "Secondary",
            "module": "src/components/sb-button/sb-button.stories.ts"
          }
        },
        {
          "kind": "js",
          "name": "Primary",
          "declaration": {
            "name": "Primary",
            "module": "src/components/sb-button/sb-button.stories.ts"
          }
        }
      ]
    },
    {
      "kind": "javascript-module",
      "path": "src/components/sb-button/sb-button.ts",
      "declarations": [
        {
          "kind": "class",
          "description": "The Button component, used for calling attention to actions.",
          "name": "SBButton",
          "cssParts": [
            {
              "description": "Allows you to style the button element.",
              "name": "button"
            }
          ],
          "slots": [
            {
              "description": "The main content area of the button.",
              "name": ""
            }
          ],
          "members": [
            {
              "kind": "field",
              "name": "variant",
              "type": {
                "text": "\"primary\" | \"secondary\""
              },
              "privacy": "public",
              "default": "\"primary\"",
              "description": "Determines the variant of the button.",
              "attribute": "variant"
            },
            {
              "kind": "field",
              "name": "disabled",
              "type": {
                "text": "boolean | undefined"
              },
              "privacy": "public",
              "description": "Disables the button.",
              "attribute": "disabled"
            }
          ],
          "attributes": [
            {
              "name": "variant",
              "type": {
                "text": "\"primary\" | \"secondary\""
              },
              "default": "\"primary\"",
              "description": "Determines the variant of the button.",
              "fieldName": "variant"
            },
            {
              "name": "disabled",
              "type": {
                "text": "boolean | undefined"
              },
              "description": "Disables the button.",
              "fieldName": "disabled"
            }
          ],
          "superclass": {
            "name": "LitElement",
            "package": "lit"
          },
          "tagName": "sb-button",
          "customElement": true,
          "github": {
            "name": "https://github.com/starbucks/web-components",
            "description": ""
          },
          "figma": {
            "name": "https://figma.com",
            "description": ""
          }
        }
      ],
      "exports": [
        {
          "kind": "js",
          "name": "default",
          "declaration": {
            "name": "SBButton",
            "module": "src/components/sb-button/sb-button.ts"
          }
        },
        {
          "kind": "custom-element-definition",
          "name": "sb-button",
          "declaration": {
            "name": "SBButton",
            "module": "src/components/sb-button/sb-button.ts"
          }
        }
      ]
    },
    {
      "kind": "javascript-module",
      "path": "src/components/sb-card/sb-card.stories.ts",
      "declarations": [
        {
          "kind": "variable",
          "name": "meta",
          "type": {
            "text": "Meta<SBCard>"
          },
          "default": "{ title: \"Components/Card\", component, args, argTypes, parameters: { actions: { handles: events, }, }, render: (args) => template(args), }"
        },
        {
          "kind": "variable",
          "name": "Secondary",
          "type": {
            "text": "Story"
          },
          "default": "{ args: { \"header-slot\": \"<h2>About Us</h2>\", \"body-slot\": \"<p>Find out more about our company and heritage.</p>\", \"image-slot\": \"<img src='https://www.starbucks.co.uk/sites/starbucks-uk-pwa/files/styles/c22_featured_card_531x273/public/2022-03/About%20us%20-%20Starbucks%20%281%29.jpg?h=78ab6d9e&itok=EjamtVRd' alt='Starbucks' />\", \"footer-slot\": \"<sb-button variant='primary'>Order Now</sb-button>\", variant: \"secondary\", }, }"
        },
        {
          "kind": "variable",
          "name": "Primary",
          "type": {
            "text": "Story"
          },
          "default": "{ args: { \"header-slot\": \"<h2>Meet your new favourits!</h2>\", \"body-slot\": \"<p>Our new Iced Shaken Espresso is the perfect way to cool down this summer.</p>\", \"image-slot\": \"<img src='https://www.starbucks.co.uk/sites/starbucks-uk-pwa/files/styles/c22_featured_card_531x273/public/2025-01/3469%20UK%20WIN25%20Web%20Banners%20-%20C22%20LTO%20Banner%20727x373px_v01.png?h=aec870e8&itok=BhZ99vG8' alt='Starbucks' />\", \"footer-slot\": \"<sb-button variant='secondary'>Order Now</sb-button>\", variant: \"primary\", }, }"
        }
      ],
      "exports": [
        {
          "kind": "js",
          "name": "default",
          "declaration": {
            "name": "meta",
            "module": "src/components/sb-card/sb-card.stories.ts"
          }
        },
        {
          "kind": "js",
          "name": "Secondary",
          "declaration": {
            "name": "Secondary",
            "module": "src/components/sb-card/sb-card.stories.ts"
          }
        },
        {
          "kind": "js",
          "name": "Primary",
          "declaration": {
            "name": "Primary",
            "module": "src/components/sb-card/sb-card.stories.ts"
          }
        }
      ]
    },
    {
      "kind": "javascript-module",
      "path": "src/components/sb-card/sb-card.ts",
      "declarations": [
        {
          "kind": "class",
          "description": "This component is used to create a card that can be utilized to call attention to important marketing opportunities,\nsuch as promotions, new product launches, or special events. It is also ideal for highlighting new drinks or menu items\nfor customers to order. The card is designed to be visually appealing and can include an image, header, body, and footer content.",
          "name": "SBCard",
          "slots": [
            {
              "description": "Slot for the primary card image.",
              "name": "image"
            },
            {
              "description": "Slot for the card header.",
              "name": "header"
            },
            {
              "description": "Slot for the card body.",
              "name": "body"
            },
            {
              "description": "Slot for the card footer.",
              "name": "footer"
            }
          ],
          "members": [
            {
              "kind": "field",
              "name": "variant",
              "type": {
                "text": "\"primary\" | \"secondary\""
              },
              "privacy": "public",
              "default": "\"primary\"",
              "description": "Copy for the read the docs hint.",
              "attribute": "variant"
            }
          ],
          "attributes": [
            {
              "name": "variant",
              "type": {
                "text": "\"primary\" | \"secondary\""
              },
              "default": "\"primary\"",
              "description": "Copy for the read the docs hint.",
              "fieldName": "variant"
            }
          ],
          "superclass": {
            "name": "LitElement",
            "package": "lit"
          },
          "tagName": "sb-card",
          "customElement": true,
          "github": {
            "name": "https://github.com/starbucks/web-components",
            "description": ""
          },
          "figma": {
            "name": "https://figma.com",
            "description": ""
          }
        }
      ],
      "exports": [
        {
          "kind": "js",
          "name": "default",
          "declaration": {
            "name": "SBCard",
            "module": "src/components/sb-card/sb-card.ts"
          }
        },
        {
          "kind": "custom-element-definition",
          "name": "sb-card",
          "declaration": {
            "name": "SBCard",
            "module": "src/components/sb-card/sb-card.ts"
          }
        }
      ]
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

So many good plugins for the generator allow you to enhance the developer experience for those using your components. For instance, you can enable VSCode IDE highlighting on your custom elements using the custom-element-vs-code-integration plugin and generate TypeScript mappings for those using your components in JSX with custom-element-jsx-integration. Getting the most out of the Custom Elements Manifest depends on how well you comment and type your codebase, which we should all strive to improve anyway.

Wiring the Controls

Storybook Controls is one of the best features of Storybook, but it's also one I've seen teams struggle with the most. If you've wired up your story to include all of the available parameters, it will build an interface that allows your users to toggle things on and off to see how the component reacts in real time. This feature is super helpful as it will enable your users to understand your component API better and how things work before they implement it in their application. It also helps QA teams test your components more effectively.

Storybook Controls Example

The problem, however, is that this is quite cumbersome to maintain. If you do this manually, you'll end up where things are often broken or left out of the table unless you have exceptional discipline. Most of the feedback I've gotten from teams struggling with Storybook is that this is their biggest pain point; however, using the Custom Elements Manifest file we generated, there's a better way, as it already knows everything about our components.

const meta: Meta<SBButton> = {
  title: "Components/Button",
  component,
  argTypes: {
    variant: {
      options: ["primary", "secondary"],
      control: { type: "radio" },
    },
  },
  render: (args) => html`<sb-button .variant=${args.variant}></sb-button>`,
};

export default meta;
Enter fullscreen mode Exit fullscreen mode

Imagining having to maintain the argTypes object for EVERY parameter?

Wiring up your stories to read from the Custom Elements Manifest is a matter of creating a function that parses it, outputs the correct schema Storybook expects, and then spreads any attributes directly onto your component. We'll need to provide the methods with the associated element name at a minimum so it knows what to key off of. I've put together a small example of what this could look like.

function getArgTypesFromManifest(manifest: Manifest, componentName: string) {
  const module = manifest.modules.find((mod) =>
    mod.declarations.some((decl) => decl.tagName === componentName)
  );

  if (!module) return {};

  const declaration = module.declarations.find(
    (decl) => decl.tagName === componentName
  );

  if (!declaration) return {};

  const argTypes: Record<string, ArgType> = {};

  declaration.attributes.forEach((attr) => {
    argTypes[attr.name] = {
      control: { type: getControlType(attr.type.text) },
      description: attr.description,
      defaultValue: attr.default,
      options: getOptions(attr.type.text),
    };
  });

  return { argTypes };
}

function getControlType(type: string): string {
  if (type.includes("|")) {
    return "select";
  }
  switch (type) {
    case "string":
      return "text";
    case "number":
      return "number";
    case "boolean":
      return "boolean";
    default:
      return "text";
  }
}

function getOptions(type: string): string[] {
  if (type.includes("|")) {
    return type.split("|").map((option) => option.trim().replace(/"/g, ""));
  }
  return [];
}

Enter fullscreen mode Exit fullscreen mode

Maintaining this code yourself can be challenging as there's a lot of nuance in how things are defined in the manifest file. Instead of writing this yourself, I recommend using this excellent plugin from Burton Smith, which handles this piece for you. Using this plugin, you can manipulate CSS variables, slots and other things directly from Storybook controls with minimal boilerplate. It's fantastic, and everyone should use it; seriously, don't write this yourself; it's much better with the plugin.

import type { Meta, StoryObj } from "@storybook/web-components";
import SBButton from "./sb-button";
import { getWcStorybookHelpers } from "wc-storybook-helpers";
import "./sb-button";

const component = "sb-button";
const { events, args, argTypes, template } = getWcStorybookHelpers(component);
type Story = StoryObj<SBButton & typeof args>;

const meta: Meta<SBButton> = {
  title: "Components/Button",
  component,
  args,
  argTypes,
  parameters: {
    actions: {
      handles: events,
    },
  },
  render: (args) => template(args),
};
export default meta;

export const Primary: Story = {
  args: {
    "default-slot": "Primary Button",
  },
};

Enter fullscreen mode Exit fullscreen mode

With your controls automatically generating, you can now focus on writing high-quality code and comments without worrying about Storybook.

To reduce any setup fatigue, I add a *.stories.ts file alongside my component generator, which sets up everything the plugin needs to start automating Storybook Controls from day 0.

Docs

When it comes to documentation, you must understand your audience. That's not just those viewing your documentation but also those writing it. While having a fancy documentation site is excellent, maintaining one takes a lot of work. It's also not easy to break habits; if most of your users are visiting your Storybook already, they'll likely continue to do so even after you launch your new site, so wouldn't it be better to meet them where they are? If you don't have specific requirements, Storybook Docs will likely suffice as the vessel for your documentation for your designers and engineers. Your engineers can continue to have their documentation automatically generated while your designers and product owners can contribute with markdown.

My approach to documenting in Storybook is to have a template that applies to everything by default, with a simple way to provide manually injected content where it makes sense. To do this, I first set up a documentation template; setting up a template can be done by extending your Storybook configuration and pointing it towards a markdown file.

import type { Preview } from "@storybook/web-components";
import { setCustomElementsManifest } from "@storybook/web-components";
import customElements from "./custom-elements.json";
import DocumentationTemplate from "./DocumentationTemplate.mdx";

setCustomElementsManifest(customElements);

const preview: Preview = {
  tags: ["autodocs"],
  parameters: {
    docs: {
      template: DocumentationTemplate,
    },
    controls: {
      expanded: true,
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;
Enter fullscreen mode Exit fullscreen mode

Out of the box, Storybook has several documentation-orientated components you can use, which they call blocks. You use them to display things like the component title, description, and the controls we created earlier.

import { Meta, Title, Primary, Controls, Stories } from '@storybook/blocks';


<Meta isTemplate />

<Title />

# Default implementation

<Primary />

## Inputs

The component accepts the following inputs (props):

<Controls />

---

## Additional variations

Listed below are additional variations of the component.

<Stories />

Enter fullscreen mode Exit fullscreen mode

Many of these out-of-the-box components, however, don't fit the bill. We want our Custom Elements Manifest to drive our documentation; it's no good if we define a bunch of custom JSDoc comments if they aren't used in our documentation, right? Fortunately, Storybook provides a way to define your own custom blocks using the of hook. Using this hook, we can tap into the metadata provided by our story to get the component name; we can then fetch the Custom Elements Manifest JSON file and pull data directly from it into our documentation. We can use this technique to set up instructions on installing our component or a list of badges based on what the component supports. More importantly, you can use your own design system elements to make everything look ✨ official ✨.

import { useOf, Markdown } from '@storybook/blocks';
import React from 'react';

/**
 * Renders an install block for a component.
 */
const Install = ({ of }) => {
  const resolvedOf = useOf(of || 'story', ['story', 'meta']);
  let componentName = '';

  switch (resolvedOf.type) {
    case 'story': {
      componentName = resolvedOf.story.component
      break;
    }
    default: {
      componentName = '';
    }
  }

  const markdown = `\`\`\`javascript
      npm install @starbucks/${componentName.toLowerCase().replace(/\s+/g, '-')}
    \`\`\``;

  return <Markdown>{markdown}</Markdown>;
};

export default Install
Enter fullscreen mode Exit fullscreen mode
import React, { useEffect, useState } from 'react';
import { Title as BlocksTitle, useOf } from '@storybook/blocks';
import { GitHubLogo, FigmaLogo } from './Logos';

/**
 * Renders a title block for a component, including any associated GitHub and Figma links.
 */
const Title = ({ of }) => {
  const [componentName, setComponentName] = useState('');
  const [githubLink, setGithubLink] = useState('');
  const [figmaLink, setFigmaLink] = useState('');

  const resolvedOf = useOf(of || 'story', ['story', 'meta']);

  useEffect(() => {
    const fetchComponentData = async () => {
      let componentName = '';

      switch (resolvedOf.type) {
        case 'story': {
          componentName = resolvedOf.story.component;
          break;
        }
        default: {
          componentName = '';
        }
      }

      setComponentName(componentName);

      try {
        const response = await fetch('/public/custom-elements.json');
        const manifest = await response.json();
        const component = manifest.modules.find(module => module.declarations.some(declaration => declaration.tagName === componentName));
        if (component) {
          const declaration = component.declarations.find(declaration => declaration.tagName === componentName);
          if (declaration.github) {
            setGithubLink(declaration.github.name);
          }
          if (declaration.figma) {
            setFigmaLink(declaration.figma.name);
          }
        }
      } catch (error) {
        console.error('Error fetching JSON:', error);
      }
    };

    fetchComponentData();
  }, [resolvedOf]);

  return (
    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
      <BlocksTitle />
      <div style={{ display: 'flex', gap: '10px' }}>
        {githubLink && (
          <a href={githubLink} target="_blank" rel="noopener noreferrer">
            <GitHubLogo />
          </a>
        )}
        {figmaLink && (
          <a href={figmaLink} target="_blank" rel="noopener noreferrer">
            <FigmaLogo />
          </a>
        )}
      </div>
    </div>
  );
};

export default Title;
Enter fullscreen mode Exit fullscreen mode

You can then update your default documentation template to reference these components.

import { Meta, Description,  Primary, Controls, Stories } from '@storybook/blocks';
import Install from './blocks/Install';
import Title from './blocks/Title';


<Meta isTemplate />

<Title />
<Description />
<Install />

<Primary />

## Inputs

The component accepts the following inputs (props):

<Controls />
Enter fullscreen mode Exit fullscreen mode

With this setup, you can now automatically generate documentation that is consistent and up-to-date with your codebase.

Storybook Docs Example

Similarly, you can use these same components in your own manually created documentation pages for cases where the template does not provide enough details. Create a .mdx file alongside your component definition, so long as you tell Storybook what component it's associated with, it will use it over the default template.
Depending on how savvy your contributors are you may want to consider abstracting the full template into its own component, this way you can provide a consistent experience across all your documentation pages, while still allowing slotted areas for curated content.

import { Meta, Description,  Primary, Controls, Stories } from '@storybook/blocks';
import Install from './blocks/Install';
import Title from './blocks/Title';
import SBCardStories from './sb-card.stories.ts';

<Meta of={SBCardStories} />

<Title />
<Description />
<Install />


<Primary />

## Inputs

The component accepts the following inputs (props):

<Controls />

# Usage Considerations

## Do

- Use this component for displaying cards in a grid.
- Use this component for displaying cards in a list.

## Don't

- Use this component for displaying cards in a carousel.
- Use this component for displaying cards in a modal.
Enter fullscreen mode Exit fullscreen mode

If you want to roll a custom documentation site, you can use the Custom Elements Manifest in the same way and even combine it with the outputted "stories.json" file, which I talked about in my Playwright article. This way, you can generate pages for each component while automatically embedding any valid Storybook stories. Ultimately, what you end up doing depends on your priorities and what you value more, and in some cases, I've found the simple route to be most palatable.

Third-party documentation platforms can be a challenge; if the tool provides no route to automation it may not be adopted by those intended to maintain it despite how rich their feature set may seem.

Conclusion

Meet your users where they are, either in the IDE, Storybook or Figma. Your documentation should be predictable, consistent, and, more importantly, accessible. You are building a product for users; if they can't find what they are looking for, they'll simply go elsewhere. Work smarter, not harder,
use the tools at your disposal.

Top comments (0)