Skip to main content

Using Storybook For UI Development

The iframe below will not render until the "X-Frame-Options: deny" setting has been removed -- hopefully soon

We use shadcn as the baseline for our component development.
We use Storybook for component hosting and documentation.
Our Storybook site is hosted at https://storybook.charles-schwab.vercel.app/

Our shadcn Integration

We follow shadcn's philosophy of copy-paste components rather than installing a component library. This gives us full control over styling and behavior while maintaining consistency.

What We Use from shadcn

1. The cn() Utility Function

Located at /packages/ui/src/utilities/shad/index.ts, this is the core utility for merging Tailwind classes:

import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

/**
* Takes in a clsx compliant set of classes and joins them into a singular string.
* Intelligently merges Tailwind classes, resolving conflicts.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

Usage in Components:

import { cn } from '../../utilities/shad';

<div className={cn(
'base-classes',
variantClasses,
conditionalClass && 'conditional-classes',
className // User-provided overrides
)} />

2. Radix UI Primitives

We use Radix UI headless components as the foundation (same as shadcn):

"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.11"

These provide:

  • Accessibility - WAI-ARIA compliant out of the box
  • Keyboard Navigation - Full keyboard support
  • Focus Management - Proper focus trapping and restoration
  • Unstyled - Complete styling control

3. Component Architecture Pattern

We adapt shadcn components to our needs. Example: Tabs component at /packages/ui/src/components/Shadcn/:

Shadcn/
├── Tabs.client.tsx # Radix UI wrapper with our styling
└── TabsWrapper.tsx # High-level wrapper for our use cases

Tabs.client.tsx - Base shadcn-style component:

'use client';

import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@schwab/ui/cn';
import { cva, type VariantProps } from 'class-variance-authority';

const Tabs = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root
{...props}
ref={ref}
className={cn(TabsVariants({ className }))}
>
{children}
</TabsPrimitive.Root>
));

const TabsVariants = cva('base-classes', {
variants: {
variant: {
default: 'bg-metal-700',
accessibleBlue: 'bg-blue-700',
},
},
});

TabsWrapper.tsx - Application-specific wrapper:

import { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs.client';

export function TabsWrapper({
tabListAriaLabel, // Required for accessibility
tabsInfo,
}: Props) {
return (
<Tabs defaultValue={tabsInfo[0]?.value} activationMode="manual">
<TabsList aria-label={tabListAriaLabel} loop={true}>
{tabsInfo.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{/* Content... */}
</Tabs>
);
}

How Our Approach Differs from Pure shadcn

1. Monorepo Structure

  • shadcn: Components live in your app's components/ui folder
  • Ours: Components in /packages/ui/src/components/ for cross-app reuse

2. Schema-Driven Props

  • shadcn: TypeScript interfaces for props
  • Ours: Zod schemas for runtime validation + type generation

3. CVA Integration

  • shadcn: Basic CVA usage
  • Ours: Centralized baseCVAClasses for design system consistency

4. Server/Client Split

  • shadcn: Client components with 'use client'
  • Ours: Separate .server.tsx and .client.tsx files for NextJS optimization

5. Path Aliases

  • shadcn: Relative imports ../../components/ui/button
  • Ours: Absolute aliases #components/Button, #utilities/shad

When to Use shadcn-Style Components

Use for:

  • Interactive UI primitives (Tabs, Dialogs, Popovers, Accordions)
  • Components requiring accessibility features
  • Reusable form controls
  • Complex interaction patterns

Don't use for:

  • Simple presentational components (use direct Tailwind)
  • Content-specific components (Article, Story, etc.)
  • Layout components (Container, Panel)

Adding New shadcn Components

If you need a shadcn component we don't have:

  1. Check Radix UI availability - Verify the primitive exists

  2. Install Radix package (if needed):

    cd /home/nextjs-web/packages/ui
    pnpm add @radix-ui/react-[component-name]
  3. Create in Shadcn folder:

    cd /home/nextjs-web/packages/ui/src/components/Shadcn
    touch YourComponent.client.tsx
  4. Follow the pattern:

    • Use 'use client' directive
    • Wrap Radix primitive with forwardRef
    • Apply CVA for variants
    • Use cn() for class merging
    • Export component and its sub-components
  5. Add export to package.json:

    "exports": {
    "./YourComponent": "./src/components/Shadcn/YourComponent.client.tsx"
    }
  6. Create Storybook stories - Follow standard story creation process

Best Practices for shadcn Components

Accessibility First:

<TabsList 
aria-label="Navigation tabs" // Always provide labels
loop={true} // Enable keyboard wrapping
/>

