Here's the thing about Storybook: it's incredibly powerful, flexible, and dynamic...but because of that flexibility, it can quickly devolve into a tangled mess of inconsistent patterns, duplicated code, and confusing file structures. We've all been there—you start with good intentions, build a few components, and six months later you're drowning in technical debt wondering why nobody can find anything.
This guide is your roadmap to building a Storybook that actually scales. Whether you're starting fresh or untangling an existing setup, you'll learn the patterns and conventions that separate the maintainable Storybooks from the ones that make developers groan. We're talking about practical, battle-tested approaches that'll make your component library a joy to work with instead of a source of frustration.
If you're new to Storybook, here's the elevator pitch: it's a dedicated workshop for building UI components and pages in isolation, separate from your business logic, APIs, and application state. That means you can develop and test those hard-to-reach edge cases without having to orchestrate your entire app into the right condition. Even better, it brings together your UI, examples, and documentation in one place, creating a single source of truth that makes it easier to discover and reuse existing patterns. It's your front-end playground and living style guide rolled into one.
Philosophy
Before thinking about how you want to structure your Storybook files, you need to determine who your audience is and what they need to know. Storybook has a lot of powerful plugins and tools that can either enhance or confuse, so my advice is to be judicious about what you use and why. Is your audience primarily developers, designers, or product managers? Do they need to know about the component's props, events, and methods? How about accessibility requirements or performance? How many code examples and usage patterns do you need to provide?
Remember that less is more. You don't necessarily need to create a new story for every variant, state, or use case. Sometimes, a single documentation page and one default story with robust props available is enough. Letting your users explore the component through the Storybook interface will often reveal additional use cases and patterns that you may not have considered.
Foundations
Before we get into the UI patterns and conventions, let's talk about some tooling choices that will help you create a consistent and maintainable Storybook.
Aliasing
Here's a quick win: set up your Storybook with package aliases instead relying on relative imports to reference other components. Your future self will thank you when it's time to refactor, and your paths will be more readable:
// Good
import { Template } from "@my-design-system/button/stories/template.js";
// Avoid
import { Template } from "../../../button/stories/template.js";Rendering libraries
This suggestion is mostly for projects shipping vanilla HTML, CSS, and JavaScript without a framework. If you're building for React, Vue, or Angular, what rendering engine you use will be dictated by the framework.
For vanilla projects, I recommend leveraging lit to handle your templates. Lit is a library that provides a set of utilities for building web components that are easy to test, debug, and reuse. The lit-html library is lightweight and performant with utilities for dynamic rendering that you'll be grateful for later.
💡 Bonus tip: You can even ship the template file with your component for CMS systems like Drupal or WordPress.
The lit package provides features such as:
- Conditional classes: apply classes based on the Storybook argsandcontext
- Style maps: dynamically apply inline styles
- Conditional rendering: show or hide elements
- Optional attributes: only render attributes when they're defined
Compose, don't duplicate
When you're building complex components or patterns that leverage other components, resist the urge to copy and paste markup. Instead, import and call their templates. This keeps everything consistent and cuts down on the maintenance burden.
File structure
The best architecture is self-documenting. When your files are organized in a way that is easy to understand and navigate, it becomes easier to contribute to the project and easier to find things when you need to. Though some of this will be dictated by the structure of your component library and the tools you have chosen, there are general principles that will help you create a standardized format. First, it's important to keep component-focused Storybook assets as close to the component source files as possible; this ensures the documentation stays up-to-date with component changes. Inside my component folder, I typically keep a stories folder with the following files:
- component.stories.js- story definitions, controls (- argTypes), default values (- args), and configuration
- template.js- reusable render functions that accept arguments and return rendered HTML
- [optional] component.docs.mdx- documentation for the component, including usage patterns, code examples, and best practices; alternatively, you can leverage JSDoc comments in your story file
- [optional] component.test.js- visual regression testing grids (used to define the component's states and variants for tools like Chromatic)
Story files
The story file (.stories.js) is where all the magic happens—it's where you define your component's stories, controls, and configuration. I like to keep all my stories for a component in a single file. This makes it easier to find and update stories, and it makes it easier to see all the stories for a component at a glance. That said, if a component (or more likely, a pattern) has a lot of stories, it might make sense to split them into multiple files. Let's break down each essential part of a story file.
Default export
Your default export is the entry point for all your stories. It contains all the metadata and configuration for your component:
export default {
  title: "Components/Button",
  component: "Button",
  argTypes: { /* control definitions */ },
  args: { /* default values */ },
  parameters: { /* additional configuration */ },
  tags: [ /* organizational tags */ ]
};Some of the guidance for these properties will differ based on what framework or rendering library you're using. I'm going to cover how to configure these properties for a vanilla project using lit, however, you can find more detailed information in the Storybook documentation for your specific framework.
Title
Pick a clear, human-readable title that reflects your component hierarchy. If your folder structure maps to how you want to group components in the sidebar, you can use a configuration object in your configuration file (.storybook/main.js):
export default {
  stories: [
      {
        // 👇 Sets the directory containing your stories
        directory: '../packages/components',
        // 👇 Storybook will load all files that match this glob
        files: '*.stories.*',
        // 👇 Used when generating automatic titles for your stories
        titlePrefix: 'MyComponents',
      },
    ],
};If you want to create a set of custom categorizations for your components, you can add as many paths as you need to the title. Every slash-separated segment will be used as a group in the sidebar, under which the component will appear.
export default {
  stories: [
    {
      title: 'Buttons/Close',
    },
  ],
};When determining how to group or nest your components, consider what distinctions would be most useful to your users. Does it benefit them to be able to dig into certain categories in one place instead of having to navigate through the entire library to find what they need? A few possible categories to consider are:
- Buttons
- Forms
- Graphs
- Layouts
- Navigation
Component
Specify the root component name here. This helps Storybook's documentation features understand what you're showcasing. If you're using a framework, this will typically be the component name or the imported component object. See the Storybook documentation for more information.
Controls
This is where you define the controls that users can interact with in the Storybook UI. Here's a pro tip: organize them into logical categories so people can quickly find what they're looking for:
- State—interactive states like focused, open, or disabled
- Variant—design variations like visual appearance or size
- Content—text, images, or nested components
- Advanced—edge cases or specialized configurations
argTypes: {
  size: {
    control: "select",
    options: ["small", "medium", "large"],
    description: "The size of the button",
    table: { category: "Variant" }
  },
  isDisabled: {
    control: "boolean",
    description: "Whether the button is disabled",
    table: { category: "State" }
  }
}Document and restrict these categories to ensure consistency across your component library. If you have a lot of controls, you can also consider creating a shared controls file to import and reuse across your components. You can store these in a controls folder inside your Storybook folder (i.e., .storybook/controls/states.js, .storybook/controls/variants.js, .storybook/controls/content.js, .storybook/controls/advanced.js). This is a great way to keep your controls organized and easy to find.
💡 Bonus tip: If you identify your Storybook folder as a workspace in your package manager, you can import the controls into your story files using the workspace alias. For example, if you have a
controlsfolder in your Storybook folder, you can import the controls like this:import { states, variants, content, advanced } from "local-storybook/controls";Be sure to export all your controls via an
index.jsfile in yourcontrolsfolder in order to leverage this syntax most effectively.
Default values
Always provide sensible defaults for all your controls. This way, your primary story renders in a useful state right out of the gate:
args: {
  size: "medium",
  isDisabled: false,
  label: "Click me"
}Not all controls have to have a default state, though. When given the choice between adding a meaningless normal or default value into the options that doesn't actually trigger a state, I opt for setting the default to undefined.
// Good
["outline", "subtle"]
// Bad
["default", "outline", "subtle"]Parameters
Parameters let you configure behavior and attach metadata. Think of them as the extra settings that make your stories more useful:
parameters: {
  design: {
    type: "figma",
    url: "https://www.figma.com/..."
  },
  actions: {
    handles: ["click", "focus", "blur"]
  },
  status: {
    type: "stable" // or "experimental", "deprecated"
  }
}Creating individual stories
Each story represents a specific state or variant of your component. Use .bind({}) to create stories that inherit from your default configuration, then override specific args to demonstrate different use cases:
export const Default = Template.bind({});
Default.args = {
  // Override specific args for this story
};
export const Disabled = Template.bind({});
Disabled.args = {
  isDisabled: true
};
export const WithIcon = Template.bind({});
WithIcon.args = {
  icon: "settings",
  label: "Settings"
};Naming conventions
Good story names are concise and descriptive. Here are some patterns that work well:
- Default—your primary interactive example
- With Icon—demonstrating optional features
- Loading State—showing specific states
- Error Variant—highlighting edge cases
And here's what not to do: don't repeat the component name in every story title. It's redundant and clutters your navigation.
Organizing with tags
Tags are your friends when it comes to controlling where stories appear and how they behave:
- Use !devto hide stories from the sidebar—super useful for documentation-only stories
- Use !autodocsto exclude stories from auto-generated docs pages
- Combine both tags for visual regression testing grids that shouldn't clutter your UI
Template files
Your template file (template.js) is where the actual rendering happens. Think of it as the engine that powers your stories.
Template structure
Export a primary Template(args, context) function that returns your rendered component. If you're using a library like Lit, this would be an html TemplateResult.
Here's the key: import and render nested component templates instead of duplicating their markup. We talked about this earlier, but it bears repeating—composition over duplication.
Standard arguments
Your templates should accept some common arguments to stay flexible:
- rootClass—the base CSS class for your component
- idand- testId—for identification and testing
- customClasses—an array of additional classes
- customStyles—an object of custom CSS properties
Using directives for dynamic behavior
Most rendering libraries give you directives or utilities to handle dynamic rendering. Here's what you'll typically need:
- classMap—for conditional classes based on your- rootClass
- styleMap—for inline style objects
- ifDefined—for optional attributes that should only render when they exist
- when—for conditional regions of your template
For interactive examples, use context.updateArgs to toggle arguments (like isOpen for a modal). And when you need to pass modifiable CSS custom properties, do it through customStyles—it's cleaner than hardcoding values.
Composition patterns
When you're composing complex components, use parent wrappers to position the pieces and pass arguments down to child templates. Here are some real-world examples of how this might look:
- An action bar might compose a popover, close button, and action group
- An action menu might compose a popover with an action button trigger and a menu for content
- A coach mark might compose a popover, coach indicator, and internal layout
Visual testing
If you're using a visual regression tool like Chromatic, you'll want to set up testing grids. These live in a separate component.test.js file.
Test grids
Most visual regression setups use some kind of variants helper to generate grids of different states and variants. You'll typically provide your Template along with arrays of test data and state data.
Here's the strategy: group many states and variants into a single snapshot grid to optimize your test runs. Nobody wants to wait forever for visual tests to complete.
In your main *.stories.js file, export the grid and mark it with !autodocs and usually !dev so it doesn't clutter your documentation or sidebar.
Control patterns
Keep your controls organized and consistent across your component library. If you have common state controls (like isOpen, isSelected, isHovered, or isFocused), put them in a shared location and import them.
Use the same categories we talked about earlier: State, Variant, Content, and Advanced. And here's a neat trick—use if conditions to tie related controls together. For example, only show imageIsFixedHeight when hasImage is true.
Don't be afraid to reuse controls from related components either. If another component already has the perfect icon dropdown setup, import it instead of rebuilding it from scratch.
Interactions and accessibility
Actions and events
When you're working with nested components, reuse their action handlers by spreading them into your own parameters.actions.handles. For interactive triggers like popover toggles, wire up onclick to context.updateArgs or leverage the behavior from child templates.
Accessibility
Always provide ARIA attributes through your arguments—things like aria-haspopup, aria-controls, aria-expanded, and aria-pressed. Use the ifDefined directive so they only render when needed.
Keep focus, hover, and active states controllable in your test grids. This makes it way easier to verify your component looks right in all states.
When you're demonstrating popovers or menus, make sure trigger and popup IDs are consistent and exposed as arguments. Your QA team (and screen reader users) will appreciate it.
Documentation and metadata
Design links
Link your stories to design files (like Figma) through parameters.design. This creates a direct connection between your implementation and the design source of truth.
Package information
Include your package.json and any metadata files in your parameters. This powers documentation blocks and helps users understand what version they're looking at.
Component status
Use parameters.status.type to communicate where your component is in its lifecycle—is it stable, experimental, or deprecated? Add tags like "migrated" to track major updates or migrations.
Dos and don'ts
Let's wrap up with some quick guidelines to keep you on the right track:
Do
- Import child component templates instead of copying their markup
- Keep your template.jsfocused on the component itself—acceptcustomClassesandcustomStylesfor composition
- Categorize your controls and set sensible defaults in args
- Hide VRT-only stories from your sidebar and docs using tags
- Reuse shared controls from a common location instead of duplicating them
Don't
- Don't hardcode IDs—use a helper function to generate random IDs
- Don't duplicate markup from nested components—import and compose instead
- Don't skip accessibility—always include proper ARIA attributes
Code examples
Here are some practical code snippets to get you started.
Story default export
Here's what a typical story file default export looks like:
import packageJson from "../package.json";
export default {
  title: "Components/Button",
  component: "Button",
  argTypes: {
    size: {
      control: "select",
      options: ["small", "medium", "large"],
      table: { category: "Variant" }
    },
    isDisabled: {
      control: "boolean",
      table: { category: "State" }
    }
  },
  args: {
    size: "medium",
    isDisabled: false,
    label: "Click me"
  },
  parameters: {
    design: {
      type: "figma",
      url: "https://www.figma.com/..."
    },
    packageJson,
    status: { type: "stable" }
  },
  tags: ["autodocs"]
};Template composition
When you're composing complex components, here's how you might structure it:
import { Template as Popover } from "@my-design-system/popover/stories/template.js";
import { Template as Button } from "@my-design-system/button/stories/template.js";
export const Template = (args, context) => Popover({
  ...args,
  isOpen: true,
  trigger: (passthroughs, ctx) => Button({
    label: "Open Menu",
    ...passthroughs
  }, ctx),
  content: [/* child content templates */]
}, context);Test grid setup
If you're setting up visual regression tests, your test file might look like this:
import { Template } from "./template.js";
export const TestGrid = {
  render: Template,
  parameters: {
    chromatic: { disableSnapshot: false }
  }
};
export const Default = TestGrid.bind({});
Default.tags = ["!autodocs", "!dev"];
Default.args = {
  // Test-specific args
};Wrapping up
Building a Storybook that scales isn't about following every best practice to the letter—it's about making intentional choices that serve your team and your users. Throughout this guide, we've covered the foundational decisions that'll keep your component library maintainable: organizing your files so they're easy to navigate, composing components instead of duplicating markup, structuring your story files with clear defaults and well-organized controls, and never forgetting about accessibility.
The most important takeaway? Consistency wins. Pick the patterns that make sense for your project—whether that's keeping all stories in one file or splitting them up, using Lit for vanilla projects or sticking with your framework's conventions, setting up visual regression tests or relying on manual QA. Whatever you choose, document it, share it with your team, and stick with it. A consistent Storybook with clear conventions will always be more valuable than a perfectly optimized one that nobody can navigate.
Six months from now, when you're diving back into a component you haven't touched in ages, you'll be grateful you put in the effort upfront. Your teammates will thank you. Your designers will thank you. And most importantly, you'll avoid that tangled mess of technical debt we talked about at the start. Let's build something great!
