Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 10

Storybook as your documentation layer

This chapter configures Storybook 10 and establishes the story conventions that make the documentation useful.

Storybook LAYOUTS Stack Sidebar COMPONENTS Input Select Canvas Docs Button Controls variant primary size md Button
The story tree, the rendered component, and the controls that document its props.

A component library without documentation is a collection of files. Storybook turns your components into a browsable, interactive catalog that serves as both the development environment and the buyer-facing documentation. This chapter configures Storybook 10 and establishes the story conventions that make the documentation useful.

Installation

npm create storybook@latest

This guide uses Storybook 10. @latest installs whatever version is current at the time you follow the guide — if the current version is not Storybook 10, the configuration format may differ from the examples below.

The CLI detects your Vite + React setup and asks a few questions. When prompted for configuration, choose Recommended — this installs the a11y, docs, and Vitest addons along with their dependencies, including Playwright. When the CLI asks whether to install Playwright with Chromium, choose Yes.

The recommended configuration installs these devDependencies:

  • storybook, @storybook/react-vite
  • @chromatic-com/storybook, @storybook/addon-vitest, @storybook/addon-a11y, @storybook/addon-docs
  • vitest, playwright, @vitest/browser-playwright, @vitest/coverage-v8

Add one more that the CLI does not install:

npm install -D remark-gfm

The CLI generates .storybook/main.ts, .storybook/preview.ts, and .storybook/vitest.setup.ts, and updates vite.config.ts. Rename preview.ts to preview.tsx before you start: the decorators below return JSX, and the file will not compile otherwise.

Configuration

// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite'
import type { InlineConfig } from 'vite'
import remarkGfm from 'remark-gfm'

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
  addons: [
    '@chromatic-com/storybook',
    '@storybook/addon-vitest',
    '@storybook/addon-a11y',
    {
      name: '@storybook/addon-docs',
      options: {
        mdxPluginOptions: {
          mdxCompileOptions: { remarkPlugins: [remarkGfm] },
        },
      },
    },
  ],
  framework: '@storybook/react-vite',
  async viteFinal(config: InlineConfig) {
    config.server ??= {}
    config.server.headers = {
      ...config.server.headers,
      'X-Frame-Options': 'ALLOWALL',
    }
    return config
  },
}

export default config

Three details in this file are worth knowing about.

Keep the ../src/**/*.mdx entry that storybook init generates in the stories array. Without it, Storybook finds your .stories.tsx files and silently ignores every hand-written MDX documentation page, so the entire Getting Started section of your sidebar never appears. Nothing errors; the pages are simply not there.

@storybook/addon-docs is registered in object form so it can take remarkPlugins: [remarkGfm]. MDX does not support GitHub-flavored Markdown tables out of the box, and documentation pages are mostly prop tables and token tables. Leave this out and those tables render as literal pipe characters.

viteFinal sets X-Frame-Options: ALLOWALL on the dev server. ALLOWALL is not a valid value, so browsers ignore the header, and the Vite dev server does not block framing in the first place. The header also never reaches the static build, which is what a site like this one embeds; framing there is controlled by the host’s headers. You can drop the block.

// .storybook/preview.tsx
import { useEffect } from 'react'
import type { Preview } from '@storybook/react-vite'
import { useArgs } from 'storybook/preview-api'
import { create } from 'storybook/theming/create'
import '../src/app.css'

const docsTheme = create({ base: 'light' })