<TabsTrigger
value="tab1"
activationMode="manual" // Prevents auto-activation on focus
>

Use CVA for Variants:

const componentVariants = cva(
'base-classes', // Always-applied classes
{
variants: {
variant: {
default: 'default-styles',
custom: 'custom-styles',
},
},
defaultVariants: {
variant: 'default',
},
},
);

Preserve Radix Props:

// Always spread Radix props to preserve functionality
<TabsPrimitive.Root {...props} ref={ref} className={cn(...)}>

Type Safety:

interface TabsList extends 
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
VariantProps<typeof TabsListVariants> {}

Available shadcn-Style Components

Currently implemented in /packages/ui/src/components/Shadcn/:

  • Tabs - Tab navigation with multiple variants
    • Accessible keyboard navigation
    • Support for horizontal layouts
    • Customizable trigger and list styling

More components can be added following the same pattern as needed.

Why use Storybook?

  1. Develop and test in isolation - Build components without needing the full application context
  2. QA in isolation - Test individual components thoroughly before integration
  3. Documentation - Auto-generated documentation and visual component library
  4. Single Source of Truth - Centralized component showcase and specifications
  5. Accessibility - Built-in accessibility testing and validation tools

Proposed Workflow

1. Determine if component should be present in Storybook

For the majority of components moving forward, the component should be developed in the Storybook environment as opposed to in the context of a page or site.

Components that belong in Storybook:

  • Reusable UI components
  • Form elements and inputs
  • Layout components
  • Interactive components with various states
  • Components with multiple variants or themes

Components that may not need Storybook:

  • Page-specific components with no reuse potential
  • Simple one-off wrappers
  • Components tightly coupled to specific business logic

2. Develop component with an eye towards adaptability and maintenance

Components should be usable/adaptable to work within a multitude of situations. Developer should ask:

  • What other use cases are applicable to this component?
  • Are requested changes (if any) going to hamper the component's ability to be extended in the future?
  • What other components have a dependency on this component? How will this affect those components?

Design System Thinking: In all cases, be aware that site owners, etc. tend to think of components on a prescriptive basis i.e. "this page needs a specific functionality." Our role as developers within a design system is to translate those needs into code that works for a variety of situations, even if that means modifying the context of the request.

3. Documentation during development

Developers should update documentation as they work. Storybook provides a number of tools for documentation but the most useful is being able to auto-generate documentation using comments in TSX files.

Required Documentation:

  • All components should have a basic description of their purpose and use
  • All props should have documentation through interface commenting
  • Usage examples and common patterns
  • Do's and Don'ts for component usage

Example Documentation Pattern:

/**
* Button component for user actions and navigation
*
* Use this component for primary and secondary actions throughout the application.
* Supports multiple variants, sizes, and states.
*
* @example
* <Button variant="primary" size="large" onClick={handleClick}>
* Click me
* </Button>
*/
export interface ButtonProps {
/** The visual style variant of the button */
variant?: 'primary' | 'secondary' | 'ghost';
/** The size of the button */
size?: 'small' | 'medium' | 'large';
/** Whether the button is disabled */
disabled?: boolean;
/** Click handler function */
onClick?: () => void;
/** Button content */
children: React.ReactNode;
}

4. Develop/update component and seek approval from UX/Designer/Owner

Before creating a PR, use the Vercel preview URL to seek approval and suggestions from concerned parties. In many cases, additional questions will arise after the initial round of development is complete.

Review Process:

  1. Deploy component to Storybook preview environment
  2. Share preview URL with stakeholders
  3. Gather feedback and iterate as needed
  4. Document any design decisions or compromises
  5. Get final approval before proceeding to PR

5. Test in Storybook

Storybook provides a number of tools for testing your code:

Available Testing Tools:

  • Accessibility testing - Built-in a11y addon for WCAG compliance
  • Unit testing - Jest integration for component logic testing
  • Interaction testing (TBD) - Simulated user interactions
  • Visual regression testing (TBD) - Screenshot comparison testing

Consider using Chromatic, a paid service for visual regression testing that is tightly integrated with Storybook. This provides:

  • Automated visual diffing
  • Cross-browser testing
  • Component history tracking
  • Integration with PR workflows

6. Test in Site

Using Vercel preview, test if the component is accomplishing its purpose within the actual application context.

Integration Testing Checklist:

  • Component renders correctly in various page contexts
  • Props are passed correctly from parent components
  • Component behaves as expected with real data
  • No styling conflicts with other components
  • Performance is acceptable in production-like environment

7. Create PR

