packages/dashboard/.storybook/CREATING-STORIES.md
This guide explains how to create high-quality, maintainable Storybook stories for components in this project.
Our Storybook setup uses a custom build-time plugin system to:
withDescription() helper to extract JSDoc from component filesargTypes for key interactive propsPlayground story for experimentationname, onBlur, ref)Every story file should follow this structure:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useForm } from 'react-hook-form';
import { ComponentName } from './component-name.js';
import { withDescription } from '../../../../.storybook/with-description.js';
const meta = {
title: 'Category/ComponentName',
component: ComponentName,
...withDescription(import.meta.url, './component-name.js'),
parameters: {
layout: 'centered', // or 'fullscreen', 'padded'
},
tags: ['autodocs'],
argTypes: {
// Define interactive controls here
},
} satisfies Meta<typeof ComponentName>;
export default meta;
type Story = StoryObj<typeof meta>;
// Playground story (required)
export const Playground: Story = {
// ...
};
// Demonstration stories (optional, only if needed)
export const ComplexInteraction: Story = {
// ...
};
The withDescription() helper extracts JSDoc documentation from your component file and inlines it into Storybook at build time.
...withDescription(import.meta.url, './component-name.js'),
Parameters:
import.meta.url - The current module URL (always use exactly this)'./component-name.js' - Relative path to the component file (use .js extension even for .tsx files)extractJSDocPlugin Vite plugin processes story fileswithDescription() calls and reads the component source file@example blocks into story metadatawithDescription import (no longer needed)The plugin matches components by converting PascalCase to kebab-case:
DateTimeInput → datetime-input.jsAffixedInput → affixed-input.jsSlugInput → slug-input.jsImportant: The component filename MUST match the export name when converted to kebab-case.
Define argTypes for props that users should be able to control interactively in Storybook.
Define argTypes for:
Don't define argTypes for:
argTypes: {
// Text control
value: {
control: 'text',
description: 'The current value',
},
// Boolean control
disabled: {
control: 'boolean',
description: 'Whether the input is disabled',
},
// Select control
type: {
control: 'select',
options: ['text', 'number', 'email', 'url'],
description: 'Input type',
},
// Number control
min: {
control: 'number',
description: 'Minimum value',
},
}
Every story file should have a Playground story as the first story. This allows users to experiment with the component interactively.
For form components that extend DashboardFormComponentProps, use React Hook Form's register():
export const Playground: Story = {
args: {
// Default values for interactive props
value: 'Default value',
disabled: false,
},
render: args => {
const { register } = useForm();
const field = register('fieldName');
return <ComponentName {...field} {...args} />;
},
};
useForm hookname, ref, onChange, onBlur){...field} to spread form registration props{...args} to pass argTypes props (will override field defaults)Create additional stories ONLY when they demonstrate:
disabled, placeholder, etc. (use Playground instead)export const ChangePassword: Story = {
render: () => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
return (
<div className="space-y-4">
<PasswordInput value={currentPassword} onChange={setCurrentPassword} {...} />
<PasswordInput value={newPassword} onChange={setNewPassword} {...} />
<PasswordInput value={confirmPassword} onChange={setConfirmPassword} {...} />
<div className="text-sm">
{newPassword && confirmPassword && newPassword === confirmPassword ? (
<span className="text-green-600">Passwords match ✓</span>
) : newPassword && confirmPassword ? (
<span className="text-red-600">Passwords do not match ✗</span>
) : (
<span>Enter and confirm your new password</span>
)}
</div>
</div>
);
},
};
Why keep: Shows multi-field validation pattern that's not possible with single component controls.
// ❌ Remove - trivially controllable via argTypes
export const Disabled: Story = {
render: () => {
return <TextInput value="Test" onChange={() => {}} disabled />;
},
};
// ❌ Remove - trivially controllable via argTypes
export const WithPlaceholder: Story = {
render: () => {
return <TextInput value="" onChange={() => {}} placeholder="Enter text" />;
},
};
Why remove: These variations are easily explorable through the Playground story's argTypes controls.
Here's a complete example for a simple input component:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useForm } from 'react-hook-form';
import { TextInput } from './text-input.js';
import { withDescription } from '../../../../.storybook/with-description.js';
const meta = {
title: 'Form Inputs/TextInput',
component: TextInput,
...withDescription(import.meta.url, './text-input.js'),
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'The current value',
},
disabled: {
control: 'boolean',
description: 'Whether the input is disabled',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
},
} satisfies Meta<typeof TextInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: {
value: 'Edit me!',
disabled: false,
placeholder: 'Enter text',
},
render: args => {
const { register } = useForm();
const field = register('text');
return <TextInput {...field} {...args} />;
},
};
// Only include if it demonstrates something non-trivial
export const LongText: Story = {
render: () => {
const { register } = useForm();
const field = register('longText');
return <TextInput {...field} />;
},
};
Form components that extend DashboardFormComponentProps use React Hook Form's register():
const { register } = useForm();
const field = register('fieldName');
return <ComponentName {...field} {...args} />;
The register() function automatically provides name, ref, onChange, and onBlur props.
To demonstrate fieldDef features (like prefix/suffix or readonly):
export const WithPrefixAndSuffix: Story = {
render: () => {
const { register } = useForm();
const field = register('affix');
return (
<NumberInput
{...field}
fieldDef={{ ui: { prefix: '$', suffix: 'USD' } }}
/>
);
},
};
For components with complex dependencies (like React Hook Form context), keep all necessary demonstration stories and provide minimal argTypes:
const meta = {
title: 'Form Inputs/SlugInput',
component: SlugInput,
...withDescription(import.meta.url, './slug-input.js'),
decorators: [
Story => (
<div className="w-[500px]">
<Story />
</div>
),
],
argTypes: {
// Only define argTypes for truly configurable props
entityName: {
control: 'text',
description: 'The entity name',
},
defaultReadonly: {
control: 'boolean',
description: 'Whether the input starts in readonly mode',
},
},
} satisfies Meta<typeof SlugInput>;
// Keep all demonstration stories that show different behaviors
export const AutoGenerating: Story = { /* ... */ };
export const WithExistingValue: Story = { /* ... */ };
export const ManualEditing: Story = { /* ... */ };
Problem: The plugin extracts JSDoc from the wrong export (e.g., a helper function instead of the component).
Solution: Ensure your component filename matches the export name when converted to kebab-case:
export function DateTimeInputdatetime-input.tsx ✅datetimeinput.tsx ❌Problem: The component description doesn't show in Storybook docs.
Checklist:
@description tag or description text?withDescription() call correct with proper file path?[extractJSDocPlugin] logs during buildProblem: TypeScript errors like "Property 'value' does not exist on type '{}'".
Solution: Cast args to the appropriate type:
const [value, setValue] = useState(args.value as string);
When creating a new story file:
withDescription from ../../../.storybook/with-description.js...withDescription(import.meta.url, './component-name.js') to metaargTypes for key interactive propsPlayground story with args and interactive stateuseForm() and register() for form components to automatically provide required props.storybook/extract-jsdoc-plugin.js.storybook/transform-jsdoc-plugin.js.storybook/main.tssrc/lib/components/data-input/*.stories.tsx