React Component Scaffolder
Scaffolds a complete React component with TypeScript types, Tailwind styles, Storybook stories, and unit tests. Follows project conventions automatically.
React Component Scaffolder
Command
/scaffold-component
Overview
A Claude Code command that generates production-ready React components with TypeScript, proper file structure, tests, stories, and styling. Give it a component name and description, and it scaffolds the entire component directory following your project's conventions.
Quick Start
# Scaffold a basic component claude "Scaffold a UserProfile component that displays avatar, name, email, and role badge" # Scaffold with specific requirements claude "Create a DataTable component with sorting, pagination, row selection, and CSV export" # Scaffold a form component claude "Build an InviteUserForm with email validation, role selector, and invite button"
Generated File Structure
src/components/UserProfile/
āāā UserProfile.tsx # Component implementation
āāā UserProfile.test.tsx # Unit tests
āāā UserProfile.stories.tsx # Storybook stories
āāā UserProfile.module.css # CSS Modules (or .styles.ts for CSS-in-JS)
āāā useUserProfile.ts # Custom hook (if needed)
āāā UserProfile.types.ts # TypeScript interfaces
āāā index.ts # Public exports
Component Templates
Functional Component (Default)
// UserProfile.tsx import { memo } from 'react'; import type { UserProfileProps } from './UserProfile.types'; import styles from './UserProfile.module.css'; export const UserProfile = memo(function UserProfile({ user, showEmail = true, onEdit, className, }: UserProfileProps) { return ( <div className={`${styles.container} ${className ?? ''}`}> <img src={user.avatarUrl} alt={`${user.name}'s avatar`} className={styles.avatar} width={48} height={48} /> <div className={styles.info}> <h3 className={styles.name}>{user.name}</h3> {showEmail && ( <p className={styles.email}>{user.email}</p> )} <span className={`${styles.badge} ${styles[user.role]}`}> {user.role} </span> </div> {onEdit && ( <button onClick={onEdit} className={styles.editButton} aria-label={`Edit ${user.name}'s profile`} > Edit </button> )} </div> ); });
TypeScript Types
// UserProfile.types.ts export interface User { id: string; name: string; email: string; avatarUrl: string; role: 'admin' | 'editor' | 'viewer'; } export interface UserProfileProps { /** The user data to display */ user: User; /** Whether to show the email address. Defaults to true */ showEmail?: boolean; /** Callback when edit button is clicked. If omitted, edit button is hidden */ onEdit?: () => void; /** Additional CSS class name */ className?: string; }
Barrel Export
// index.ts export { UserProfile } from './UserProfile'; export type { UserProfileProps, User } from './UserProfile.types';
Component Patterns
With Data Fetching Hook
// useUserProfile.ts import { useState, useEffect } from 'react'; import type { User } from './UserProfile.types'; interface UseUserProfileOptions { userId: string; enabled?: boolean; } interface UseUserProfileReturn { user: User | null; isLoading: boolean; error: Error | null; refetch: () => void; } export function useUserProfile({ userId, enabled = true }: UseUserProfileOptions): UseUserProfileReturn { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const fetchUser = async () => { setIsLoading(true); setError(null); try { const res = await fetch(`/api/users/${userId}`); if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); const data = await res.json(); setUser(data); } catch (err) { setError(err instanceof Error ? err : new Error('Unknown error')); } finally { setIsLoading(false); } }; useEffect(() => { if (enabled && userId) fetchUser(); }, [userId, enabled]); return { user, isLoading, error, refetch: fetchUser }; }
Container + Presenter Pattern
// UserProfileContainer.tsx (Smart component ā handles data) import { UserProfile } from './UserProfile'; import { useUserProfile } from './useUserProfile'; export function UserProfileContainer({ userId }: { userId: string }) { const { user, isLoading, error } = useUserProfile({ userId }); if (isLoading) return <UserProfileSkeleton />; if (error) return <ErrorMessage message={error.message} />; if (!user) return null; return <UserProfile user={user} />; }
Form Component
// InviteUserForm.tsx import { useState, useCallback, type FormEvent } from 'react'; import type { InviteFormData, InviteFormProps } from './InviteUserForm.types'; export function InviteUserForm({ onSubmit, isLoading }: InviteFormProps) { const [formData, setFormData] = useState<InviteFormData>({ email: '', role: 'viewer', message: '', }); const [errors, setErrors] = useState<Partial<Record<keyof InviteFormData, string>>>({}); const validate = useCallback((): boolean => { const newErrors: typeof errors = {}; if (!formData.email) { newErrors.email = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { newErrors.email = 'Enter a valid email address'; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }, [formData]); const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (validate()) { onSubmit(formData); } }; return ( <form onSubmit={handleSubmit} noValidate> <div> <label htmlFor="invite-email">Email address</label> <input id="invite-email" type="email" value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} /> {errors.email && <span id="email-error" role="alert">{errors.email}</span>} </div> <div> <label htmlFor="invite-role">Role</label> <select id="invite-role" value={formData.role} onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value as any }))} > <option value="viewer">Viewer</option> <option value="editor">Editor</option> <option value="admin">Admin</option> </select> </div> <button type="submit" disabled={isLoading}> {isLoading ? 'Sending...' : 'Send Invite'} </button> </form> ); }
Compound Component
// Tabs.tsx ā Compound component pattern import { createContext, useContext, useState, type ReactNode } from 'react'; interface TabsContextType { activeTab: string; setActiveTab: (tab: string) => void; } const TabsContext = createContext<TabsContextType | null>(null); function useTabs() { const ctx = useContext(TabsContext); if (!ctx) throw new Error('Tab components must be used within <Tabs>'); return ctx; } export function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) { const [activeTab, setActiveTab] = useState(defaultTab); return ( <TabsContext.Provider value={{ activeTab, setActiveTab }}> <div role="tablist">{children}</div> </TabsContext.Provider> ); } export function TabButton({ id, children }: { id: string; children: ReactNode }) { const { activeTab, setActiveTab } = useTabs(); return ( <button role="tab" aria-selected={activeTab === id} onClick={() => setActiveTab(id)} > {children} </button> ); } export function TabPanel({ id, children }: { id: string; children: ReactNode }) { const { activeTab } = useTabs(); if (activeTab !== id) return null; return <div role="tabpanel">{children}</div>; }
Styling Options
CSS Modules (Default)
/* UserProfile.module.css */ .container { display: flex; align-items: center; gap: 1rem; padding: 1rem; border-radius: 0.5rem; background: var(--surface); } .avatar { border-radius: 50%; object-fit: cover; } .name { font-size: 1.125rem; font-weight: 600; margin: 0; } .email { color: var(--text-secondary); font-size: 0.875rem; margin: 0.25rem 0 0; } .badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; } .admin { background: #fef2f2; color: #dc2626; } .editor { background: #eff6ff; color: #2563eb; } .viewer { background: #f0fdf4; color: #16a34a; }
Tailwind CSS
export function UserProfile({ user, showEmail = true, onEdit }: UserProfileProps) { const roleBadgeColors = { admin: 'bg-red-100 text-red-700', editor: 'bg-blue-100 text-blue-700', viewer: 'bg-green-100 text-green-700', }; return ( <div className="flex items-center gap-4 rounded-lg bg-white p-4 shadow-sm"> <img src={user.avatarUrl} alt={`${user.name}'s avatar`} className="h-12 w-12 rounded-full object-cover" /> <div className="flex-1"> <h3 className="text-lg font-semibold text-gray-900">{user.name}</h3> {showEmail && <p className="text-sm text-gray-500">{user.email}</p>} <span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeColors[user.role]}`}> {user.role} </span> </div> {onEdit && ( <button onClick={onEdit} className="rounded-md bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-200" > Edit </button> )} </div> ); }
Testing
// UserProfile.test.tsx import { render, screen, fireEvent } from '@testing-library/react'; import { UserProfile } from './UserProfile'; import type { User } from './UserProfile.types'; const mockUser: User = { id: '1', name: 'Jane Doe', email: '[email protected]', avatarUrl: '/avatar.jpg', role: 'admin', }; describe('UserProfile', () => { it('renders user name and role', () => { render(<UserProfile user={mockUser} />); expect(screen.getByText('Jane Doe')).toBeInTheDocument(); expect(screen.getByText('admin')).toBeInTheDocument(); }); it('renders email by default', () => { render(<UserProfile user={mockUser} />); expect(screen.getByText('[email protected]')).toBeInTheDocument(); }); it('hides email when showEmail is false', () => { render(<UserProfile user={mockUser} showEmail={false} />); expect(screen.queryByText('[email protected]')).not.toBeInTheDocument(); }); it('renders edit button when onEdit is provided', () => { const onEdit = jest.fn(); render(<UserProfile user={mockUser} onEdit={onEdit} />); fireEvent.click(screen.getByRole('button', { name: /edit/i })); expect(onEdit).toHaveBeenCalledTimes(1); }); it('hides edit button when onEdit is not provided', () => { render(<UserProfile user={mockUser} />); expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); }); it('renders avatar with alt text', () => { render(<UserProfile user={mockUser} />); expect(screen.getByAltText("Jane Doe's avatar")).toBeInTheDocument(); }); });
Storybook Stories
// UserProfile.stories.tsx import type { Meta, StoryObj } from '@storybook/react'; import { UserProfile } from './UserProfile'; const meta: Meta<typeof UserProfile> = { title: 'Components/UserProfile', component: UserProfile, tags: ['autodocs'], argTypes: { onEdit: { action: 'edit clicked' }, }, }; export default meta; type Story = StoryObj<typeof UserProfile>; export const Default: Story = { args: { user: { id: '1', name: 'Jane Doe', email: '[email protected]', avatarUrl: 'https://i.pravatar.cc/150?u=jane', role: 'admin', }, }, }; export const WithoutEmail: Story = { args: { ...Default.args, showEmail: false, }, }; export const Viewer: Story = { args: { user: { ...Default.args!.user!, role: 'viewer' }, }, }; export const WithEditButton: Story = { args: { ...Default.args, onEdit: () => alert('Edit clicked'), }, };
Accessibility Checklist
Every scaffolded component includes:
- Semantic HTML elements (
button,nav,main, notdivwith onClick) - ARIA labels for interactive elements without visible text
-
roleattributes where semantic HTML isn't sufficient -
aria-invalidandaria-describedbyon form fields with errors - Keyboard navigation support (focus management, tab order)
- Color contrast ratios meeting WCAG AA (4.5:1 for text)
-
alttext on images (emptyalt=""for decorative images) - Focus visible styles (never
outline: nonewithout replacement) - Screen reader announcements for dynamic content (
aria-live)
Best Practices
- Props over state ā Prefer controlled components; lift state up when possible
- Composition over inheritance ā Use children and render props, not class inheritance
- Single responsibility ā Each component does one thing well
- Explicit props ā Avoid spreading
...reston DOM elements (security risk) - Memoize expensive renders ā
memo()for pure components,useMemo/useCallbackfor values - Collocate related files ā Keep component, tests, styles, and types together
- Export from index ā Only export what consumers need from the barrel file
- Document props ā Use JSDoc comments on interface properties
- Handle all states ā Loading, error, empty, success for every data-driven component
- Test user behavior ā Test what the user sees and does, not implementation details
Reviews
No reviews yet. Be the first to review this template!
Similar Templates
Git Commit Message Generator
Generates well-structured conventional commit messages by analyzing staged changes. Follows Conventional Commits spec with scope detection.
CI/CD Pipeline Generator
Generates GitHub Actions workflows for CI/CD including linting, testing, building, and deploying. Detects project stack automatically.
Act Action
Streamline your workflow with this execute, github, actions, locally. Includes structured workflows, validation checks, and reusable patterns for automation.