After completing development, testing, and stakeholder review:

  1. Create pull request with comprehensive description
  2. Include links to Storybook stories for reviewers
  3. Document any breaking changes or migration notes
  4. Request reviews from appropriate team members
  5. Address feedback and iterate as needed

How to Add a UI Component to Storybook

Follow this step-by-step guide to add a new component to our Storybook.

Prerequisites

Before you begin, ensure:

  • Your component will be located in /home/nextjs-web/packages/ui/src/components/[ComponentName]/
  • You have a Zod schema defined (or will create one) in /home/nextjs-web/packages/schema/src/ui/[ComponentName]/[ComponentName]Schema.ts
  • Storybook is running locally (pnpm dev from /home/nextjs-web/apps/storybook)

Our Component Architecture Overview

Our component library in /home/nextjs-web/packages/ui/src/components/ contains 78+ components organized by functionality:

Layout Components: Container, Panel, Mosaic, Deck, Grid structures
Content Components: Card, Tile, ArticleTile, DeckTile
Media Components: Image, Video, Audio, Podcast, PDF
Navigation: Meganav, NavContainer, NavItem, Link, SearchBar
Typography: Heading, RichText, PullQuote, Footnote
Interactive: Button, Modal, Dialog, Popover, Sheet, Toast
Data Display: Table, HighChart, GoogleMaps
Specialized: Logo, Icon, Spinner, Skeleton, Lockup
Content Management: ContentRenderEngine, Resource, Collection

Each component follows a consistent pattern with separation between client and server logic.

Step 1: Component File Structure

Our components follow one of three architectural patterns depending on their complexity and requirements:

Pattern 1: Full Server/Client Split (Most Common)

Used for complex components with both server and client logic.

packages/ui/src/components/Card/
├── Card.tsx # Wrapper that decides server/client routing
├── Card.server.tsx # Server-side rendering logic
├── Card.stories.tsx # Storybook stories
└── __tests__/
└── Card.test.tsx

Example: Card.tsx (Wrapper)

import { TCardProps } from '@schwab/schema/ui/Card/CardSchema';
import CardServer from './Card.server';

/**
* A React Functional Component that leverages props to generate and return JSX.
* @param {TCardProps} props
* @returns {ReturnType<typeof CardServer>}
*/
export default function Card(props: TCardProps): ReturnType<typeof CardServer> {
return <CardServer {...props} />;
}

Example: Card.server.tsx (Implementation)

import { TCardProps } from '@schwab/schema/ui/Card/CardSchema';
import { cva } from 'class-variance-authority';
import { type JSX } from 'react';
import Image from '#components/Image';
import Lockup from '#components/Lockup';
import { baseCVAClasses } from '../../utilities/cva/common';
import { cn } from '../../utilities/shad';

/**
* A React Functional Component that leverages props to generate and return JSX.
* @param {TCardProps} props
* @returns {JSX.Element}
*/
export default function CardServer({
borderColor,
palette,
image,
mediaBleed = false,
hasPriorityImage,
body,
disclosure,
eyebrow,
heading,
headingLink,
headingLevel = 3,
headingStyle = 'bcn-heading--m-bold',
iconComponent,
iconImage,
links,
textAlign,
children,
componentName = 'Card',
className,
}: TCardProps): JSX.Element {
return (
<div
data-component={componentName}
className={cn(cardVariants({ borderColor, palette }), className)}
>
{image && <CardImage image={image} hasPriorityImage={hasPriorityImage} mediaBleed={mediaBleed} />}
<CardBody
body={body}
disclosure={disclosure}
heading={heading}
headingLevel={headingLevel}
headingLink={headingLink}
headingStyle={headingStyle}
iconImage={iconImage}
iconComponent={iconComponent}
eyebrow={eyebrow}
links={links}
textAlign={textAlign}
>
{children}
</CardBody>
</div>
);
}

/**
* Card Variants using Class Variance Authority (CVA)
*/
const cardVariants = cva(
['@container/card', 'rounded-lg', 'overflow-hidden', 'flex', 'flex-col', 'h-full'],
{
variants: {
palette: baseCVAClasses.palette,
borderColor: baseCVAClasses.border,
},
},
);

Pattern 2: Server + Client Split

Used when components need both server and client-specific logic.

packages/ui/src/components/Image/
├── Image.tsx # Main wrapper
├── Image.server.tsx # Server-side logic
├── Image.client.tsx # Client-side logic (for interactivity)
└── __tests__/
└── Image.test.tsx

Example: Image.tsx

import type { TImageProps } from '@schwab/schema/ui/Image/ImageSchema';
import ImageServer from './Image.server';

