Files
2025-11-30 08:25:20 +08:00

15 KiB

UI Components: [Project Name]

Framework: React 19 Component Library: shadcn/ui (Radix UI primitives) Styling: Tailwind v4 Forms: React Hook Form + Zod State: TanStack Query (server) + Zustand (client) Last Updated: [Date]


Overview

This document outlines the component hierarchy, reusable components, forms, and UI patterns.

Component Philosophy:

  • Composition over configuration - Build complex UIs from simple parts
  • Accessibility first - All components keyboard navigable, ARIA compliant
  • shadcn/ui ownership - Components copied to codebase, not npm dependency
  • Tailwind utility classes - Minimal custom CSS
  • Dark mode support - All components adapt to theme

Component Hierarchy

App
├── Providers
│   ├── ClerkProvider (auth)
│   ├── ThemeProvider (dark/light mode)
│   └── QueryClientProvider (TanStack Query)
│
├── Layout
│   ├── Header
│   │   ├── Logo
│   │   ├── Navigation
│   │   └── UserMenu
│   ├── Main (page content)
│   └── Footer (optional)
│
└── Pages (routes)
    ├── HomePage
    │   ├── HeroSection
    │   ├── FeaturesSection
    │   └── CTASection
    │
    ├── DashboardPage
    │   ├── Sidebar
    │   │   └── NavLinks
    │   └── DashboardContent
    │       ├── StatsCards
    │       └── [ResourceList]
    │
    ├── [Resource]Page
    │   ├── [Resource]List
    │   │   ├── [Resource]Card (for each item)
    │   │   └── Pagination
    │   └── [Resource]CreateDialog
    │
    └── [Resource]DetailPage
        ├── [Resource]Header
        ├── [Resource]Info
        └── [Resource]Actions

Core Layout Components

App.tsx

Purpose: Root component with providers

Structure:

import { ClerkProvider } from '@clerk/clerk-react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@/components/theme-provider'
import { Router } from '@/router'

const queryClient = new QueryClient()

export function App() {
  return (
    <ClerkProvider publishableKey={...}>
      <QueryClientProvider client={queryClient}>
        <ThemeProvider defaultTheme="system" storageKey="app-theme">
          <Router />
        </ThemeProvider>
      </QueryClientProvider>
    </ClerkProvider>
  )
}

Layout.tsx

Purpose: Common layout wrapper for authenticated pages

Props: None (uses Outlet from react-router)

Structure:

export function Layout() {
  return (
    <div className="min-h-screen bg-background">
      <Header />
      <main className="container mx-auto px-4 py-8">
        <Outlet />
      </main>
      <Footer />
    </div>
  )
}

Files: src/components/layout/Layout.tsx, Header.tsx, Footer.tsx


Header.tsx

Purpose: Top navigation bar

Features:

  • Logo/brand
  • Navigation links
  • User menu (authenticated) or Sign In button (unauthenticated)
  • Theme toggle (dark/light mode)

Structure:

export function Header() {
  return (
    <header className="border-b bg-background">
      <div className="container mx-auto flex h-16 items-center justify-between px-4">
        <div className="flex items-center gap-6">
          <Logo />
          <Navigation />
        </div>
        <div className="flex items-center gap-4">
          <ThemeToggle />
          <UserButton />
        </div>
      </div>
    </header>
  )
}

Files: src/components/layout/Header.tsx


shadcn/ui Components Used

Installed Components

Run these to add components:

pnpm dlx shadcn@latest add button
pnpm dlx shadcn@latest add dialog
pnpm dlx shadcn@latest add dropdown-menu
pnpm dlx shadcn@latest add form
pnpm dlx shadcn@latest add input
pnpm dlx shadcn@latest add label
pnpm dlx shadcn@latest add select
pnpm dlx shadcn@latest add textarea
pnpm dlx shadcn@latest add card
pnpm dlx shadcn@latest add badge
pnpm dlx shadcn@latest add avatar
pnpm dlx shadcn@latest add skeleton
pnpm dlx shadcn@latest add toast

Location: src/components/ui/

Ownership: These are now part of our codebase, modify as needed


Custom Components

[Resource]List.tsx

Purpose: Display list of [resources] with loading/error states

