Component Recipes
Complete working examples combining shadcn/ui components into common UI patterns.
Contact Form
Components: Form + Input + Textarea + Button + Toast
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from '@/components/ui/form'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
export function ContactForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', message: '' },
})
async function onSubmit(values: z.infer<typeof schema>) {
try {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
toast.success('Message sent!')
form.reset()
} catch {
toast.error('Failed to send message')
}
}
return (
<Form {...form}>
<form className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input value={field.value ?? ''} name={field.name} ref={field.ref} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" value={field.value ?? ''} name={field.name} ref={field.ref} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="message" render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl><Textarea value={field.value ?? ''} name={field.name} ref={field.ref} /></FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Sending...' : 'Send'}
</Button>
</form>
</Form>
)
}
Data Table
Components: Table + @tanstack/react-table + Input (search) + Button (pagination)
import { useState } from 'react'
import {
useReactTable, getCoreRowModel, getSortedRowModel,
getFilteredRowModel, getPaginationRowModel,
flexRender, type ColumnDef, type SortingState,
} from '@tanstack/react-table'
import { ArrowUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
interface DataTableProps<T> {
columns: ColumnDef<T>[]
data: T[]
}
export function DataTable<T>({ columns, data }: DataTableProps<T>) {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data, columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<div className="space-y-4">
<Input placeholder="Search..." value={globalFilter} => setGlobalFilter(e.target.value)} className="max-w-sm" />
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id}>
{hg.headers.map(h => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : (
<Button variant="ghost"
{flexRender(h.column.columnDef.header, h.getContext())}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} result(s)
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>
</div>
)
}
Modal CRUD
Components: Dialog + Form + Button (create/edit in a modal)
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface Item { id: string; name: string; email: string }
export function CrudModal({ item, open, onOpenChange, onSave }: {
item?: Item // undefined = create, defined = edit
open: boolean
onOpenChange: (open: boolean) => void
onSave: (data: Omit<Item, 'id'>) => Promise<void>
}) {
const [name, setName] = useState(item?.name ?? '')
const [email, setEmail] = useState(item?.email ?? '')
const [saving, setSaving] = useState(false)
async function handleSave() {
setSaving(true)
try {
await onSave({ name, email })
toast.success(item ? 'Updated!' : 'Created!')
onOpenChange(false)
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
return (
<Dialog open={open}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{item ? 'Edit' : 'Create'} Item</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={email} => setEmail(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" => onOpenChange(false)}>Cancel</Button>
<Button disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Responsive Navigation
Components: NavigationMenu (desktop) + Sheet (mobile) + ModeToggle
import { useState } from 'react'
import { Menu } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { ModeToggle } from '@/components/mode-toggle'
const navLinks = [
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about' },
{ label: 'Contact', href: '/contact' },
]
export function Navigation() {
return (
<header className="border-b">
<div className="container flex h-14 items-center justify-between">
<span className="font-bold">Logo</span>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-6">
{navLinks.map(link => (
<a key={link.href} href={link.href} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
{link.label}
</a>
))}
<ModeToggle />
</nav>
{/* Mobile nav */}
<div className="flex md:hidden items-center gap-2">
<ModeToggle />
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon"><Menu className="h-5 w-5" /></Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader><SheetTitle>Menu</SheetTitle></SheetHeader>
<nav className="flex flex-col gap-4 mt-4">
{navLinks.map(link => (
<a key={link.href} href={link.href} className="text-lg">{link.label}</a>
))}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
)
}
Settings Page
Components: Tabs + Form + Switch + Select + Toast
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
export function SettingsPage() {
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6 mt-4">
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input id="name" defaultValue="Jez" />
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Select defaultValue="australia-sydney">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="australia-sydney">Australia/Sydney</SelectItem>
<SelectItem value="utc">UTC</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<Button => toast.success('Settings saved')}>Save</Button>
</TabsContent>
<TabsContent value="notifications" className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<div>
<Label>Email notifications</Label>
<p className="text-sm text-muted-foreground">Receive email updates</p>
</div>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<div>
<Label>Marketing emails</Label>
<p className="text-sm text-muted-foreground">Receive promotional content</p>
</div>
<Switch />
</div>
</TabsContent>
</Tabs>
</div>
)
}