/**
* A React Functional Component that leverages props to generate and return JSX.
* @param {TImageProps} props
* @returns {ReturnType<typeof ImageServer>}
*/
export default function Image(props: TImageProps): ReturnType<typeof ImageServer> {
return <ImageServer {...props} />;
}

Pattern 3: Simple Single File

Used for simple components without server/client complexity.

packages/ui/src/components/Spinner/
└── Spinner.tsx # Single file with component

Example: Spinner.tsx

import { JSX } from 'react';
import IconSpinner from '#icons/IconSpinner';

/**
* A React Functional Component that leverages props to generate and return JSX.
* @param {React.HTMLAttributes<HTMLDivElement>} props
* @returns {JSX.Element}
*/
function Spinner({ className }: React.HTMLAttributes<HTMLDivElement>): JSX.Element {
return (
<div className={`flex items-center justify-center h-20 w-full ${className}`}>
<IconSpinner className="animate-spin" />
<span className="sr-only">Loading...</span>
</div>
);
}

export { Spinner };

Key Architectural Patterns

1. Component Aliases: Use path aliases for imports:

import Image from '#components/Image';
import Lockup from '#components/Lockup';
import IconSpinner from '#icons/IconSpinner';

2. CVA for Styling: Use Class Variance Authority with our shared base classes:

import { baseCVAClasses } from '../../utilities/cva/common';

const componentVariants = cva(
['base', 'classes'], // Base classes
{
variants: {
palette: baseCVAClasses.palette, // Reuse color palettes
borderColor: baseCVAClasses.border, // Reuse border colors
},
},
);

3. Utility Functions: Use the cn() utility for conditional classes:

import { cn } from '../../utilities/shad';

<div className={cn(componentVariants({ palette, size }), className)} />

The cn() utility is our shadcn-inspired utility that:

  • Merges multiple class strings using clsx
  • Resolves Tailwind class conflicts using twMerge
  • Handles conditional classes elegantly
  • Allows user overrides through className prop

Example usage:

// Basic merging
cn('px-4 py-2', 'bg-blue-500') // 'px-4 py-2 bg-blue-500'

// Conflict resolution (last wins)
cn('px-4', 'px-6') // 'px-6'

// Conditional classes
cn('base-classes', isActive && 'active-classes', className)

// With CVA
cn(buttonVariants({ variant, size }), disabled && 'opacity-50', className)

4. Sub-components: Break complex components into sub-components within the same file:

function CardContainer({ children, palette }: Props): JSX.Element {
return <div className={cn(cardVariants({ palette }))}>{children}</div>;
}

function CardBody({ body, children }: Props): JSX.Element {
return <div className="p-6">{body || children}</div>;
}

5. Data Attributes: Always add a data-component attribute for debugging:

<div data-component="Card" className={...}>

Available Base CVA Classes

Our design system provides reusable CVA variant classes in /packages/ui/src/utilities/cva/common.ts:

Color Palettes (baseCVAClasses.palette)

Available background color schemes with appropriate text colors:

palette: baseCVAClasses.palette

Available palettes:

  • white - White background, dark text
  • blue - Blue 700 background, white text
  • blueClickable - Blue with hover states
  • blue-95 - Light blue background
  • sapphire - Sapphire 700 background
  • metal - Metal 800 background, white text
  • metal-70 - Metal 400 background
  • metal-95 - Light metal background
  • coastal-40 - Coastal 700 with underlined links
  • coastal-95 - Light coastal background
  • teal, teal-95 - Teal variants
  • tangerine-95 - Light tangerine background
  • none - No background styling

Border Colors (baseCVAClasses.border)

Consistent border styling:

borderColor: baseCVAClasses.border

Available borders:

  • blue, sapphire, metal, metal-70, coastal, white, teal, teal-60, tangerine

Heading Styles (baseCVAClasses.headingStyle)

Typography styles for headings:

headingStyle: baseCVAClasses.headingStyle

Modern heading styles:

  • bcn-heading--xl, bcn-heading--xl-bold - Extra large (text-4xl/text-7xl)
  • bcn-heading--l, bcn-heading--l-bold - Large (text-3xl/text-4xl)
  • bcn-heading--m, bcn-heading--m-bold - Medium (text-2xl/text-3xl) - Default for most components
  • bcn-heading--s, bcn-heading--s-bold - Small (text-2xl)
  • bcn-heading--xs-bold - Extra small (text-xl)

