STORYBOOK.md
July 23, 2026 ยท View on GitHub
Web Component Storybook details
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
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 manifestsortModulesPluginis 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/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.
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.
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.
-
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.
- 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
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.
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
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.