R

React Component Scaffolder

Scaffolds a complete React component with TypeScript types, Tailwind styles, Storybook stories, and unit tests. Follows project conventions automatically.

CommandClipticsfrontendv1.0.0MIT
0 views0 copies

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, not div with onClick)
  • ARIA labels for interactive elements without visible text
  • role attributes where semantic HTML isn't sufficient
  • aria-invalid and aria-describedby on form fields with errors
  • Keyboard navigation support (focus management, tab order)
  • Color contrast ratios meeting WCAG AA (4.5:1 for text)
  • alt text on images (empty alt="" for decorative images)
  • Focus visible styles (never outline: none without replacement)
  • Screen reader announcements for dynamic content (aria-live)

Best Practices

  1. Props over state — Prefer controlled components; lift state up when possible
  2. Composition over inheritance — Use children and render props, not class inheritance
  3. Single responsibility — Each component does one thing well
  4. Explicit props — Avoid spreading ...rest on DOM elements (security risk)
  5. Memoize expensive renders — memo() for pure components, useMemo/useCallback for values
  6. Collocate related files — Keep component, tests, styles, and types together
  7. Export from index — Only export what consumers need from the barrel file
  8. Document props — Use JSDoc comments on interface properties
  9. Handle all states — Loading, error, empty, success for every data-driven component
  10. Test user behavior — Test what the user sees and does, not implementation details
Community

Reviews

Write a review

No reviews yet. Be the first to review this template!

Similar Templates