Article heading styles:

  • bcn-article-heading--3xl, bcn-article-heading--3xl-bold - (text-9xl/text-12xl)
  • bcn-article-heading--2xl, bcn-article-heading--2xl-bold - (text-8xl/text-11xl)
  • bcn-article-heading--xl, bcn-article-heading--xl-bold - (text-7xl/text-10xl)
  • bcn-article-heading--l, bcn-article-heading--l-bold - (text-6xl/text-9xl)

Background Focal Points (baseCVAClasses.backgroundFocalPoint)

For background image positioning:

backgroundFocalPoint: baseCVAClasses.backgroundFocalPoint

Available positions:

  • left_top, center_top, right_top
  • left_center, center_center, right_center
  • left_bottom, center_bottom, right_bottom

Body Styles (baseCVAClasses.bodyStyle)

Typography for body text:

bodyStyle: baseCVAClasses.bodyStyle

Available styles:

  • bcn-body--s - Small body text (text-sm)
  • Various deprecated styles for backward compatibility

Example: Using Base CVA Classes

import { baseCVAClasses } from '../../utilities/cva/common';
import { cva } from 'class-variance-authority';

const myComponentVariants = cva(
['rounded-lg', 'p-6'], // Base classes
{
variants: {
palette: baseCVAClasses.palette, // Reuse all color palettes
borderColor: baseCVAClasses.border, // Reuse all border colors
headingStyle: baseCVAClasses.headingStyle, // Reuse heading typography
// Add custom variants
size: {
small: ['p-3', 'text-sm'],
large: ['p-8', 'text-lg'],
},
},
defaultVariants: {
palette: 'white',
size: 'small',
},
},
);

Step 2: Create the Schema (if needed)

Define your component's props using Zod in the schema package. The schema serves as the single source of truth for component props and automatically generates TypeScript types and Storybook controls.

Location: /home/nextjs-web/packages/schema/src/ui/YourComponent/YourComponentSchema.ts

// packages/schema/src/ui/YourComponent/YourComponentSchema.ts
import { z } from 'zod';

/**
* Schema for YourComponent props.
* This schema is used for:
* - TypeScript type generation
* - Runtime validation
* - Storybook controls generation
* - API validation
*/
export const YourComponentSchema = z.object({
/**
* Component variant style
* Controls the visual appearance of the component
*/
variant: z.enum(['primary', 'secondary', 'ghost']).optional().default('primary'),

/**
* Component size
* Affects padding, font size, and overall dimensions
*/
size: z.enum(['small', 'medium', 'large']).optional().default('medium'),

/**
* Whether the component is disabled
* Disables interaction and applies disabled styling
*/
disabled: z.boolean().optional().default(false),

/**
* Additional CSS classes to apply
* Use for custom styling or layout adjustments
*/
className: z.string().optional(),

/**
* Component content
* Can be text, elements, or other React components
*/
children: z.any(),
});

/**
* TypeScript type inferred from the schema
* Use this type for component props
*/
export type TYourComponentProps = z.infer<typeof YourComponentSchema>;

Important Guidelines:

  1. JSDoc Comments: Every schema property MUST have a JSDoc comment - these become the Storybook control descriptions
  2. Naming Convention: Use T prefix for types (e.g., TYourComponentProps)
  3. Schema Export: Export both the schema and the inferred type
  4. Defaults: Use .default() for optional props that should have a default value
  5. Enums: Use z.enum() for props with fixed options - these become select controls in Storybook

Step 3: Create the Stories File

Create a .stories.tsx file in your component directory:

// packages/ui/src/components/YourComponent/YourComponent.stories.tsx
import { YourComponentSchema, TYourComponentProps } from '@schwab/schema/ui/YourComponent/YourComponentSchema';
import { transformZodToArgTypes } from '@schwab/utilities/functions/transformZodToArgTypes';
import type { Meta, StoryObj } from '@storybook/nextjs';
import YourComponent from './YourComponent';

const meta: Meta<TYourComponentProps> = {
title: 'NextGen-Tailwind/YourComponent', // Category/ComponentName
component: YourComponent,
parameters: {
layout: 'padded', // Options: 'centered', 'padded', 'fullscreen'
docs: {
description: {
component: 'A brief description of your component and its primary use case.'
}
}
},
decorators: [
(Story) => (
<div className="max-w-md"> // Optional: Constrain component width
<Story />
</div>
),
],
argTypes: transformZodToArgTypes(
YourComponentSchema.omit({
// Omit internal/complex props from Storybook controls
className: true,
}).shape,
),
};

export default meta;
type Story = StoryObj<TYourComponentProps>;

// Default/primary story - this becomes the "Default" in Storybook
export const Default: Story = {
name: 'Default State',
args: {
variant: 'primary',
size: 'medium',
children: 'Your Component',
},
};

