Building Web Components Story-First with Storybook

The most useful shift we made with our Storybook web components setup was a change of mindset: from “add a story for the component” to “build the component through its stories.”
We first expected Storybook to give us a visual catalog of our web components. It did, but that turned out to be the smallest part of its value. As our setup evolved, it became part development environment, part documentation site and part test suite. Getting there was not about installing Storybook. The real work was shaping it around the way we build and consume components.
Starting with a Storybook Web Components Catalog
Our components are built with web standards and Lit, so we chose the Storybook web components framework with Vite. This gave us a place to render components in isolation and try out different states. (If you are new to Lit templates, my post on separating Lit-Element and lit-html templates covers the basics.)
Each story became a focused example of a component or directive. Instead of navigating through an entire application to reach a component, developers could open Storybook and see the relevant behavior straight away. Even the first, simple stories were valuable because they gave developers, testers and consumers a shared reference point.
Building Features One Story at a Time
A story is an isolated starting point for a feature state. We begin with the default state, check the markup and styling, and add states as the feature develops. A component might gradually gain stories for:
- Default content
- Disabled and loading states
- Empty and error states
- Sizes or variants
- Localized content
- Dark mode
- Keyboard interaction
Rather than building the whole feature inside the application and checking it at the end, we build one state, inspect it, test it and move on to the next. Each story is a small feedback loop for implementation, styling, accessibility, documentation and testing.
Making Stories Useful as Documentation
A story is far more useful when its controls explain the component’s API. We added typed story args, enum-backed controls, argTypes descriptions and default values, so the controls panel doubles as an interactive API reference.
Here is a simplified story for a message-bar component. The defaults live in one constant, which is shared by the docs table, the story args and (later) the HTML source transform, so they cannot drift apart:
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import { MessageBarColor, MessageBarKind } from './message-bar';
interface MessageBarStoryArgs {
color: MessageBarColor;
kind: MessageBarKind;
dismissible: boolean;
title: string;
message: string;
}
export const MESSAGE_BAR_DEFAULTS: MessageBarStoryArgs = {
color: MessageBarColor.Informative,
kind: MessageBarKind.Contextual,
dismissible: true,
title: 'Notice:',
message: 'We require key information from you.',
};
const meta: Meta<MessageBarStoryArgs> = {
title: 'Web Components/MessageBar',
tags: ['autodocs'],
argTypes: {
color: {
control: { type: 'select' },
options: Object.values(MessageBarColor),
description: 'Semantic color intent of the message bar.',
table: { defaultValue: { summary: MESSAGE_BAR_DEFAULTS.color } },
},
kind: {
control: { type: 'select' },
options: Object.values(MessageBarKind),
description: 'Controls the message bar layout.',
table: { defaultValue: { summary: MESSAGE_BAR_DEFAULTS.kind } },
},
dismissible: {
control: { type: 'boolean' },
description: 'Whether the message bar includes a dismiss button.',
table: { defaultValue: { summary: String(MESSAGE_BAR_DEFAULTS.dismissible) } },
},
title: {
control: { type: 'text' },
description: 'Short title, also used as the accessible name.',
table: { defaultValue: { summary: MESSAGE_BAR_DEFAULTS.title } },
},
message: {
control: { type: 'text' },
description: 'Message text.',
table: { defaultValue: { summary: MESSAGE_BAR_DEFAULTS.message } },
},
},
args: { ...MESSAGE_BAR_DEFAULTS },
render: (args) => html`
<message-bar
data-testid="message-bar-element"
color=${args.color}
kind=${args.kind}
dismissible=${String(args.dismissible)}
aria-labelledby="mb-title"
>
<span>
<strong id="mb-title">${args.title}</strong>
${args.message}
</span>
</message-bar>
`,
};
export default meta;
type Story = StoryObj<MessageBarStoryArgs>;
Our components read dismissible as a "true"/"false" string attribute rather than a presence-only boolean attribute, so the render function writes the value out explicitly. The source transform later in this post treats it the same way.
Autodocs put this information next to the examples, but they were not always enough on their own. Complex components still needed carefully written stories and explanations that show how they should really be used.

Extending Storybook with an HTML Tab
One of the most useful parts of our setup was an addon that adds an HTML tab to Storybook. It is an extension, not part of core Storybook.
The HTML tab does not show the browser’s final rendered DOM. It shows the authoring code used to create the story, which is what developers need when they use the component themselves. It lets them:
- See how the component is authored
- Copy and adapt examples outside Storybook
- Understand attributes and boolean flags
- See nested content and complex structures
- Get implementation-ready code that follows the controls
The output has to be clean to be useful. Test-only attributes, comments, styles and values that match defaults make it noisy, so we added a shared transform through Storybook’s parameters.docs.source.transform hook. It reuses the same defaults and the same title and message args as the render function, so the HTML tab always matches what is rendered:
import type { StoryContext } from '@storybook/web-components';
import { formatComponentTag } from '../utils/storybook-transform';
const meta: Meta<MessageBarStoryArgs> = {
// ...argTypes, args and render as above
parameters: {
docs: {
source: {
language: 'html',
transform: (_code: string, storyContext: StoryContext<MessageBarStoryArgs>) => {
const { title, message, ...attrs } = storyContext.args;
const inner = ` <span>
<strong id="mb-title">${title}</strong>
${message}
</span>`;
return formatComponentTag(
'message-bar',
{ ...attrs, 'aria-labelledby': 'mb-title' },
{
booleanStrings: ['dismissible'],
defaults: MESSAGE_BAR_DEFAULTS,
},
inner,
);
},
},
},
},
};
For the default story, that produces a focused example:
<message-bar aria-labelledby="mb-title">
<span>
<strong id="mb-title">Notice:</strong>
We require key information from you.
</span>
</message-bar>
This mattered more than we first thought. If the generated source is hard to read, the documentation loses much of its value. The HTML tab should show the code a developer would realistically write, not an internal representation of the story.