Props:

interface [Resource]ListProps {
  // No props - uses TanStack Query internally
}

Structure:

export function [Resource]List() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['[resources]'],
    queryFn: fetch[Resources]
  })

  if (isLoading) return <[Resource]ListSkeleton />
  if (error) return <ErrorMessage error={error} />

  return (
    <div className="space-y-4">
      {data.map(item => (
        <[Resource]Card key={item.id} item={item} />
      ))}
    </div>
  )
}

Files: src/components/[resource]/[Resource]List.tsx


[Resource]Card.tsx

Purpose: Display single [resource] item

Props:

interface [Resource]CardProps {
  item: [Resource]
  onEdit?: (id: number) => void
  onDelete?: (id: number) => void
}

Structure:

export function [Resource]Card({ item, onEdit, onDelete }: [Resource]CardProps) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>{item.title}</CardTitle>
      </CardHeader>
      <CardContent>
        <p>{item.description}</p>
      </CardContent>
      <CardFooter>
        <Button onClick={() => onEdit?.(item.id)}>Edit</Button>
        <Button variant="destructive" onClick={() => onDelete?.(item.id)}>Delete</Button>
      </CardFooter>
    </Card>
  )
}

Files: src/components/[resource]/[Resource]Card.tsx


[Resource]Form.tsx

Purpose: Create or edit [resource]

Props:

interface [Resource]FormProps {
  item?: [Resource] // undefined for create, populated for edit
  onSuccess?: () => void
}

Structure (using React Hook Form + Zod):

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { [resource]Schema } from '@/lib/schemas'

export function [Resource]Form({ item, onSuccess }: [Resource]FormProps) {
  const form = useForm({
    resolver: zodResolver([resource]Schema),
    defaultValues: item || {
      field1: '',
      field2: ''
    }
  })

  const mutation = useMutation({
    mutationFn: item ? update[Resource] : create[Resource],
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['[resources]'] })
      onSuccess?.()
    }
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(data => mutation.mutate(data))}>
        <FormField name="field1" control={form.control} render={({ field }) => (
          <FormItem>
            <FormLabel>Field 1</FormLabel>
            <FormControl>
              <Input {...field} />
            </FormControl>
            <FormMessage />
          </FormItem>
        )} />

        <Button type="submit" disabled={mutation.isPending}>
          {mutation.isPending ? 'Saving...' : 'Save'}
        </Button>
      </form>
    </Form>
  )
}

Files: src/components/[resource]/[Resource]Form.tsx


[Resource]CreateDialog.tsx

Purpose: Modal dialog for creating new [resource]

Structure:

export function [Resource]CreateDialog() {
  const [open, setOpen] = useState(false)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>Create [Resource]</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Create New [Resource]</DialogTitle>
        </DialogHeader>
        <[Resource]Form onSuccess={() => setOpen(false)} />
      </DialogContent>
    </Dialog>
  )
}

Files: src/components/[resource]/[Resource]CreateDialog.tsx


Loading and Error States

[Resource]ListSkeleton.tsx

Purpose: Loading state for [resource] list

Structure:

export function [Resource]ListSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map(i => (
        <Card key={i}>
          <CardHeader>
            <Skeleton className="h-6 w-48" />
          </CardHeader>
          <CardContent>
            <Skeleton className="h-4 w-full" />
            <Skeleton className="h-4 w-3/4 mt-2" />
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

ErrorMessage.tsx

Purpose: Reusable error display

Props:

interface ErrorMessageProps {
  error: Error
  retry?: () => void
}

Structure:

export function ErrorMessage({ error, retry }: ErrorMessageProps) {
  return (
    <div className="rounded-lg border border-destructive bg-destructive/10 p-4">
      <h3 className="font-semibold text-destructive">Error</h3>
      <p className="text-sm text-muted-foreground">{error.message}</p>
      {retry && (
        <Button variant="outline" onClick={retry} className="mt-2">
          Retry
        </Button>
      )}
    </div>
  )
}

State Management Patterns

Server State (TanStack Query)

Use for: Data from API (users, tasks, analytics, etc)

Example:

// In component
const { data, isLoading, error } = useQuery({
  queryKey: ['tasks'],
  queryFn: () => api.get('/api/tasks').then(res => res.data)
})

// Mutation
const mutation = useMutation({
  mutationFn: (newTask) => api.post('/api/tasks', newTask),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['tasks'] })
  }
})