// Additional variant stories
export const Secondary: Story = {
name: 'Secondary Variant',
args: {
variant: 'secondary',
size: 'medium',
children: 'Secondary Component',
},
};

// Story with custom render function
export const WithCustomRender: Story = {
name: 'With Custom Context',
args: {
variant: 'primary',
size: 'large',
children: 'Custom Rendered Component',
},
render: (args) => {
return (
<>
<p className="text-xs mb-6">
<b>Note:</b> This story demonstrates custom rendering with additional context.
</p>
<YourComponent {...args} />
</>
);
},
};

Step 3b: Optional - Add MDX Documentation

For complex components, you can add an .mdx file for enhanced documentation:

packages/ui/src/components/Panel/
├── Panel.tsx
├── Panel.server.tsx
├── Panel.stories.tsx
└── Panel.mdx # Enhanced documentation

Example: Panel.mdx

import { Meta, Controls, Primary, Stories } from '@storybook/addon-docs/blocks';
import * as PanelStories from './Panel.stories';

<Meta of={PanelStories} />

# Panel

## Description
Panel extends Container. It is intended to show children components in a single or row layout without wrapping in desktop view.

## Usage
- Children elements should be wrapped in a DIV element in order for layout to be applied correctly.
- Use for horizontal layouts of related content
- Provides consistent spacing and alignment

## Accessibility Guidelines
- Ensure proper heading hierarchy within panel content
- Use semantic HTML for child elements
- Maintain color contrast ratios

## Known Issues
None

<Controls />
<Stories />

This creates a rich documentation page with:

  • Component description
  • Usage guidelines
  • Accessibility notes
  • Interactive controls
  • All stories displayed below

Step 4: Key Configuration Options

Story Title Organization

The title property determines the component's location in Storybook's sidebar:

title: 'NextGen-Tailwind/ComponentName'  // Appears in NextGen-Tailwind section
title: 'NextGen-Tailwind/Forms/Input' // Creates nested folders
title: 'Deprecated/OldComponent' // Appears in Deprecated section

Our standard categories (defined in .storybook/preview.tsx):

  • NextGen-Tailwind - Modern Tailwind CSS components
  • Documentation - Documentation pages
  • MFE - Micro-frontend components
  • Deprecated - Legacy components

Layout Options

Control the story canvas layout with the layout parameter:

parameters: {
layout: 'centered', // Center component (good for small components)
layout: 'padded', // Add padding (default for most components)
layout: 'fullscreen', // No padding (good for page-level components)
}

Using transformZodToArgTypes

This utility automatically generates Storybook controls from your Zod schema:

argTypes: transformZodToArgTypes(
YourComponentSchema.omit({
// Omit props that shouldn't be controlled in Storybook
className: true,
children: true,
internalProp: true,
}).shape,
)

Benefits:

  • Automatic control types (select, boolean, text, etc.)
  • Auto-generated descriptions from JSDoc comments
  • Type-safe prop controls
  • Single source of truth for props

Decorators

Decorators wrap your stories with additional markup or context:

decorators: [
(Story) => (
<div className="max-w-md p-4 bg-gray-100">
<Story />
</div>
),
]

Common use cases:

  • Constrain component width
  • Add background colors
  • Provide layout context
  • Add theme providers

Step 5: Story Naming Best Practices

// ✅ Good: Descriptive, clear purpose
export const Default: Story = { name: 'Default Button' };
export const PrimaryLarge: Story = { name: 'Primary Large' };
export const WithIcon: Story = { name: 'With Leading Icon' };
export const DisabledState: Story = { name: 'Disabled State' };

// ❌ Bad: Vague or technical
export const Story1: Story = { name: 'Test' };
export const Example: Story = { name: 'Example' };

Step 6: Testing Your Stories Locally

  1. Start Storybook development server:

    cd /home/nextjs-web/apps/storybook
    pnpm dev
  2. Access Storybook: Open http://localhost:6006 in your browser

  3. Verify your component:

    • Navigate to your component in the sidebar
    • Test all story variants
    • Verify controls work correctly
    • Check the "Docs" tab for auto-generated documentation
    • Test accessibility with the a11y addon (bottom panel)
  4. Check for issues:

    • No console errors
    • All props render correctly
    • Controls update the component
    • Documentation is complete and accurate

Step 7: Advanced Story Patterns

Multiple Stories with Shared Args

const BaseArgs = {
variant: 'primary',
size: 'medium',
};

export const Default: Story = {
args: BaseArgs,
};

export const Large: Story = {
args: {
...BaseArgs,
size: 'large',
},
};

