Web Component Storybook
Pipeline overview
index.js (JSDoc + LitElement)
|
V
storybook/custom-elements.json <- the custom element manifest (source of truth)
|
|- @wc-toolkit/jsx-types -> storybook/custom-elements.types.d.ts (typed *Props)
|- setCustomElementsManifest() (storybook/preview.js)
|
@wc-toolkit/storybook-helpers -> getStorybookHelpers() { args, argTypes, template }
|
*.stories.ts
Web Component Storybook setup details
The analyzer and CEM configuration
custom-elements-manifest.config.js drives cem analyze:
export default {
globs: ['packages/cfpb-design-system/src/elements/**/*.js'],
exclude: ['**/*.spec.js', '**/utilities/**'],
outdir: 'storybook',
litelement: true,
plugins: [
sortModulesPlugin(),
jsxTypesPlugin({
outdir: 'storybook',
fileName: 'custom-elements-types.d.ts',
// Generate imports relative to the storybook/ output dir
componentTypePath: (_name, _tag, modulePath) => `../${modulePath}`,
}),
],
};
-
litelement: truetells the analyzer to understand Lit’sstatic properties = {...}shorthand (attribute name, reflect, type) instead of requiring@property()decorators. -
exclude: ['**/utilities/**']keeps non-component helper modules (likeshared-config.js) out of the manifest -
sortModulesPluginis a local plugin added to make output diff-stable. Without this regenerating the manifest could produce reorder only diffs.
Run it manually any time with:
yarn analyze
wc-toolkit - the two packages in use
@wc-toolkit/jsx-types (analyzer plugin at build time): reads the manifest and emits storybook/custom-elements.types.d.ts - one
//storybook/custom-elements.types.d.ts snippet
export type CfpbButtonProps = {
'icon-left'?: CfpbButton['iconLeft'];
/** */
iconLeft?: CfpbButton['iconLeft'];
/** */
'icon-right'?: CfpbButton['iconRight'];
/** */
iconRight?: CfpbButton['iconRight'];
...
}
@wc-toolkit/storybook-helpers (runtime, used in every .stories.ts) reads the manifest (registered once via setCustomElementsManifest in .storybook/preview.js) and turns it into ready-made Storybook args/argTypes/template for a given tag:
const { args, argTypes, template } = getStorybookHelpers<CfpbButtonProps>(
'cfpb-button',
{ excludeCategories: ['methods] },
);
excludeCategories: ['methods'] is used in every story file. It drops public methods from the args/controls table since methods aren’t bindable Storybook controls.
setStorybookHlpersConfig({ hideArgsRef: true }) in preview.js suppresses the “ref” column the helper would otherwise add to the args table in the UI.
JSDoc - what works and what doesn't
Class level tags on the component’s leading doc comment work correctly and are the only reliable source of documentation right now:
/**
*
* @element cfpb-expandable
* @slot header - The header content for the expandable.
* @slot content - The content within the expandable.
* @fires expandbegin - The expandable started expanding.
* @fires expandend - The expandable finished expanding.
* @fires collapsebegin - The expandables started collapsing.
* @fires collapseend - The expandables finished collapsing.
*/
Verified in the generated manifest, these come through with the real descriptions:
// EG from the CEM at storybook/custom-elements.json
...
"slots": [
{
"description": "The header content for the expandable.",
"name": "header"
},
{
"description": "The content within the expandable.",
"name": "content"
}
],
...
"events": [
{
"name": "expandbegin",
"type": {
"text": "CustomEvent"
},
"description": "The expandable started expanding."
},
{
"name": "expandend",
"type": {
"text": "CustomEvent"
},
"description": "The expandable finished expanding."
},
{
"name": "collapsebegin",
"type": {
"text": "CustomEvent"
},
"description": "The expandables started collapsing."
},
{
"name": "collapseend",
"type": {
"text": "CustomEvent"
},
"description": "The expandables finished collapsing."
}
],
...
Gotcha: every component in this codebase also writes a @property block directly above static properties
In cfpb-button/index.js for example:
/**
* @property {string} type - The button type: button, submit, or reset.
* @property {boolean} disabled - Whether the button is disabled or not.
...
* @returns {object} The map of properties.
*/
static properties = {
type: { ... },
href: { ... },
...
That looks like it should document each attribute, but it doesn’t because the CEM lit-plugin doesn’t attach @property tag text to individual manifest attributes when they are declared this way.
The net effect of this currently is that the Storybook Controls/Docs tables do not show attribute descriptions for any component. Do not expect @property {type} name - description to do anything visible in Storybook for now. We can open a spike in the future to investigate the correct comment formatting for populating the CEM. I did not want to rewrite all the Web Components comments for this right now. I opted to keep it for in editor documentation, but just be aware of the implication for Storybook.
Attributes over properties for bindings
Since custom elements only receive strings/booleans through markup, stories should drive components through their attributes (kebab-case), not JS properties. This is why every args object in the 4 examples use attribute-cased keys: icon-left, icon-right, icon-left-spin, icon-right-spin, style-as-link, full-on-mobile which is matching the attribute: value declaration in each static properties entry.
getStorybookHelpers’s argTypes are keyed the same way, so when you override a control you also use the attribute name:
argTypes {
...argTypes,
'icon-left': { control: 'select', options: ['', ...iconNames] },
'icon-right': { control: 'select', options: ['', ...iconNames] },
}
If a component reflects a boolean attribute (reflect: true), prefer testing/asserting against the DOM attribute in play functions. There is an example of this in packages/cfpb-design-system/src/elements/cfpb-button/cfpb-button.stories.ts under CollapseProgramatically. We check button.getAttribute(aria-expanded), not a JS property to assert real rendered state.
This isn’t stylistic. Prior to fixing this, cfpb-button.stories.ts used camelCase keys here (iconLeft, iconRight) which silently failed to attach the icon-select controls to the real args since getStorybookHelpers generates attribute-cased keys.
Walkthrough of how the 4 example stories differ
-
cfpb-button.stories.tsis the fullest example. It uses the helper generatedtemplate(args)directly asrender(no custom markup needed since the button has a default slot). Adds adefault-slotpseudo-arg for the slotted button labelgetStorybookHelpersderives this arg automatically from the manifest’s unnamed slot entry (slots: [{ name: '', description: '...'}]) and the story just seeds a default value for it:args: { ...args, variant: 'primary', 'deafault-slot': 'Button label' },It also dynamically builds a select control foricon-left/icon-rightoff the actual icon SVG filenames and includes two intentionally invalid stories (InvalidVariant,InvalidType) tagged['!dev','!autodocs']. These exist to be picked up by the Vitest/Storybook test runner and verify that invalid values fall back gracefully without cluttering the storybook UI (sidebar/docs). -
cfpb-expandable.stories.tscan’t use the autotemplate()because it needs two named slots. This is the pattern to copy for any component with named slots. It also demonstrates theplayfunctions exercising the component’s 4 custom events usingfn()spies from thestorybook/testanduserEvent.click, plus a synthetic-event trick for the CSS-transition-drivencollapsend/expandendevents because the component’s internal BaseTransition listens for the Chromium-prefixed name first.CollapseProgramaticallyshows testing an imperative property write.It writes a custom
render:like this:render: ({ open, 'header-slot': header, 'content-slot': content }) => html`<cfpb-expandable ?open="${open}"> <span slot="header">${header}</span> <p slot="content">${content}</p> </cfpb-expandable>`, -
cfpb-tag-filter.stories.ts- back to autotemplate(args)since it only has a default slot. Demonstrates testing a custom even (item-click) via click, and a second story (focused) that calls the component’s own asyncfocus()method directly on the element reference rather than through play-function DOM interaction. That is a useful pattern when a component exposes imperative methods that don’t correspond to any attribute. -
cfpb-tagline.stories.ts- the minimal case. Single boolean property (isLarge, no explicitattribute:override so it defaults to the camelCase name), no play functions. Good starting template for the simplest components.
Recipe: adding a story for a component
- Confirm the component’s
index.jshas a class-level@element,@slotand@firesJSDoc block since these are what renders in the Docs page - Create
<name>.stories.tsnext toindex.js. Copy thecfpb-tagline.stories.tsas the minimal template, orcfpb-button.stories.tsif you are needing more than one variant. - Import
Meta/StoryObjfrom@storybook/web-components - Call
<Component>.init()at module scope before anything else - Call
getStorybookHelpers<xProps>('tag-name', { excludeCategories: ['methods'] }), importing theXPropstype from thestorybook/custom-elements-types - Decide
tempalate(args)vs customrender:use the autotemplateif the component onlhy has a default slot. Write a customhtmlrender (like in the expandables story) if it has named slots or needs conditional markup. - Set
meta.args/meta.argTypesusing attribute-cased keys, not camelCased properties. If you do this wrong it won’t error, it just silently no-ops the control or arg - Set
meta.component: 'tag-name'andtags: ['autodocs]. This isn’t optional. Withoutcomponent:set the auto-generatedOverviewdocs page fails to render its canvas and Attributes/Slots/Events table. - For components with custom events or imperative methods, add
playfunctions usingfn()+userEvent+expectfromstorybook/testfollowing thecfpb-expandable/cfpb-tag-filterpattern. Tag test-only/invalid-input stories['!dev, !autodocs]so they run in the test suite but don’t clutter the sidebar or docs page. - Run
yarn storybook(regenerates the manifest viayarn analyzefirst) and confirms the new story renders and controls bind correctly
Linting
Auto-generated CEM and storybook/custom-elements-types.d.ts get linted as part of yarn analyze and linting for TS files was added to the project.
Testing
vitest.config.js defines Vitest projects:
- Unit (
packages/**/*.spec.js, jsdom environment) -
storybook- usesstorybookTest()from@storybook/addon-vitest/vitest-plugin, pointed at.storybook/running in a real headless Chromium via@vitest/browser-playwright. This project turns every story (including the[!dev]tagged ones) into a Vitest test, and runs each story’splayfunction as the test body.
Run everything with yarn test
Accessibility (@storybook/addon-a11y)
Registered in .storybook/main.js and configured in .storybook/preview.js
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
Set as 'todo means that axe core accessibility checks run against every story automatically and violations surface in the Storybook a11y panel/Vitest addon UI, but DO NOT FAIL yarn test or CI. Bumping this to 'error' repo-wide would turn any existing violations across all stories into a build failure if we want that in the future. Importantly, new components get a11y checking for free just by having a story! No extra config per component needed.