
Component Library
- 59 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
Component-library is a Claude skill that generates 30+ production-ready React components using shadcn/ui, CVA, Radix UI, and Tailwind CSS.
About
Component-library is a Claude skill that generates production-ready React components using shadcn/ui architecture, CVA variants, Radix UI primitives, and Tailwind CSS. It covers 30+ components across form, display, feedback, navigation, and layout categories with accessibility and dark-mode support. A frontend developer uses it to scaffold a consistent component system instead of hand-coding each component.
- Generates 30+ React components on shadcn/ui architecture
- CVA variants, Radix UI primitives, Tailwind CSS, dark mode, accessibility
- Covers form, display, feedback, navigation, and layout categories
Component Library by the numbers
- 59 all-time installs (skills.sh)
- Ranked #1,218 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
component-library capabilities & compatibility
- Capabilities
- component generation · ui scaffolding · design system
- Use cases
- frontend · ui design
- IDEs
- vscode · cursor ide
- Pricing
- Free
What component-library says it does
Comprehensive React component library with 30+ production-ready components using shadcn/ui architecture, CVA variants, Radix UI primitives, and Tailwind CSS.
Generate production-ready React components with shadcn/ui patterns, saving 8-10 hours per project.
Use CVA for all variants:
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill component-libraryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 59 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Generate accessible React UI components with shadcn/ui, CVA, Radix, and Tailwind.
Who is it for?
Scaffolding a consistent, accessible React component system.
Skip if: Non-React stacks or backend work.
When should I use this skill?
You need to create React UI components or build a complete component system with consistent design.
What you get
A consistent shadcn/ui component system generated in minutes.
- React UI components
- lib/utils.ts cn() helper
- components.json registry
By the numbers
- 30+ production-ready components
- saves 8-10 hours per project
- 20-45 minutes saved per component
Files
Component Library - shadcn/ui Architecture
Generate production-ready React components with shadcn/ui patterns, saving 8-10 hours per project.
Quick Start
When generating components: 1. Create /components/ui/ directory structure 2. Generate lib/utils.ts with cn() helper first 3. Create requested components with full TypeScript, variants, and accessibility 4. Include example usage for each component
Core Setup Files
Always generate these first:
lib/utils.ts - Essential cn() helper:
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}components.json - Component registry:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}Component Categories
Form Components
- Input - Text input with variants (default, ghost, underline)
- Select - Custom dropdown with search, multi-select options
- Checkbox - With indeterminate state support
- Radio - Radio groups with custom styling
- Switch - Toggle switches with labels
- Textarea - Auto-resize, character count variants
- DatePicker - Calendar integration, range selection
- FileUpload - Drag & drop, preview, progress
- Slider - Range input with marks, tooltips
- Form - React Hook Form wrapper with validation
Display Components
- Card - Container with header/footer slots
- Table - Sortable, filterable, pagination
- Badge - Status indicators with variants
- Avatar - Image/initials with fallback
- Progress - Linear and circular variants
- Skeleton - Loading states
- Separator - Visual dividers
- ScrollArea - Custom scrollbars
Feedback Components
- Alert - Info/warning/error/success states
- Toast - Notifications with actions
- Dialog/Modal - Accessible overlays
- Tooltip - Hover information
- Popover - Positioned content
- AlertDialog - Confirmation dialogs
Navigation Components
- Navigation - Responsive nav with mobile menu
- Tabs - Tab panels with keyboard nav
- Breadcrumb - Path navigation
- Pagination - Page controls
- CommandMenu - Command palette (⌘K)
- ContextMenu - Right-click menus
- DropdownMenu - Action menus
Layout Components
- Accordion - Collapsible sections
- Collapsible - Show/hide content
- ResizablePanels - Draggable split panes
- Sheet - Slide-out panels
- AspectRatio - Maintain ratios
Component Implementation Patterns
Use CVA for all variants:
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)Accessibility Requirements:
- ARIA labels and roles on all interactive elements
- Keyboard navigation (Tab, Arrow keys, Enter, Escape)
- Focus management and trapping for modals
- Screen reader announcements
- Semantic HTML elements
Dark Mode Support:
- Use Tailwind dark: modifier
- CSS variables for theme colors
- Smooth transitions between modes
Responsive Design:
- Mobile-first approach
- Container queries where appropriate
- Touch-friendly tap targets (min 44x44px)
- Responsive typography scale
Dependencies
Include in package.json:
{
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.263.1",
"react-day-picker": "^8.8.0",
"react-hook-form": "^7.45.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7"
}
}Implementation Workflow
1. Assess Requirements: Identify which components are needed 2. Generate Base Files: Create utils.ts and components.json 3. Create Components: Generate requested components with all features 4. Provide Examples: Include usage examples for each component 5. Document Props: Add TypeScript interfaces with JSDoc comments
Advanced Patterns
For complex requirements, see:
- references/form-patterns.md - Advanced form handling
- references/data-tables.md - Complex table implementations
- references/animation-patterns.md - Framer Motion integration
- references/testing-setup.md - Component testing patterns
Performance Optimization
- Use React.memo for expensive components
- Implement virtual scrolling for long lists
- Lazy load heavy components
- Optimize bundle size with tree shaking
- Use CSS containment for layout stability
Component Generation Tips
When generating components:
- Include all variant combinations
- Add proper TypeScript types
- Implement keyboard shortcuts
- Include loading and error states
- Provide Storybook stories structure
- Add comprehensive prop documentation
- Include accessibility attributes
- Test with screen readers
{
"name": "component-library",
"description": "Generate production-ready React components with shadcn/ui patterns, saving 8-10 hours per project. When generating components:",
"tags": [
"web",
"react",
"typescript",
"scaffolding"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"form-react",
"web-artifacts-builder",
"d3js-visualization"
],
"last_reviewed_at": "2026-05-30",
"review_score": 72,
"relevance_tier": "A"
}
Animation Patterns
Framer Motion integration patterns for creating smooth, performant animations in React components.
Basic Animation Patterns
Fade In Animation
import { motion } from "framer-motion"
export function FadeIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
delay,
ease: [0.25, 0.1, 0.25, 1],
}}
>
{children}
</motion.div>
)
}Stagger Children Animation
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
}
export function StaggerList({ items }: { items: string[] }) {
return (
<motion.ul
variants={container}
initial="hidden"
animate="show"
className="space-y-2"
>
{items.map((text, i) => (
<motion.li
key={i}
variants={item}
className="p-4 bg-secondary rounded-lg"
>
{text}
</motion.li>
))}
</motion.ul>
)
}Page Transitions
Route Transition Wrapper
import { motion, AnimatePresence } from "framer-motion"
import { useLocation } from "react-router-dom"
const pageVariants = {
initial: {
opacity: 0,
x: "-100vw",
},
in: {
opacity: 1,
x: 0,
},
out: {
opacity: 0,
x: "100vw",
},
}
const pageTransition = {
type: "tween",
ease: "anticipate",
duration: 0.5,
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation()
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial="initial"
animate="in"
exit="out"
variants={pageVariants}
transition={pageTransition}
>
{children}
</motion.div>
</AnimatePresence>
)
}Gesture Animations
Draggable Card
export function DraggableCard() {
return (
<motion.div
drag
dragConstraints={{
top: -50,
left: -50,
right: 50,
bottom: 50,
}}
dragElastic={0.2}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
whileDrag={{ scale: 1.1 }}
className="w-64 h-40 bg-gradient-to-br from-primary to-secondary rounded-lg shadow-lg cursor-move flex items-center justify-center text-white font-semibold"
>
Drag me around!
</motion.div>
)
}Hover Effects
export function HoverCard() {
return (
<motion.div
className="relative p-6 bg-card rounded-lg shadow-md cursor-pointer"
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300 }}
>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-primary to-secondary rounded-lg"
initial={{ opacity: 0 }}
whileHover={{ opacity: 0.1 }}
transition={{ duration: 0.3 }}
/>
<h3 className="text-lg font-semibold">Interactive Card</h3>
<p className="text-muted-foreground">Hover to see the effect</p>
</motion.div>
)
}Scroll-Based Animations
Scroll Reveal
import { useInView } from "framer-motion"
import { useRef } from "react"
export function ScrollReveal({ children }: { children: React.ReactNode }) {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.3 })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 50 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 50 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
{children}
</motion.div>
)
}Parallax Scrolling
import { useScroll, useTransform } from "framer-motion"
export function ParallaxSection() {
const { scrollYProgress } = useScroll()
const y = useTransform(scrollYProgress, [0, 1], ["0%", "50%"])
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 0.5, 0])
return (
<div className="relative h-screen overflow-hidden">
<motion.div
className="absolute inset-0 bg-gradient-to-b from-primary to-secondary"
style={{ y, opacity }}
/>
<div className="relative z-10 flex items-center justify-center h-full">
<h1 className="text-6xl font-bold text-white">Parallax Effect</h1>
</div>
</div>
)
}Complex Animations
Morphing SVG
export function MorphingSVG() {
const [isOpen, setIsOpen] = useState(false)
const pathVariants = {
closed: {
d: "M 2 2.5 L 20 2.5",
},
open: {
d: "M 3 16.5 L 17 2.5",
},
}
return (
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 rounded-lg hover:bg-accent"
>
<svg width="24" height="24">
<motion.path
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
animate={isOpen ? "open" : "closed"}
variants={pathVariants}
/>
<motion.path
d="M 2 9.423 L 20 9.423"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
animate={{
opacity: isOpen ? 0 : 1,
}}
/>
<motion.path
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
animate={isOpen ? {
d: "M 3 2.5 L 17 16.5",
} : {
d: "M 2 16.346 L 20 16.346",
}}
/>
</svg>
</button>
)
}Animated Counter
import { animate, useMotionValue, useTransform } from "framer-motion"
import { useEffect } from "react"
export function AnimatedCounter({ value }: { value: number }) {
const count = useMotionValue(0)
const rounded = useTransform(count, Math.round)
useEffect(() => {
const animation = animate(count, value, {
duration: 2,
ease: "easeOut",
})
return animation.stop
}, [value])
return (
<motion.span className="text-4xl font-bold tabular-nums">
{rounded}
</motion.span>
)
}Notification Stack
export function NotificationStack() {
const [notifications, setNotifications] = useState<string[]>([])
const addNotification = (message: string) => {
setNotifications((prev) => [...prev, message])
setTimeout(() => {
setNotifications((prev) => prev.slice(1))
}, 3000)
}
return (
<div className="fixed bottom-4 right-4 space-y-2">
<AnimatePresence>
{notifications.map((message, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: 100, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 40 }}
className="bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg"
>
{message}
</motion.div>
))}
</AnimatePresence>
</div>
)
}Loading Animations
Skeleton Pulse
export function SkeletonPulse() {
return (
<motion.div
className="h-4 bg-muted rounded"
animate={{
opacity: [0.5, 1, 0.5],
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
)
}Spinner
export function Spinner() {
return (
<motion.div
className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: "linear",
}}
/>
)
}Progress Bar
export function ProgressBar({ progress }: { progress: number }) {
return (
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary"
initial={{ width: "0%" }}
animate={{ width: `${progress}%` }}
transition={{
duration: 0.5,
ease: "easeOut",
}}
/>
</div>
)
}Micro-interactions
Button Press Effect
export function PressButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }}
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium"
>
{children}
</motion.button>
)
}Toggle Switch Animation
export function AnimatedSwitch({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
return (
<button
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted"
)}
>
<motion.span
className="inline-block h-4 w-4 transform rounded-full bg-white shadow-lg"
animate={{
x: checked ? 24 : 2,
}}
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
/>
</button>
)
}Confetti Burst
export function ConfettiBurst() {
const [particles, setParticles] = useState<Array<{ id: number; x: number; y: number }>>([])
const burst = () => {
const newParticles = Array.from({ length: 20 }, (_, i) => ({
id: Date.now() + i,
x: Math.random() * 200 - 100,
y: Math.random() * -200 - 50,
}))
setParticles(newParticles)
setTimeout(() => setParticles([]), 1000)
}
return (
<div className="relative">
<button onClick={burst} className="px-4 py-2 bg-primary text-primary-foreground rounded">
Click for confetti!
</button>
<AnimatePresence>
{particles.map((particle) => (
<motion.div
key={particle.id}
className="absolute w-2 h-2 bg-gradient-to-r from-pink-500 to-yellow-500 rounded-full"
initial={{ x: 0, y: 0, opacity: 1 }}
animate={{
x: particle.x,
y: particle.y,
opacity: 0,
}}
exit={{ opacity: 0 }}
transition={{
duration: 1,
ease: "easeOut",
}}
style={{ left: "50%", top: "50%" }}
/>
))}
</AnimatePresence>
</div>
)
}Layout Animations
Shared Layout
export function TabsWithIndicator() {
const [activeTab, setActiveTab] = useState(0)
const tabs = ["Home", "About", "Contact"]
return (
<div className="flex gap-2 relative">
{tabs.map((tab, index) => (
<button
key={tab}
onClick={() => setActiveTab(index)}
className="relative px-4 py-2 text-sm font-medium transition-colors"
>
{activeTab === index && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 bg-primary rounded-lg"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<span className={cn(
"relative z-10",
activeTab === index ? "text-primary-foreground" : "text-muted-foreground"
)}>
{tab}
</span>
</button>
))}
</div>
)
}Data Table Patterns
Advanced patterns for building feature-rich data tables with sorting, filtering, pagination, and row selection.
Complete Data Table Implementation
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react"
import { useState } from "react"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
return (
<div className="w-full">
{/* Toolbar */}
<div className="flex items-center py-4 gap-2">
<Input
placeholder="Filter emails..."
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("email")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}Column Definitions
Basic Columns
export const columns: ColumnDef<User>[] = [
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
<Badge variant={status === "active" ? "default" : "secondary"}>
{status}
</Badge>
)
},
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"))
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount)
return <div className="text-right font-medium">{formatted}</div>
},
},
]Sortable Columns
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <div className="lowercase">{row.getValue("name")}</div>,
}Selectable Rows
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
}Action Columns
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const payment = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(payment.id)}
>
Copy payment ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View customer</DropdownMenuItem>
<DropdownMenuItem>View payment details</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
}Advanced Filtering
Global Filter
export function GlobalFilter({
globalFilter,
setGlobalFilter,
}: {
globalFilter: string
setGlobalFilter: (value: string) => void
}) {
const [value, setValue] = useState(globalFilter)
const onChange = useDebounce((value: string) => {
setGlobalFilter(value || undefined)
}, 200)
return (
<Input
value={value || ""}
onChange={(e) => {
setValue(e.target.value)
onChange(e.target.value)
}}
placeholder="Search all columns..."
className="max-w-sm"
/>
)
}Faceted Filter
interface FacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function FacetedFilter<TData, TValue>({
column,
title,
options,
}: FacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="rounded-sm px-1">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<Check className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
</Command>
</Command>
</PopoverContent>
</Popover>
)
}Virtual Scrolling for Large Data Sets
import { useVirtualizer } from "@tanstack/react-virtual"
export function VirtualTable({ data }: { data: any[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 10,
})
return (
<div
ref={parentRef}
className="h-[400px] overflow-auto"
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<TableRow data={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}Server-Side Pagination
interface ServerTableProps {
endpoint: string
columns: ColumnDef<any>[]
}
export function ServerTable({ endpoint, columns }: ServerTableProps) {
const [data, setData] = useState([])
const [loading, setLoading] = useState(false)
const [pageCount, setPageCount] = useState(0)
const [{ pageIndex, pageSize }, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
})
const fetchData = useCallback(async () => {
setLoading(true)
const response = await fetch(
`${endpoint}?page=${pageIndex}&size=${pageSize}`
)
const json = await response.json()
setData(json.data)
setPageCount(json.pageCount)
setLoading(false)
}, [pageIndex, pageSize])
useEffect(() => {
fetchData()
}, [fetchData])
const table = useReactTable({
data,
columns,
pageCount,
state: {
pagination: {
pageIndex,
pageSize,
},
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
if (loading) {
return <Skeleton className="w-full h-[400px]" />
}
return <DataTable table={table} />
}Expandable Rows
export function ExpandableTable() {
const columns: ColumnDef<Order>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.getCanExpand() ? (
<button
{...{
onClick: row.getToggleExpandedHandler(),
style: { cursor: "pointer" },
}}
>
{row.getIsExpanded() ? <ChevronDown /> : <ChevronRight />}
</button>
) : null
},
},
// ... other columns
]
return (
<Table>
<TableBody>
{table.getRowModel().rows.map((row) => (
<Fragment key={row.id}>
<TableRow>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={row.getVisibleCells().length}>
<OrderDetails order={row.original} />
</TableCell>
</TableRow>
)}
</Fragment>
))}
</TableBody>
</Table>
)
}Export to CSV
export function ExportButton({ table }: { table: Table<any> }) {
const exportToCSV = () => {
const rows = table.getFilteredRowModel().rows
const headers = table.getVisibleFlatColumns()
.map(column => column.columnDef.header)
.join(",")
const csvContent = [
headers,
...rows.map(row =>
row.getVisibleCells()
.map(cell => cell.getValue())
.join(",")
)
].join("\n")
const blob = new Blob([csvContent], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = "table-data.csv"
link.click()
}
return (
<Button onClick={exportToCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
)
}Advanced Form Patterns
Comprehensive patterns for complex form implementations with React Hook Form.
Form Validation Patterns
Zod Schema Integration
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
const formSchema = z.object({
username: z.string().min(2, "Username must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.number().min(18).max(100),
website: z.string().url().optional(),
bio: z.string().max(160).optional(),
notifications: z.object({
email: z.boolean(),
push: z.boolean(),
sms: z.boolean(),
}),
role: z.enum(["admin", "user", "guest"]),
startDate: z.date(),
skills: z.array(z.string()).min(1, "Select at least one skill"),
})
type FormData = z.infer<typeof formSchema>
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
notifications: {
email: true,
push: false,
sms: false,
},
skills: [],
},
})Custom Validation Rules
const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character")
const confirmPasswordSchema = z.object({
password: passwordSchema,
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})Multi-Step Form Pattern
import { useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
interface MultiStepFormData {
// Step 1: Personal Info
firstName: string
lastName: string
email: string
// Step 2: Address
street: string
city: string
state: string
zipCode: string
// Step 3: Preferences
newsletter: boolean
notifications: boolean
theme: "light" | "dark" | "system"
}
export function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(0)
const methods = useForm<MultiStepFormData>({
mode: "onChange",
defaultValues: {
newsletter: false,
notifications: true,
theme: "system",
},
})
const steps = [
{
id: "personal",
title: "Personal Information",
fields: ["firstName", "lastName", "email"],
},
{
id: "address",
title: "Address",
fields: ["street", "city", "state", "zipCode"],
},
{
id: "preferences",
title: "Preferences",
fields: ["newsletter", "notifications", "theme"],
},
]
const next = async () => {
const fields = steps[currentStep].fields
const output = await methods.trigger(fields as any)
if (!output) return
if (currentStep < steps.length - 1) {
setCurrentStep(step => step + 1)
}
}
const previous = () => {
if (currentStep > 0) {
setCurrentStep(step => step - 1)
}
}
const onSubmit = (data: MultiStepFormData) => {
console.log("Form submitted:", data)
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<div className="space-y-4">
{/* Progress indicator */}
<div className="flex justify-between mb-8">
{steps.map((step, index) => (
<div
key={step.id}
className={cn(
"flex-1 text-center pb-2 border-b-2 transition-colors",
index <= currentStep
? "border-primary text-primary"
: "border-muted text-muted-foreground"
)}
>
{step.title}
</div>
))}
</div>
{/* Step content */}
{currentStep === 0 && <PersonalInfoStep />}
{currentStep === 1 && <AddressStep />}
{currentStep === 2 && <PreferencesStep />}
{/* Navigation */}
<div className="flex justify-between mt-8">
<Button
type="button"
variant="outline"
onClick={previous}
disabled={currentStep === 0}
>
Previous
</Button>
{currentStep === steps.length - 1 ? (
<Button type="submit">Submit</Button>
) : (
<Button type="button" onClick={next}>
Next
</Button>
)}
</div>
</div>
</form>
</FormProvider>
)
}Dynamic Field Arrays
import { useFieldArray, useForm } from "react-hook-form"
import { Plus, Trash2 } from "lucide-react"
interface FormData {
items: Array<{
name: string
quantity: number
price: number
}>
}
export function DynamicFieldArray() {
const { control, register, handleSubmit, watch } = useForm<FormData>({
defaultValues: {
items: [{ name: "", quantity: 1, price: 0 }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: "items",
})
const watchItems = watch("items")
const total = watchItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.price || 0),
0
)
return (
<form onSubmit={handleSubmit(console.log)} className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-end">
<div className="flex-1">
<Label htmlFor={`items.${index}.name`}>Item Name</Label>
<Input
{...register(`items.${index}.name` as const, {
required: "Item name is required",
})}
placeholder="Enter item name"
/>
</div>
<div className="w-24">
<Label htmlFor={`items.${index}.quantity`}>Qty</Label>
<Input
type="number"
{...register(`items.${index}.quantity` as const, {
valueAsNumber: true,
min: { value: 1, message: "Min quantity is 1" },
})}
/>
</div>
<div className="w-32">
<Label htmlFor={`items.${index}.price`}>Price</Label>
<Input
type="number"
step="0.01"
{...register(`items.${index}.price` as const, {
valueAsNumber: true,
min: { value: 0, message: "Price must be positive" },
})}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex justify-between items-center pt-4 border-t">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ name: "", quantity: 1, price: 0 })}
>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
<div className="text-lg font-semibold">
Total: ${total.toFixed(2)}
</div>
</div>
<Button type="submit" className="w-full">
Submit Order
</Button>
</form>
)
}Conditional Fields Pattern
export function ConditionalForm() {
const { register, watch, control } = useForm({
defaultValues: {
accountType: "personal",
firstName: "",
lastName: "",
companyName: "",
taxId: "",
hasShippingAddress: false,
billingAddress: "",
shippingAddress: "",
},
})
const accountType = watch("accountType")
const hasShippingAddress = watch("hasShippingAddress")
return (
<form className="space-y-4">
<div>
<Label>Account Type</Label>
<RadioGroup defaultValue="personal" {...register("accountType")}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="personal" id="personal" />
<Label htmlFor="personal">Personal</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="business" id="business" />
<Label htmlFor="business">Business</Label>
</div>
</RadioGroup>
</div>
{accountType === "personal" ? (
<>
<Input {...register("firstName")} placeholder="First Name" />
<Input {...register("lastName")} placeholder="Last Name" />
</>
) : (
<>
<Input {...register("companyName")} placeholder="Company Name" />
<Input {...register("taxId")} placeholder="Tax ID" />
</>
)}
<div>
<Label htmlFor="billing">Billing Address</Label>
<Textarea {...register("billingAddress")} id="billing" />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="shipping"
{...register("hasShippingAddress")}
/>
<Label htmlFor="shipping">
Ship to a different address
</Label>
</div>
{hasShippingAddress && (
<div>
<Label htmlFor="shipping-address">Shipping Address</Label>
<Textarea {...register("shippingAddress")} id="shipping-address" />
</div>
)}
</form>
)
}Auto-Save Pattern
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { debounce } from "lodash"
export function AutoSaveForm() {
const { register, watch, formState } = useForm({
defaultValues: async () => {
// Load from localStorage or API
const saved = localStorage.getItem("draft")
return saved ? JSON.parse(saved) : {}
},
})
const formData = watch()
useEffect(() => {
const saveDraft = debounce((data) => {
localStorage.setItem("draft", JSON.stringify(data))
console.log("Draft saved")
}, 1000)
const subscription = watch((data) => {
if (formState.isDirty) {
saveDraft(data)
}
})
return () => {
subscription.unsubscribe()
saveDraft.cancel()
}
}, [watch, formState.isDirty])
return (
<form className="space-y-4">
<div className="text-sm text-muted-foreground">
{formState.isDirty ? "Saving..." : "All changes saved"}
</div>
<Input {...register("title")} placeholder="Title" />
<Textarea {...register("content")} placeholder="Content" rows={10} />
</form>
)
}File Upload with Preview
import { useState } from "react"
import { useForm } from "react-hook-form"
import { Upload, X } from "lucide-react"
interface FormData {
files: FileList
description: string
}
export function FileUploadForm() {
const [previews, setPreviews] = useState<string[]>([])
const { register, handleSubmit, setValue, watch } = useForm<FormData>()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
const newPreviews: string[] = []
Array.from(files).forEach((file) => {
if (file.type.startsWith("image/")) {
const reader = new FileReader()
reader.onloadend = () => {
newPreviews.push(reader.result as string)
setPreviews([...newPreviews])
}
reader.readAsDataURL(file)
}
})
}
const removeFile = (index: number) => {
const newPreviews = [...previews]
newPreviews.splice(index, 1)
setPreviews(newPreviews)
}
return (
<form onSubmit={handleSubmit(console.log)} className="space-y-4">
<div>
<Label htmlFor="files">Upload Files</Label>
<div className="mt-2">
<label
htmlFor="files"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent"
>
<Upload className="h-8 w-8 text-muted-foreground" />
<span className="mt-2 text-sm text-muted-foreground">
Click to upload or drag and drop
</span>
<input
id="files"
type="file"
multiple
accept="image/*"
className="hidden"
{...register("files")}
onChange={handleFileChange}
/>
</label>
</div>
</div>
{previews.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{previews.map((preview, index) => (
<div key={index} className="relative group">
<img
src={preview}
alt={`Preview ${index + 1}`}
className="w-full h-24 object-cover rounded"
/>
<button
type="button"
onClick={() => removeFile(index)}
className="absolute top-1 right-1 p-1 bg-destructive text-destructive-foreground rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
<Textarea
{...register("description")}
placeholder="Add a description..."
rows={3}
/>
<Button type="submit">Upload Files</Button>
</form>
)
}Component Testing Patterns
Comprehensive testing patterns for React components using Jest, React Testing Library, and Storybook.
Testing Setup
Jest Configuration
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/test/**',
],
testMatch: [
'**/__tests__/**/*.{ts,tsx}',
'**/?(*.)+(spec|test).{ts,tsx}',
],
}Test Setup File
// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
takeRecords() {
return []
}
}
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})Basic Component Testing
Button Component Test
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '@/components/ui/button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
it('handles click events', async () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
const button = screen.getByRole('button')
await userEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies variant styles', () => {
const { rerender } = render(<Button variant="destructive">Delete</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-destructive')
rerender(<Button variant="outline">Cancel</Button>)
expect(button).toHaveClass('border')
})
it('can be disabled', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveClass('disabled:opacity-50')
})
it('renders as child component when asChild is true', () => {
render(
<Button asChild>
<a href="/link">Link Button</a>
</Button>
)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/link')
expect(link).toHaveTextContent('Link Button')
})
})Form Component Test
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
import { Form, FormField, FormItem, FormLabel, FormControl } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
function TestForm({ onSubmit }: { onSubmit: (data: any) => void }) {
const form = useForm({
defaultValues: {
email: '',
password: '',
},
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
rules={{ required: 'Email is required' }}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
</FormItem>
)}
/>
<button type="submit">Submit</button>
</form>
</Form>
)
}
describe('Form', () => {
it('submits form with valid data', async () => {
const handleSubmit = jest.fn()
const user = userEvent.setup()
render(<TestForm onSubmit={handleSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
const submitButton = screen.getByRole('button', { name: /submit/i })
await user.type(emailInput, 'test@example.com')
await user.click(submitButton)
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: '',
})
})
})
it('shows validation errors', async () => {
const handleSubmit = jest.fn()
const user = userEvent.setup()
render(<TestForm onSubmit={handleSubmit} />)
const submitButton = screen.getByRole('button', { name: /submit/i })
await user.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
})
expect(handleSubmit).not.toHaveBeenCalled()
})
})Testing Async Components
Data Fetching Component
import { render, screen, waitFor } from '@testing-library/react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { DataTable } from '@/components/ui/data-table'
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
])
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('DataTable with async data', () => {
it('loads and displays data', async () => {
render(<DataTable endpoint="/api/users" />)
// Initially shows loading state
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('jane@example.com')).toBeInTheDocument()
})
// Loading state should be gone
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument()
})
it('handles error state', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500))
})
)
render(<DataTable endpoint="/api/users" />)
await waitFor(() => {
expect(screen.getByText(/error loading data/i)).toBeInTheDocument()
})
})
})Testing Accessibility
ARIA and Keyboard Navigation
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe, toHaveNoViolations } from 'jest-axe'
import { Dialog } from '@/components/ui/dialog'
expect.extend(toHaveNoViolations)
describe('Dialog Accessibility', () => {
it('has no accessibility violations', async () => {
const { container } = render(
<Dialog open>
<DialogContent>
<DialogHeader>
<DialogTitle>Accessible Dialog</DialogTitle>
</DialogHeader>
<DialogDescription>
This dialog should be accessible
</DialogDescription>
</DialogContent>
</Dialog>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('traps focus within dialog', async () => {
const user = userEvent.setup()
render(
<Dialog open>
<DialogContent>
<button>First button</button>
<button>Second button</button>
<button>Third button</button>
</DialogContent>
</Dialog>
)
const buttons = screen.getAllByRole('button')
// Focus should start on first focusable element
expect(buttons[0]).toHaveFocus()
// Tab through elements
await user.tab()
expect(buttons[1]).toHaveFocus()
await user.tab()
expect(buttons[2]).toHaveFocus()
// Should cycle back to first element
await user.tab()
expect(buttons[0]).toHaveFocus()
// Shift+Tab should go backwards
await user.tab({ shift: true })
expect(buttons[2]).toHaveFocus()
})
it('closes on Escape key', async () => {
const handleClose = jest.fn()
const user = userEvent.setup()
render(
<Dialog open onOpenChange={handleClose}>
<DialogContent>Dialog content</DialogContent>
</Dialog>
)
await user.keyboard('{Escape}')
expect(handleClose).toHaveBeenCalledWith(false)
})
})Testing Custom Hooks
useLocalStorage Hook Test
import { renderHook, act } from '@testing-library/react'
import { useLocalStorage } from '@/hooks/use-local-storage'
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear()
jest.clearAllMocks()
})
it('initializes with default value', () => {
const { result } = renderHook(() =>
useLocalStorage('test-key', 'default-value')
)
expect(result.current[0]).toBe('default-value')
})
it('reads existing value from localStorage', () => {
localStorage.setItem('existing-key', JSON.stringify('existing-value'))
const { result } = renderHook(() =>
useLocalStorage('existing-key', 'default')
)
expect(result.current[0]).toBe('existing-value')
})
it('updates localStorage when value changes', () => {
const { result } = renderHook(() =>
useLocalStorage('update-key', 'initial')
)
act(() => {
result.current[1]('updated')
})
expect(result.current[0]).toBe('updated')
expect(localStorage.getItem('update-key')).toBe('"updated"')
})
it('handles complex objects', () => {
const { result } = renderHook(() =>
useLocalStorage('object-key', { name: 'test', count: 0 })
)
act(() => {
result.current[1]({ name: 'updated', count: 5 })
})
expect(result.current[0]).toEqual({ name: 'updated', count: 5 })
})
})Testing with Context
Theme Context Test
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider, useTheme } from '@/contexts/theme-context'
function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}
describe('ThemeProvider', () => {
it('provides theme context to children', () => {
render(
<ThemeProvider defaultTheme="light">
<ThemeToggle />
</ThemeProvider>
)
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument()
})
it('toggles theme', async () => {
const user = userEvent.setup()
render(
<ThemeProvider defaultTheme="light">
<ThemeToggle />
</ThemeProvider>
)
const button = screen.getByRole('button')
expect(button).toHaveTextContent('Current theme: light')
await user.click(button)
expect(button).toHaveTextContent('Current theme: dark')
await user.click(button)
expect(button).toHaveTextContent('Current theme: light')
})
it('persists theme to localStorage', async () => {
const user = userEvent.setup()
render(
<ThemeProvider defaultTheme="light">
<ThemeToggle />
</ThemeProvider>
)
await user.click(screen.getByRole('button'))
expect(localStorage.getItem('theme')).toBe('dark')
})
})Storybook Stories
Button Stories
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from '@/components/ui/button'
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'],
},
size: {
control: 'select',
options: ['default', 'sm', 'lg', 'icon'],
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
children: 'Button',
},
}
export const AllVariants: Story = {
render: () => (
<div className="flex gap-2 flex-wrap">
<Button variant="default">Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
),
}
export const AllSizes: Story = {
render: () => (
<div className="flex gap-2 items-center">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">🎯</Button>
</div>
),
}
export const Loading: Story = {
args: {
children: (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
),
disabled: true,
},
}
export const AsChild: Story = {
render: () => (
<Button asChild>
<a href="https://example.com" target="_blank">
External Link
</a>
</Button>
),
}Form Stories with Controls
// Form.stories.tsx
export const InteractiveForm: Story = {
render: () => {
const [formData, setFormData] = useState({})
return (
<div className="w-96">
<Form onSubmit={setFormData}>
{/* Form fields */}
</Form>
<pre className="mt-4 p-4 bg-muted rounded">
{JSON.stringify(formData, null, 2)}
</pre>
</div>
)
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const emailInput = canvas.getByLabelText(/email/i)
await userEvent.type(emailInput, 'test@example.com')
await userEvent.click(canvas.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(canvas.getByText(/"test@example.com"/)).toBeInTheDocument()
})
},
}#!/usr/bin/env python3
"""
Component Generator Script
Generates boilerplate code for shadcn/ui style components
"""
import os
import sys
import argparse
from pathlib import Path
def generate_component(name, variant_type="default"):
"""Generate a React component with shadcn/ui patterns"""
# Convert name to proper formats
kebab_case = name.lower().replace(" ", "-")
pascal_case = "".join(word.capitalize() for word in name.split(" "))
# Component template
component_template = f'''import * as React from "react"
import {{ cva, type VariantProps }} from "class-variance-authority"
import {{ cn }} from "@/lib/utils"
const {kebab_case}Variants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{{
variants: {{
variant: {{
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
}},
size: {{
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
}},
}},
defaultVariants: {{
variant: "default",
size: "default",
}},
}}
)
export interface {pascal_case}Props
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof {kebab_case}Variants> {{
asChild?: boolean
}}
const {pascal_case} = React.forwardRef<HTMLDivElement, {pascal_case}Props>(
({{ className, variant, size, asChild = false, ...props }}, ref) => {{
const Comp = asChild ? Slot : "div"
return (
<Comp
className={{cn({kebab_case}Variants({{ variant, size, className }})}}
ref={{ref}}
{{...props}}
/>
)
}}
)
{pascal_case}.displayName = "{pascal_case}"
export {{ {pascal_case}, {kebab_case}Variants }}
'''
# Test template
test_template = f'''import {{ render, screen }} from '@testing-library/react'
import {{ {pascal_case} }} from '@/components/ui/{kebab_case}'
describe('{pascal_case}', () => {{
it('renders correctly', () => {{
render(<{pascal_case}>Test Content</{pascal_case}>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
}})
it('applies variant classes', () => {{
const {{ rerender }} = render(<{pascal_case} variant="outline">Content</{pascal_case}>)
const element = screen.getByText('Content')
expect(element).toHaveClass('border')
rerender(<{pascal_case} variant="ghost">Content</{pascal_case}>)
expect(element).toHaveClass('hover:bg-accent')
}})
it('applies size classes', () => {{
render(<{pascal_case} size="sm">Small</{pascal_case}>)
const element = screen.getByText('Small')
expect(element).toHaveClass('h-9')
}})
it('forwards ref', () => {{
const ref = React.createRef<HTMLDivElement>()
render(<{pascal_case} ref={{ref}}>Ref Test</{pascal_case}>)
expect(ref.current).toBeInstanceOf(HTMLDivElement)
}})
}})
'''
# Story template
story_template = f'''import type {{ Meta, StoryObj }} from '@storybook/react'
import {{ {pascal_case} }} from '@/components/ui/{kebab_case}'
const meta: Meta<typeof {pascal_case}> = {{
title: 'UI/{pascal_case}',
component: {pascal_case},
parameters: {{
layout: 'centered',
}},
tags: ['autodocs'],
argTypes: {{
variant: {{
control: 'select',
options: ['default', 'secondary', 'outline', 'ghost'],
}},
size: {{
control: 'select',
options: ['default', 'sm', 'lg'],
}},
}},
}}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {{
args: {{
children: '{pascal_case} Component',
}},
}}
export const AllVariants: Story = {{
render: () => (
<div className="flex gap-4 flex-wrap">
<{pascal_case} variant="default">Default</{pascal_case}>
<{pascal_case} variant="secondary">Secondary</{pascal_case}>
<{pascal_case} variant="outline">Outline</{pascal_case}>
<{pascal_case} variant="ghost">Ghost</{pascal_case}>
</div>
),
}}
export const AllSizes: Story = {{
render: () => (
<div className="flex gap-4 items-center">
<{pascal_case} size="sm">Small</{pascal_case}>
<{pascal_case} size="default">Default</{pascal_case}>
<{pascal_case} size="lg">Large</{pascal_case}>
</div>
),
}}
'''
return {
'component': component_template,
'test': test_template,
'story': story_template,
'kebab_case': kebab_case,
'pascal_case': pascal_case
}
def main():
parser = argparse.ArgumentParser(description='Generate shadcn/ui style React components')
parser.add_argument('name', help='Component name (e.g., "Button" or "Data Table")')
parser.add_argument('--output-dir', default='./components/ui', help='Output directory for component')
parser.add_argument('--with-tests', action='store_true', help='Generate test file')
parser.add_argument('--with-story', action='store_true', help='Generate Storybook story')
args = parser.parse_args()
# Generate component files
templates = generate_component(args.name)
# Create output directory if it doesn't exist
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Write component file
component_file = output_dir / f"{templates['kebab_case']}.tsx"
with open(component_file, 'w') as f:
f.write(templates['component'])
print(f"✅ Created component: {component_file}")
# Write test file if requested
if args.with_tests:
test_dir = output_dir.parent / '__tests__' / 'components'
test_dir.mkdir(parents=True, exist_ok=True)
test_file = test_dir / f"{templates['kebab_case']}.test.tsx"
with open(test_file, 'w') as f:
f.write(templates['test'])
print(f"✅ Created test: {test_file}")
# Write story file if requested
if args.with_story:
story_file = output_dir / f"{templates['kebab_case']}.stories.tsx"
with open(story_file, 'w') as f:
f.write(templates['story'])
print(f"✅ Created story: {story_file}")
print(f"\n🎉 Successfully generated {templates['pascal_case']} component!")
print("\nNext steps:")
print(f"1. Import component: import {{ {templates['pascal_case']} }} from '@/components/ui/{templates['kebab_case']}'")
print(f"2. Use in your code: <{templates['pascal_case']}>Content</{templates['pascal_case']}>")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Tailwind Configuration Generator for shadcn/ui
Generates the complete Tailwind CSS configuration with shadcn/ui defaults
"""
import json
import sys
from pathlib import Path
def generate_tailwind_config():
"""Generate tailwind.config.js with shadcn/ui defaults"""
config = '''/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}
'''
return config
def generate_global_css():
"""Generate globals.css with shadcn/ui CSS variables"""
css = '''@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
'''
return css
def generate_postcss_config():
"""Generate postcss.config.js"""
config = '''module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
'''
return config
def main():
print("🎨 Generating shadcn/ui Tailwind Configuration")
print("=" * 50)
# Generate tailwind.config.js
print("\n📝 tailwind.config.js:")
print("-" * 30)
print(generate_tailwind_config())
# Generate globals.css
print("\n📝 globals.css:")
print("-" * 30)
print(generate_global_css())
# Generate postcss.config.js
print("\n📝 postcss.config.js:")
print("-" * 30)
print(generate_postcss_config())
print("\n✅ Configuration files generated!")
print("\n📦 Required packages:")
print("npm install -D tailwindcss postcss autoprefixer tailwindcss-animate")
print("npm install class-variance-authority clsx tailwind-merge")
print("\n🚀 Next steps:")
print("1. Copy the configurations above to your project")
print("2. Install the required packages")
print("3. Import globals.css in your main app file")
print("4. Start using shadcn/ui components!")
if __name__ == "__main__":
main()
Related skills
FAQ
What architecture does it use?
shadcn/ui with CVA variants, Radix UI primitives, and Tailwind CSS.
How many components does it cover?
30+ across form, display, feedback, navigation, and layout categories.