Stories with Complex Children

export const WithComplexContent: Story = {
args: {
variant: 'primary',
},
render: (args) => (
<YourComponent {...args}>
<div className="flex items-center gap-2">
<Icon name="check" />
<span>Complex Content</span>
</div>
</YourComponent>
),
};

Stories with NextJS-specific Features

export const BankContext: Story = {
args: {
variant: 'primary',
},
parameters: {
nextjs: {
navigation: {
pathname: '/bank', // Simulate bank page context
},
},
},
};

Step 8: Storybook Configuration Files

Our Storybook is configured with these key files:

.storybook/main.ts

  • Defines where stories are located
  • Configures addons
  • Sets up framework options
  • Manages static assets
stories: [
'../../../packages/ui/src/components/**/*.mdx',
'../../../packages/ui/src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../docs/**/*.mdx',
]

.storybook/preview.tsx

  • Configures global story parameters
  • Sets up decorators (like fonts)
  • Defines story sorting order
  • Configures global toolbar options

Step 9: Embedding Stories in Documentation

You can embed your Storybook stories in other documentation using iframes:

<!-- Embed a specific story -->
<iframe
src="https://storybook.charles-schwab.vercel.app/iframe.html?id=nextgen-tailwind-yourcomponent--default&viewMode=story"
width="100%"
height="400">
</iframe>

<!-- Embed full component docs -->
<iframe
src="https://storybook.charles-schwab.vercel.app/iframe.html?id=nextgen-tailwind-yourcomponent--docs&viewMode=docs"
width="100%"
height="600">
</iframe>

How to find your story ID:

  1. Navigate to your story in Storybook
  2. Look at the URL: ?path=/story/<story-id>
  3. Story ID format: category-componentname--storyname (all lowercase, kebab-case)

URL Parameters:

  • viewMode=story - Show just the component
  • viewMode=docs - Show documentation page
  • args=propName:value - Override props
  • nav=0 - Hide navigation

Step 10: Common Patterns from Existing Components

Card Component Pattern

// Omit complex props, provide realistic defaults
argTypes: transformZodToArgTypes(
CardSchema.omit({
role: true,
badgeType: true,
video: true,
}).shape,
)

Logo Component Pattern

// Responsive story with explanatory note
export const Responsive: Story = {
name: 'Responsive Logo',
args: {
className: 'size-12 lg:size-24',
type: 'responsive',
},
render: (args) => (
<>
<p className="text-xs mb-6">
<b>Note:</b> Change screen width to see responsive behavior.
</p>
<Logo {...args} />
</>
),
};

Mosaic Component Pattern

// Dynamic child components
render: (args) => (
<Mosaic {...args}>
{createChildComponents(args.itemCount)}
</Mosaic>
)

Storybook Configuration Best Practices

Addons Configuration

Our Storybook uses these essential addons (configured in .storybook/main.ts):

  • @storybook/addon-a11y - Accessibility testing and WCAG validation
  • @storybook/addon-docs - Auto-generated documentation from stories
  • @storybook/addon-essentials - Core Storybook functionality (via NextJS framework)
  • @storybook/nextjs - NextJS-specific integration

Global Configuration

Configured in .storybook/preview.tsx:

parameters: {
options: {
storySort: {
method: 'alphabetical',
order: ['NextGen-Tailwind', 'Documentation', 'MFE', '*', 'Deprecated'],
},
},
controls: {
sort: 'requiredFirst', // Required props appear first
disableSaveFromUI: true, // Prevent saving from UI
},
}

Custom Toolbar Features

We have a custom "Main Column" toolbar that shows brand standard column guides:

globalTypes: {
showMainColumn: {
name: 'Main column',
description: 'Shows guides for the main content column',
toolbar: {
icon: 'component',
items: [
{ value: true, title: 'Show Main Column' },
{ value: false, title: 'Hide Main Column' },
],
},
},
}

Running Storybook

Development Mode

cd /home/nextjs-web/apps/storybook
pnpm dev

Starts Storybook on http://localhost:6006

Build for Production

cd /home/nextjs-web/apps/storybook
pnpm build

Outputs to storybook-static/ directory

Production Deployment

Our Storybook is automatically deployed to Vercel:

Real-World Examples

Example 1: Card Component

See /home/nextjs-web/packages/ui/src/components/Card/Card.stories.tsx for a complete example showing:

  • Multiple variants (with image, with icon, text-only)
  • Custom render functions
  • Omitting complex props from controls
  • Using realistic test data

Example 2: Logo Component