Files: Queries/mutations defined in src/lib/api.ts or component files


Client State (Zustand)

Use for: UI state, preferences, filters

Example Store:

// src/stores/ui-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface UIStore {
  sidebarOpen: boolean
  setSidebarOpen: (open: boolean) => void
  theme: 'light' | 'dark' | 'system'
  setTheme: (theme: 'light' | 'dark' | 'system') => void
}

export const useUIStore = create<UIStore>()(
  persist(
    (set) => ({
      sidebarOpen: true,
      setSidebarOpen: (open) => set({ sidebarOpen: open }),
      theme: 'system',
      setTheme: (theme) => set({ theme })
    }),
    { name: 'ui-store' }
  )
)

Usage:

const { sidebarOpen, setSidebarOpen } = useUIStore()

Theme System

ThemeProvider

Purpose: Manage dark/light/system theme

Structure:

// src/components/theme-provider.tsx
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
      {...props}
    >
      {children}
    </NextThemesProvider>
  )
}

export function useTheme() {
  return useNextTheme()
}

Files: src/components/theme-provider.tsx


ThemeToggle

Purpose: Toggle between light/dark/system themes

Structure:

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Sun className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Form Patterns

All forms use React Hook Form + Zod for validation.

Schema Definition (shared client + server):

// src/lib/schemas.ts
import { z } from 'zod'

export const taskSchema = z.object({
  title: z.string().min(1, 'Title required').max(100),
  description: z.string().optional(),
  dueDate: z.string().optional(),
  priority: z.enum(['low', 'medium', 'high']).default('medium')
})

export type TaskFormData = z.infer<typeof taskSchema>

Form Component:

const form = useForm<TaskFormData>({
  resolver: zodResolver(taskSchema),
  defaultValues: { ... }
})

Responsive Design

Breakpoints (Tailwind defaults):

  • sm: 640px
  • md: 768px
  • lg: 1024px
  • xl: 1280px
  • 2xl: 1536px

Patterns:

// Stack on mobile, grid on desktop
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">

// Hide on mobile, show on desktop
<div className="hidden md:block">

// Different padding on mobile vs desktop
<div className="px-4 md:px-8">

Accessibility

Requirements:

  • All interactive elements keyboard navigable
  • Focus states visible (use ring classes)
  • Images have alt text
  • Forms have associated labels
  • Color contrast meets WCAG AA
  • Screen reader friendly (ARIA labels)

shadcn/ui benefits: All components built with Radix UI (accessible by default)


File Structure

src/
├── components/
│   ├── ui/                    # shadcn/ui components
│   │   ├── button.tsx
│   │   ├── dialog.tsx
│   │   ├── form.tsx
│   │   └── ...
│   ├── layout/                # Layout components
│   │   ├── Header.tsx
│   │   ├── Footer.tsx
│   │   └── Layout.tsx
│   ├── [resource]/            # Feature-specific components
│   │   ├── [Resource]List.tsx
│   │   ├── [Resource]Card.tsx
│   │   ├── [Resource]Form.tsx
│   │   └── [Resource]CreateDialog.tsx
│   ├── theme-provider.tsx
│   └── error-message.tsx
├── lib/
│   ├── schemas.ts             # Zod schemas
│   ├── api.ts                 # API client
│   └── utils.ts               # cn() helper
├── stores/
│   └── ui-store.ts            # Zustand stores
└── pages/
    ├── HomePage.tsx
    ├── DashboardPage.tsx
    └── ...

Design Tokens

See src/index.css for theme configuration.

Colors (semantic):

  • background / foreground
  • primary / primary-foreground
  • secondary / secondary-foreground
  • destructive / destructive-foreground
  • muted / muted-foreground
  • accent / accent-foreground
  • border
  • ring

Usage: These adapt to light/dark mode automatically.


Future Components

Components to build:

  • [Component] - [Description]
  • [Component] - [Description]

Revision History

v1.0 ([Date]): Initial component structure v1.1 ([Date]): [Changes made]