const preview: Preview = {
  parameters: {
    docs: { theme: docsTheme },
    options: {
      storySort: {
        order: [
          'Getting Started',
          [
            'Introduction',
            'Accessibility',
            'Token Architecture',
            'Layout Primitives',
            'Typography',
            'Component Guidelines',
            'Theming',
          ],
          'Components',
          'Layouts',
          'Typography',
          'Examples',
        ],
      },
    },
    controls: {
      sort: 'requiredFirst',
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    a11y: {},
  },
  globalTypes: {
    theme: {
      description: 'Color theme',
      toolbar: {
        title: 'Theme',
        icon: 'paintbrush',
        items: [
          { value: 'light', title: 'Default Light', icon: 'sun' },
          { value: 'dark', title: 'Default Dark', icon: 'moon' },
        ],
        dynamicTitle: true,
      },
    },
  },
  initialGlobals: {
    theme: 'light',
  },
  decorators: [
    (Story, context) => {
      const theme = context.globals.theme || 'light'
      useEffect(() => {
        document.documentElement.setAttribute('data-theme', theme)
        return () => document.documentElement.removeAttribute('data-theme')
      }, [theme])
      return (
        <div
          style={{
            padding: '2rem',
            backgroundColor: 'var(--rudi-color-background-surface)',
            color: 'var(--rudi-color-text-default)',
            fontFamily: 'var(--rudi-font-family-sans)',
          }}
        >
          <Story />
        </div>
      )
    },
  ],
}

export default preview

The theme decorator

The decorator sets a data-theme attribute on document.documentElement. It does not add a class, and it does not put the attribute on the wrapper <div>.

Both alternatives are tempting and both are wrong. Theme CSS is generated by Style Dictionary under [data-theme="dark"] selectors, so a class never matches anything. And portalled content, such as the Dialog from chapter 9, renders outside the decorator’s wrapper into document.body. An attribute on the wrapper themes the page and leaves every overlay in the default theme, which is a confusing bug to chase. On the root element, everything inherits.

The cleanup function removes the attribute rather than resetting it to light. That matches how the library expects to be used: the default theme lives in :root, and light mode means no attribute at all. While the light theme is selected the decorator does set data-theme="light", but no rule matches that value, so it falls through to :root.

The wrapper is styled with inline var(--rudi-*) references rather than utility classes. bg-surface and text-text-default do not exist, because chapter 3 deliberately did not map tokens into a Tailwind @theme block.

The docs theme and the controlled-args decorator

parameters.docs.theme is set to a light Storybook theme explicitly. Without it the “Show code” source block falls back to a dark syntax palette, which renders light-on-light against the docs page background and is unreadable.

There is a second decorator worth adding once you have form components, and it prevents a problem that will otherwise make half your stories look broken.

Storybook renders components with the args from meta.args. When one of those args is a controlled prop such as value, isSelected, or selectedKey, the component becomes controlled, and a controlled input whose onChange never updates the arg cannot respond to typing or clicking. The checkbox refuses to check. Anyone evaluating the library concludes the component is broken.

The fix is a decorator that detects controlled props and wraps onChange to write the new value back:

(Story) => {
  const [args, updateArgs] = useArgs<Record<string, unknown>>()
  const hasIsSelected = 'isSelected' in args
  const hasValue = 'value' in args

  if (!hasIsSelected && !hasValue) return <Story />

  const original = args.onChange as ((v: unknown) => void) | undefined

  return (
    <Story
      args={{
        ...args,
        onChange: (newValue: unknown) => {
          if (hasIsSelected) updateArgs({ isSelected: newValue })
          if (hasValue) updateArgs({ value: newValue })
          original?.(newValue)
        },
      }}
    />
  )
}

The companion repository’s version also handles selectedKey and onSelectionChange for Select. The early return matters: without it every story gets an injected onChange, controlled or not.

Two more configuration files

.storybook/manager.ts themes the Storybook shell itself, which is what makes a deployed Storybook look like your documentation rather than a default install:

import { addons } from 'storybook/manager-api'
import { create } from 'storybook/theming/create'

addons.setConfig({
  theme: create({ base: 'light', brandTitle: 'Rudiment UI' }),
})

.storybook/preview-head.html is where font <link> tags and docs-page CSS overrides belong. Worth knowing: no fonts ship with the library. If your themes reference IBM Plex Sans, or the Rubik and Nunito faces that chapter 13’s themes use, and nothing loads them, text falls back to the system sans-serif. The result looks subtly off rather than obviously broken, which makes it a slow bug to notice. Load them here, or from the consuming application.

Story organization

The sidebar has five top-level sections, and their order is pinned by storySort rather than left to import order:

SectionTitle prefixContents
Getting StartedGetting Started/IntroductionHand-written MDX: installation, accessibility, tokens, theming
ComponentsComponents/Button, Components/Charts/BarChartOne entry per UI component; nested groups allowed
LayoutsLayouts/Stack, Layouts/SidebarOne entry per layout primitive
TypographyTypography/Heading, Typography/TextThe three typography components
ExamplesExamples/CRM/Dashboard, Examples/eCommerce/CheckoutComposed pages demonstrating the full system

The title field in each story’s meta controls placement, and a slash creates nesting, so Components/Charts/BarChart becomes a subgroup.

Pinning the order earns its config. Left to import order, the sidebar follows file discovery, so the first thing a new reader sees can be a props table rather than an introduction. storySort also accepts a nested array for pages inside a group, which is how the Getting Started sequence above is fixed.

The controls panel is a separate axis, and Storybook offers less control over it: controls: { sort: 'requiredFirst' } lifts required props to the top, and the remaining options are 'alpha' and 'none'. There is no parameter that pins an explicit category order, so the table: { category } values you started assigning in chapter 4 group the panel but do not sequence it. requiredFirst also sorts the remaining props alphabetically. If you would rather keep the declaration order of your argTypes block, leave sort unset; the default is 'none'.

What every story file includes

For layout primitives:

  1. Default: The primitive with token defaults and placeholder children.
  2. Custom props: The primitive with explicit prop values demonstrating customization.
  3. Composed: The primitive nested inside or containing other primitives.

For UI components:

  1. Default: The component in its default state.
  2. Variants: One story per visual variant.
  3. States: Stories for disabled, loading, error, and any other states.
  4. Composed: The component inside a layout primitive, demonstrating a realistic arrangement.

Beyond the stories themselves, every meta object carries four things:

  • tags: ['autodocs'], which generates a documentation page from the metadata and TypeScript prop types.
  • satisfies Meta<typeof RudiX> paired with type Story = StoryObj<typeof meta>, which is what makes story args type-check against the real props instead of any.
  • parameters.docs.description.component, the paragraph at the top of the generated page.
  • A description and a table: { category } on every argType.

One exception to autodocs is worth noting: internal components that exist only to compose others, such as an application shell or a top bar, are usually better left out of the generated documentation. Give them stories for development and skip the tag.

The example pages

Composed example pages are the closest thing to an integration test your documentation has, and they are what someone evaluating the library actually looks at.

The instinct is to build one kitchen-sink page containing every component. That page answers the wrong question. Adopters are not asking whether the library can render 40 components at once; they are asking whether it looks right for the thing they are building.

Organize by product domain instead, several pages each. The companion repository ships four sets:

DomainPages
CRMDashboard, Sales Dashboard, Finance Dashboard, Customers, Employees, Team Management
eCommerceLanding Page, Category Page, Product Page, Checkout, Order Confirmation, Empty Cart
Project ManagementDashboard, Board, Backlog, Epics, Stories, Burndown, Profile
BiotechLanding Page, About Us, Products & Services, Investors, Contact Us, Terms & Privacy

Each domain gets a shared.tsx holding its sample data and local presentational helpers, so the story files stay readable. Build these after the components are complete. They are where API gaps become obvious, and nearly every one produces a small fix somewhere upstream.

Deploying Storybook

The Storybook CLI adds the build script for you:

{
  "scripts": {
    "dev": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  }
}

Note the hyphen. build-storybook is the CLI’s own convention and differs from the colon-separated build:tokens you added in chapter 3. It is tempting to rename it for consistency; leave it, because Storybook’s own tooling and most deployment presets look for this exact name.

Then build the static site:

npm run build-storybook

The output lands in storybook-static, a plain static site you can deploy anywhere. Vercel and Netlify both deploy it from a Git repository with two settings: point the build command at npm run build-storybook and the publish directory at storybook-static.

One deployment detail that is easy to miss. npm run build-storybook does not build your tokens. If tokens/build/ is missing on a clean CI checkout, the CSS imports fail and the build stops. If it is stale, Storybook builds successfully and ships outdated values, which is harder to notice. Either commit tokens/build/ as chapter 3 suggested, or make the deploy command npm run build:tokens && npm run build-storybook.

The deployed URL becomes the public demo linked from your README and anywhere else you point people.

What you have now

A configured Storybook with accessibility audits on every story, a theme toggle wired to data-theme, a pinned sidebar order, generated documentation pages, and composed example pages organized by product domain. Every component and layout primitive has stories that document its API and demonstrate its behavior.

The stories are written but nothing verifies them yet. The next chapter adds the test layer, including a project that renders every story in a real browser and runs axe against it.

This chapter has been corrected since it was first published: see what changed. Found an error? Report it.