See /home/nextjs-web/packages/ui/src/components/Logo/Logo.stories.tsx for:

  • Context-dependent rendering (bank vs. default)
  • Responsive stories with explanatory notes
  • NextJS navigation parameter usage

Example 3: Mosaic Component

See /home/nextjs-web/packages/ui/src/components/Mosaic/Mosaic.stories.tsx for:

  • Dynamic child component generation
  • Multiple layout variations
  • Complex component composition

Component Creation Workflow Guide

Complete Workflow for Creating a New Component

Follow these steps to create a production-ready component from scratch:

Step 1: Planning & Design Review

  1. Review design specs and component requirements
  2. Identify reusability opportunities
  3. Determine which architectural pattern to use:
    • shadcn-style (in /Shadcn/ folder) - Interactive primitives with Radix UI
    • Full Server/Client split - Complex components with server rendering
    • Simple single file - Basic presentational components
  4. Check if similar components exist that could be extended
  5. Check if a Radix UI primitive exists for interactive components
  6. Get stakeholder alignment on component scope

Step 2: Create Directory Structure

# Navigate to components directory
cd /home/nextjs-web/packages/ui/src/components

# Create component directory
mkdir YourComponent
cd YourComponent

# Create test directory
mkdir __tests__

Step 3: Create Zod Schema

# Navigate to schema directory
cd /home/nextjs-web/packages/schema/src/ui

# Create component schema directory
mkdir YourComponent
cd YourComponent

# Create schema file
touch YourComponentSchema.ts

Edit the schema with proper JSDoc comments and type definitions.

Step 4: Implement Component Files

Create files in this order:

  1. YourComponent.tsx - Main wrapper
  2. YourComponent.server.tsx - Implementation
  3. YourComponent.client.tsx - Client logic (if needed)

Use existing components as reference (Card, Panel, Lockup).

Step 5: Create Stories

cd /home/nextjs-web/packages/ui/src/components/YourComponent
touch YourComponent.stories.tsx

Create at least 3 stories showing different use cases.

Step 6: Start Storybook and Iterate

cd /home/nextjs-web/apps/storybook
pnpm dev

Navigate to http://localhost:6006 and test your component:

  • Verify all variants render correctly
  • Test controls in Storybook
  • Check accessibility panel
  • Test responsive behavior
  • Verify in different color palettes

Step 7: Create Tests

cd /home/nextjs-web/packages/ui/src/components/YourComponent/__tests__
touch YourComponent.test.tsx

Write unit tests for component logic and rendering.

Step 8: Documentation

If component is complex, create MDX documentation:

cd /home/nextjs-web/packages/ui/src/components/YourComponent
touch YourComponent.mdx

Step 9: Integration Testing

Test component in actual application context:

  1. Import component into a page
  2. Test with real data
  3. Verify no styling conflicts
  4. Check performance

Step 10: Code Review & Deploy

  1. Run type checking: pnpm type-check
  2. Run tests: pnpm test
  3. Build Storybook: pnpm build
  4. Create PR with preview links
  5. Address review feedback
  6. Merge when approved

Quick Reference: File Templates

Minimal Component Template

// YourComponent.tsx
import { TYourComponentProps } from '@schwab/schema/ui/YourComponent/YourComponentSchema';
import { JSX } from 'react';
import YourComponentServer from './YourComponent.server';

export default function YourComponent(props: TYourComponentProps): JSX.Element {
return <YourComponentServer {...props} />;
}

Minimal Server Component Template

// YourComponent.server.tsx
import { TYourComponentProps } from '@schwab/schema/ui/YourComponent/YourComponentSchema';
import { cva } from 'class-variance-authority';
import { type JSX } from 'react';
import { baseCVAClasses } from '../../utilities/cva/common';
import { cn } from '../../utilities/shad';

export default function YourComponentServer({
palette = 'white',
className,
children,
}: TYourComponentProps): JSX.Element {
return (
<div
data-component="YourComponent"
className={cn(variants({ palette }), className)}
>
{children}
</div>
);
}

const variants = cva(['rounded-lg'], {
variants: {
palette: baseCVAClasses.palette,
},
defaultVariants: {
palette: 'white',
},
});

Minimal Schema Template

// YourComponentSchema.ts
import { z } from 'zod';

/**
* Schema for YourComponent props
*/
export const YourComponentSchema = z.object({
/** Color palette for component background and text */
palette: z.enum(['white', 'blue', 'metal']).optional().default('white'),

/** Additional CSS classes */
className: z.string().optional(),

/** Component children */
children: z.any(),
});

export type TYourComponentProps = z.infer<typeof YourComponentSchema>;