Moving from Examples to Tests
Next, we added interaction tests to stories with play functions. A story can check that the component renders the expected structure, responds to changes and supports user interaction, with the setup visible right next to the assertions. Each story becomes a behavioral contract rather than a static screenshot.
Lit updates asynchronously, so the test waits for updateComplete after each attribute change before asserting:
import { expect, userEvent, waitFor, within } from '@storybook/test';
import type { MessageBar } from './message-bar';
export const Default: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const host = canvas.getByTestId<MessageBar>('message-bar-element');
await expect(host).toBeInTheDocument();
const messageBar = host.querySelector('.message-bar');
await expect(messageBar).not.toBeNull();
await expect(messageBar).toHaveAttribute('role', 'alert');
await step('Responds to attribute changes', async () => {
host.setAttribute('color', 'warning');
await host.updateComplete;
await expect(messageBar).toHaveClass('message-bar--warning');
host.setAttribute('kind', 'global');
await host.updateComplete;
await expect(messageBar).toHaveClass('message-bar--global');
host.setAttribute('dismissible', 'false');
await host.updateComplete;
await expect(host.querySelector('.message-bar__dismiss')).toBeNull();
});
},
};
Attribute checks prove the API works, but users don’t set attributes; they click buttons. So we also test the dismiss behavior the way a user would, by finding the button by its role and accessible name:
export const Dismiss: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const host = canvas.getByTestId('message-bar-element');
await step('Dismisses with a click', async () => {
await userEvent.click(canvas.getByRole('button', { name: /dismiss/i }));
await waitFor(() => expect(host.querySelector('.message-bar')).toBeNull());
});
},
};
This works because our components render into the light DOM, so Testing Library’s queries can reach the button. If the button lived inside a shadow root, within(canvasElement) would not find it; you would query through host.shadowRoot or use a shadow-DOM-aware query helper instead.
Not every test belongs in a story, but interaction tests suit states that users can see and interact with directly.

Accessibility in Every Story
Because each state has its own story, each state can also be checked for accessibility as it is built, not in an audit at the end.
- Choose the right live region role.
role="alert"is assertive and interrupts screen reader users, so it suits warnings and errors. For informative or success messages,role="status"is polite and usually the better fit. The component can derive the role from its color, for examplealertfor warning and error andstatusotherwise, and the play function can assert the role for each color story. - Run the a11y addon.
@storybook/addon-a11yruns axe checks against every story, so missing names, contrast problems and invalid ARIA show up in the panel while you work. - Test the keyboard, not just the mouse. A play function can tab to the dismiss button and press Enter or Space, which catches focus and keyboard-handling bugs that a click test misses (my post on finding all focusable elements, including inside shadow DOMs goes deeper into focus):
await userEvent.tab();
await expect(canvas.getByRole('button', { name: /dismiss/i })).toHaveFocus();
await userEvent.keyboard('{Enter}');
Finding Problems Earlier
Exposing every attribute of our Storybook web components through controls also surfaced problems that would otherwise wait until a consumer asked for a particular scenario.
Some attributes were not fully observable. As an illustration: an attribute is documented and appears in the controls panel, but it is missing from the component’s observedAttributes (or the property is not declared to react to attribute changes), so changing it after first render does nothing. In an application this is easy to miss because the value is usually set once; in Storybook, flipping the control and seeing no change makes it obvious.
The catalog also exposed inconsistent attribute naming. For example, one component might call its visual intent variant, another color and a third type, for the same set of values. Seen side by side in one catalog, the inconsistency was hard to ignore, and it gave us a clear case for consolidating naming conventions and making the APIs more predictable.
The Integration Work We Did Not Expect
The hard part was not the first story. It was fitting our Storybook web components setup to the details of a real component library: light-DOM rendering, shared styles, icon assets, fonts, themes, localization, and properties that cannot be expressed as simple HTML attributes.
Some states needed more than a set of controls, such as custom render functions or source transforms to show the correct usage. Themes mattered too: a component can look right in one theme and break in another, so switching between light and dark modes became part of the shared preview configuration.
All of this reinforced one lesson: Storybook adoption is not separate from component architecture. The quality of the Storybook experience depends on how clearly the component API, styling model and state model are defined.
What We Learned
Storybook pays off when stories are part of the development process, not documentation added at the end. A good story:
- Gives developers an isolated place to build a feature
- Shows how the component should be authored
- Provides a testable state, including accessibility checks
- Exposes gaps in component behavior and inconsistencies across APIs
- Gives reviewers a clear way to inspect changes
- Documents the component for future consumers
There is still work involved. Stories need maintenance, complex states need thoughtful setup and generated docs may need customizing. But that work pays off when the same stories support development, testing, documentation and collaboration.
Final Thoughts
Adopting Storybook was not a one-off setup task. It was an ongoing process of deciding what developers need to know, how components should be demonstrated and which states are worth testing.
Building the component through its stories made Storybook more than a gallery. It became a practical workspace for developing features one state at a time, catching component issues early and giving the people who use our components a more consistent API.