
Shadcn
- 647 installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
shadcn is a Claude Code skill that encodes shadcn/ui community rules for writing, reviewing, and refactoring Radix-and-Tailwind components for developers who need accessible, performant UI patterns.
About
shadcn is a dot-skills package that distills shadcn/ui community best practices for building and reviewing React components built on Radix primitives and Tailwind CSS. The guide organizes 58 rules across 10 categories prioritized by impact to steer automated refactors and code generation. It triggers on tasks involving React Hook Form validation, data tables, theming, component composition, and accessibility checks. Developers reach for shadcn when implementing new UI, refactoring legacy shadcn/ui code, or enforcing consistent patterns across design-system components.
- 58 community rules across 10 prioritized categories
- CRITICAL coverage for CLI setup, component architecture, and accessibility preservation
- HIGH-priority styling, theming, dark mode, and React Hook Form + Zod form patterns
- Data tables and large-dataset display guidance
- Triggers on Radix primitives, Tailwind styling, and composition refactors
Shadcn by the numbers
- 647 all-time installs (skills.sh)
- Ranked #525 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill shadcnAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 647 |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you follow shadcn/ui best practices in React?
Write, review, and refactor shadcn/ui components with community rules for setup, a11y, forms, tables, and theming.
Who is it for?
React developers using shadcn/ui, Radix, and Tailwind who want enforced community patterns during write or review tasks.
Skip if: Projects on Material UI or Chakra only, backend-only tasks, or teams not using Tailwind-based component libraries.
When should I use this skill?
User writes, reviews, or refactors shadcn/ui components, data tables, forms, theming, or Radix accessibility issues.
What you get
Refactored shadcn/ui components, a11y fixes, and pattern-aligned form, table, and theme implementations.
- refactored UI components
- a11y-compliant patterns
- themed component sets
By the numbers
- Contains 58 rules across 10 categories
Files
shadcn/ui Community Best Practices
Comprehensive best practices guide for shadcn/ui applications, maintained by the shadcn/ui community. Contains 58 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Installing and configuring shadcn/ui in a project
- Writing new shadcn/ui components or composing primitives
- Implementing forms with React Hook Form and Zod validation
- Building data tables or handling large dataset displays
- Customizing themes or adding dark mode support
- Reviewing code for accessibility compliance
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | CLI & Project Setup | CRITICAL | setup- |
| 2 | Component Architecture | CRITICAL | arch- |
| 3 | Accessibility Preservation | CRITICAL | ally- |
| 4 | Styling & Theming | HIGH | style- |
| 5 | Form Patterns | HIGH | form- |
| 6 | Data Display | MEDIUM-HIGH | data- |
| 7 | Layout & Navigation | MEDIUM | layout- |
| 8 | Component Composition | MEDIUM | comp- |
| 9 | Performance Optimization | MEDIUM | perf- |
| 10 | State Management | LOW-MEDIUM | state- |
Quick Reference
1. CLI & Project Setup (CRITICAL)
- `setup-components-json` - Configure components.json before adding components
- `setup-path-aliases` - Configure TypeScript path aliases to match components.json
- `setup-cn-utility` - Create the cn utility before using components
- `setup-use-cli-not-copy` - Use CLI to add components instead of copy-paste
- `setup-css-variables-theme` - Enable CSS variables for consistent theming
- `setup-rsc-configuration` - Set RSC flag based on framework support
2. Component Architecture (CRITICAL)
- `arch-use-asChild-for-custom-triggers` - Use asChild prop for custom trigger elements
- `arch-preserve-radix-primitive-structure` - Maintain Radix compound component hierarchy
- `arch-extend-variants-with-cva` - Use Class Variance Authority for type-safe variants
- `arch-use-cn-for-class-merging` - Use cn() utility for safe Tailwind class merging
- `arch-forward-refs-for-composable-components` - Forward refs for form and focus integration
- `arch-isolate-component-variants` - Separate base styles from variant-specific styles
3. Accessibility Preservation (CRITICAL)
- `ally-preserve-aria-attributes` - Keep Radix ARIA attributes intact
- `ally-provide-sr-only-labels` - Add screen reader labels for icon buttons
- `ally-maintain-focus-management` - Preserve focus trapping in modals
- `ally-preserve-keyboard-navigation` - Keep WAI-ARIA keyboard patterns
- `ally-ensure-color-contrast` - Maintain WCAG color contrast ratios
- `ally-dialog-title-required` - Always include DialogTitle for screen readers
- `ally-form-field-labels` - Associate labels with form controls
- `ally-aria-invalid-errors` - Use aria-invalid for form error states
- `ally-checkbox-label-association` - Wrap Checkbox with Label for click target
- `ally-focus-visible-styles` - Preserve focus visible styles for keyboard navigation
4. Styling & Theming (HIGH)
- `style-use-css-variables-for-theming` - Use CSS variables for theme colors
- `style-avoid-important-overrides` - Never use !important for style overrides
- `style-use-tailwind-theme-extend` - Extend Tailwind theme for design tokens
- `style-consistent-spacing-scale` - Use consistent Tailwind spacing scale
- `style-responsive-design-patterns` - Apply mobile-first responsive design
- `style-dark-mode-support` - Support dark mode with CSS variables
5. Form Patterns (HIGH)
- `form-use-react-hook-form-integration` - Integrate with React Hook Form
- `form-use-zod-for-schema-validation` - Use Zod for type-safe validation
- `form-show-validation-errors-correctly` - Show errors at appropriate times
- `form-handle-async-validation` - Debounce async validation calls
- `form-reset-form-state-correctly` - Reset form state after submission
6. Data Display (MEDIUM-HIGH)
- `data-use-tanstack-table-for-complex-tables` - Use TanStack Table for sorting/filtering
- `data-virtualize-large-lists` - Virtualize lists with 100+ items
- `data-use-skeleton-loading-states` - Use Skeleton for loading states
- `data-paginate-server-side` - Paginate large datasets server-side
- `data-empty-states-with-guidance` - Provide actionable empty states
7. Layout & Navigation (MEDIUM)
- `layout-sidebar-provider` - Wrap layout with SidebarProvider
- `layout-sidebar-collapsible` - Configure sidebar collapsible behavior
- `layout-sidebar-groups` - Organize sidebar navigation with groups
- `layout-sheet-mobile-nav` - Use Sheet for mobile navigation overlay
- `layout-breadcrumb-navigation` - Implement breadcrumbs for deep navigation
8. Component Composition (MEDIUM)
- `comp-compose-with-compound-components` - Use compound component patterns
- `comp-use-drawer-for-mobile-modals` - Use Drawer on mobile devices
- `comp-combine-command-with-popover` - Create searchable selects with Command
- `comp-nest-dialogs-correctly` - Manage nested dialog focus correctly
- `comp-create-reusable-form-fields` - Extract reusable form field components
- `comp-use-slot-pattern-for-flexibility` - Use slot pattern for flexible content
9. Performance Optimization (MEDIUM)
- `perf-lazy-load-heavy-components` - Lazy load components over 50KB
- `perf-memoize-expensive-renders` - Memoize list items and expensive components
- `perf-optimize-icon-imports` - Use direct imports for Lucide icons
- `perf-avoid-unnecessary-rerenders-in-forms` - Isolate form field watching
- `perf-debounce-search-inputs` - Debounce search and filter inputs
10. State Management (LOW-MEDIUM)
- `state-prefer-uncontrolled-for-simple-inputs` - Use uncontrolled for simple forms
- `state-lift-state-to-appropriate-level` - Lift state to lowest common ancestor
- `state-use-controlled-dialog-state` - Control dialogs for programmatic access
- `state-colocate-state-with-components` - Keep state close to where it's used
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Full Compiled Document
For a single-file reference containing all rules, see AGENTS.md.
Reference Files
| File | Description |
|---|---|
| AGENTS.md | Complete compiled guide with all rules |
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
shadcn/ui
Version 0.2.0 shadcn/ui Community January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive best practices guide for shadcn/ui applications, designed for AI agents and LLMs. Contains 58 rules across 10 categories, prioritized by impact from critical (CLI setup, component architecture, accessibility preservation) to incremental (state management). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. CLI & Project Setup — CRITICAL
- 1.1 Configure components.json Before Adding Components — CRITICAL (prevents path resolution failures across all component imports)
- 1.2 Configure TypeScript Path Aliases to Match components.json — CRITICAL (prevents module resolution errors in all component files)
- 1.3 Create the cn Utility Before Using Components — CRITICAL (required by every shadcn/ui component for class merging)
- 1.4 Use CLI to Add Components Instead of Copy-Paste — CRITICAL (ensures correct imports, dependencies, and file structure)
- 1.5 Enable CSS Variables for Consistent Theming — CRITICAL (enables dark mode and design system consistency)
- 1.6 Set RSC Flag Based on Framework Support — HIGH (prevents client/server component mismatch errors)
2. Component Architecture — CRITICAL
- 2.1 Extend Variants with Class Variance Authority — CRITICAL (maintains type safety and design consistency)
- 2.2 Forward Refs for Composable Components — CRITICAL (enables integration with form libraries and focus management)
- 2.3 Isolate Component Variants from Base Styles — CRITICAL (prevents style bleeding and maintains component reusability)
- 2.4 Preserve Radix Primitive Structure — CRITICAL (maintains keyboard navigation and focus management)
- 2.5 Use asChild for Custom Trigger Elements — CRITICAL (preserves accessibility and event handling)
- 2.6 Use cn() for Safe Class Merging — CRITICAL (prevents Tailwind class conflicts)
3. Accessibility Preservation — CRITICAL
- 3.1 Ensure Color Contrast Meets WCAG Standards — CRITICAL (enables readability for low vision users)
- 3.2 Maintain Focus Management in Modals — CRITICAL (prevents 100% keyboard user navigation failure)
- 3.3 Preserve ARIA Attributes from Radix Primitives — CRITICAL (maintains screen reader compatibility)
- 3.4 Preserve Keyboard Navigation Patterns — CRITICAL (enables non-mouse users to navigate components)
- 3.5 Provide Screen Reader Labels for Icon Buttons — CRITICAL (enables navigation for visually impaired users)
- 3.6 Always Include DialogTitle for Screen Readers — HIGH (required for ARIA labeling and screen reader announcements)
- 3.7 Associate Labels with Form Controls — HIGH (enables screen reader announcements and click-to-focus)
- 3.8 Use aria-invalid for Form Error States — HIGH (announces validation errors to screen readers)
- 3.9 Wrap Checkbox with Label for Click Target — HIGH (expands clickable area and provides screen reader context)
- 3.10 Preserve Focus Visible Styles for Keyboard Navigation — MEDIUM-HIGH (required for keyboard users to track focus position)
4. Styling & Theming — HIGH
- 4.1 Apply Mobile-First Responsive Design — HIGH (prevents mobile usability failures on 50%+ of traffic)
- 4.2 Avoid !important Overrides — HIGH (maintains style specificity and component customization)
- 4.3 Extend Tailwind Theme for Custom Design Tokens — HIGH (maintains design system consistency)
- 4.4 Support Dark Mode with CSS Variables — HIGH (provides user preference compliance and reduces eye strain)
- 4.5 Use Consistent Spacing Scale — HIGH (creates visual rhythm and reduces design inconsistency)
- 4.6 Use CSS Variables for Theme Colors — HIGH (enables runtime theme switching and consistency)
5. Form Patterns — HIGH
- 5.1 Handle Async Validation with Debouncing — HIGH (prevents excessive API calls during validation)
- 5.2 Reset Form State Correctly After Submission — HIGH (prevents stale data and submission errors)
- 5.3 Show Validation Errors at Appropriate Times — HIGH (improves user experience and reduces frustration)
- 5.4 Use React Hook Form with shadcn/ui Forms — HIGH (eliminates re-renders and provides validation)
- 5.5 Use Zod for Schema Validation — HIGH (eliminates runtime type errors with full TS inference)
6. Data Display — MEDIUM-HIGH
- 6.1 Paginate Large Datasets Server-Side — MEDIUM-HIGH (reduces initial payload by 90%+ for large datasets)
- 6.2 Provide Actionable Empty States — MEDIUM-HIGH (increases user action rate by 2-4×)
- 6.3 Use Skeleton Components for Loading States — MEDIUM-HIGH (reduces perceived load time and prevents layout shift)
- 6.4 Use TanStack Table for Complex Data Tables — MEDIUM-HIGH (eliminates 200-500 lines of manual table logic)
- 6.5 Virtualize Large Lists and Tables — MEDIUM-HIGH (10-100× rendering performance for large lists)
7. Layout & Navigation — MEDIUM
- 7.1 Wrap Layout with SidebarProvider — MEDIUM (enables sidebar state management across components)
- 7.2 Configure Sidebar Collapsible Behavior — MEDIUM (controls how sidebar collapses on different screen sizes)
- 7.3 Organize Sidebar Navigation with Groups — MEDIUM (improves navigation findability with logical grouping)
- 7.4 Use Sheet for Mobile Navigation Overlay — MEDIUM (proper mobile navigation with slide-in behavior)
- 7.5 Implement Breadcrumbs for Deep Navigation — MEDIUM (provides location context and quick navigation to parent pages)
8. Component Composition — MEDIUM
- 8.1 Combine Command with Popover for Searchable Selects — MEDIUM (reduces selection time by 3-5× for long lists)
- 8.2 Compose with Compound Component Patterns — MEDIUM (reduces prop count by 60-80% vs monolithic components)
- 8.3 Create Reusable Form Field Components — MEDIUM (reduces boilerplate and ensures consistency)
- 8.4 Nest Dialogs with Proper Focus Management — MEDIUM (maintains focus trap hierarchy in nested modals)
- 8.5 Use Drawer for Mobile Modal Interactions — MEDIUM (reduces touch distance by 40-60% on mobile)
- 8.6 Use Slot Pattern for Flexible Content Areas — MEDIUM (enables custom content injection without prop explosion)
9. Performance Optimization — MEDIUM
- 9.1 Avoid Unnecessary Re-renders in Forms — MEDIUM (prevents full form re-render on every keystroke)
- 9.2 Debounce Search and Filter Inputs — MEDIUM (reduces API calls by 80-90% during typing)
- 9.3 Lazy Load Heavy Components — MEDIUM (reduces initial bundle by 30-50%)
- 9.4 Memoize Expensive Component Renders — MEDIUM (prevents unnecessary re-renders in lists and data displays)
- 9.5 Optimize Icon Imports from Lucide — MEDIUM (reduces bundle by 200-500KB with direct imports)
10. State Management — LOW-MEDIUM
- 10.1 Colocate State with the Components That Use It — LOW-MEDIUM (improves code organization and reduces unnecessary coupling)
- 10.2 Lift State to the Appropriate Level — LOW-MEDIUM (prevents prop drilling and enables component communication)
- 10.3 Prefer Uncontrolled Components for Simple Inputs — LOW-MEDIUM (reduces state management overhead for simple cases)
- 10.4 Use Controlled State for Dialogs Triggered Externally — LOW-MEDIUM (enables programmatic dialog control from parent components)
---
References
1. https://ui.shadcn.com/ 2. https://www.radix-ui.com/primitives/docs/overview/accessibility 3. https://vercel.com/academy/shadcn-ui 4. https://react-hook-form.com/ 5. https://tailwindcss.com/ 6. https://cva.style/docs 7. https://tanstack.com/table/latest 8. https://tanstack.com/virtual/latest
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation of WHY this matters for performance, accessibility, or maintainability. Focus on the consequences of not following the rule. 1-3 sentences.
Incorrect (description of what's wrong):
// Realistic code example showing the anti-pattern
// Comment explaining the specific cost or problem
function BadExample({ props }: Props) {
// Show the problematic code
}Correct (description of what's right):
// Realistic code example showing the correct approach
// Comment explaining the benefit
function GoodExample({ props }: Props) {
// Show the improved code
}When NOT to use this pattern:
- Exception case 1
- Exception case 2
Benefits:
- Benefit 1
- Benefit 2
Reference: Source Title
{
"version": "1.0.6",
"organization": "shadcn/ui Community",
"technology": "shadcn/ui",
"date": "January 2026",
"abstract": "Comprehensive best practices guide for shadcn/ui applications, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact from critical (component architecture, accessibility preservation) to incremental (state management). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://ui.shadcn.com/",
"https://www.radix-ui.com/primitives/docs/overview/accessibility",
"https://vercel.com/academy/shadcn-ui",
"https://react-hook-form.com/",
"https://tailwindcss.com/",
"https://cva.style/docs",
"https://tanstack.com/table/latest",
"https://tanstack.com/virtual/latest"
],
"category": "Frontend"
}
shadcn/ui Best Practices
A comprehensive best practices skill for building applications with shadcn/ui, Radix primitives, and Tailwind CSS.
Overview
This skill provides 42 rules across 8 categories, covering:
- Component Architecture - Proper Radix primitive usage, CVA patterns, ref forwarding
- Accessibility - ARIA preservation, focus management, keyboard navigation
- Styling & Theming - CSS variables, dark mode, Tailwind patterns
- Form Patterns - React Hook Form integration, Zod validation
- Data Display - TanStack Table, virtualization, loading states
- Component Composition - Compound components, responsive patterns
- Performance - Lazy loading, memoization, bundle optimization
- State Management - Controlled vs uncontrolled, state colocation
Structure
shadcn-ui/
├── SKILL.md # Quick reference and navigation
├── AGENTS.md # Compiled guide for AI agents
├── README.md # This file
├── metadata.json # Version and reference information
├── references/
│ ├── _sections.md # Category definitions
│ ├── arch-*.md # Component architecture rules (6)
│ ├── a11y-*.md # Accessibility rules (5)
│ ├── style-*.md # Styling rules (6)
│ ├── form-*.md # Form pattern rules (5)
│ ├── data-*.md # Data display rules (5)
│ ├── comp-*.md # Composition rules (6)
│ ├── perf-*.md # Performance rules (5)
│ └── state-*.md # State management rules (4)
└── assets/
└── templates/
└── _template.md # Rule templateGetting Started
Installation
pnpm installBuilding
pnpm buildValidation
pnpm validateCreating a New Rule
1. Choose the appropriate category prefix:
| Category | Prefix | Impact |
|---|---|---|
| Component Architecture | arch- | CRITICAL |
| Accessibility | a11y- | CRITICAL |
| Styling & Theming | style- | HIGH |
| Form Patterns | form- | HIGH |
| Data Display | data- | MEDIUM-HIGH |
| Component Composition | comp- | MEDIUM |
| Performance | perf- | MEDIUM |
| State Management | state- | LOW-MEDIUM |
2. Create a new file in references/ with the naming pattern: {prefix}-{description}.md
3. Use the template structure from assets/templates/_template.md
4. Run validation to ensure compliance
Rule File Structure
Each rule file follows this structure:
---
title: Rule Title
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, technique, tools, concepts
---
## Rule Title
Brief explanation of WHY this matters (1-3 sentences).
**Incorrect (what's wrong):**
\`\`\`tsx
// Bad code example with comment explaining the cost
\`\`\`
**Correct (what's right):**
\`\`\`tsx
// Good code example with comment explaining the benefit
\`\`\`
Reference: [Source](url)File Naming Convention
Rule files use the pattern: {prefix}-{kebab-case-description}.md
Examples:
arch-use-asChild-for-custom-triggers.mda11y-preserve-aria-attributes.mdstyle-use-css-variables-for-theming.md
Impact Levels
| Level | Description | Examples |
|---|---|---|
| CRITICAL | Foundational - wrong patterns cascade everywhere | Component structure, accessibility |
| HIGH | Significant UX/DX impact | Theming, form validation |
| MEDIUM-HIGH | Important for specific use cases | Data tables, loading states |
| MEDIUM | Improves quality noticeably | Composition, performance |
| LOW-MEDIUM | Nice to have, situational | State patterns |
| LOW | Edge cases or micro-optimizations | Advanced patterns |
Scripts
| Command | Description |
|---|---|
pnpm build | Build AGENTS.md from references |
pnpm validate | Validate skill against guidelines |
pnpm lint | Lint markdown files |
Contributing
1. Read existing rules to understand the style 2. Create your rule following the template 3. Run validation before submitting 4. Ensure code examples are realistic and runnable
Acknowledgments
- shadcn/ui - The component library
- Radix UI - Accessible primitives
- Tailwind CSS - Utility-first CSS
- React Hook Form - Form management
- TanStack - Table and virtual list libraries
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. CLI & Project Setup (setup)
Impact: CRITICAL Description: Proper project configuration is foundational - incorrect setup causes component installation failures and TypeScript errors that block all development.
2. Component Architecture (arch)
Impact: CRITICAL Description: Proper component structure and Radix primitive usage is foundational - architectural mistakes cascade to every consumer and are costly to fix.
3. Accessibility Preservation (ally)
Impact: CRITICAL Description: shadcn/ui inherits WAI-ARIA compliance from Radix UI - breaking accessibility patterns excludes users and violates legal requirements.
4. Styling & Theming (style)
Impact: HIGH Description: Consistent Tailwind and CSS variable usage ensures visual coherence and maintainable theming across the entire application.
5. Form Patterns (form)
Impact: HIGH Description: Forms are critical UX touchpoints - proper React Hook Form and Zod integration ensures data integrity and user experience.
6. Data Display (data)
Impact: MEDIUM-HIGH Description: Tables, lists, and data visualization patterns affect how users interact with large datasets and complex information.
7. Layout & Navigation (layout)
Impact: MEDIUM Description: Sidebar, navigation, and page structure patterns provide consistent user experience across application sections.
8. Component Composition (comp)
Impact: MEDIUM Description: Combining shadcn/ui primitives using compound component patterns maximizes reusability and maintains API consistency.
9. Performance Optimization (perf)
Impact: MEDIUM Description: Bundle size management, lazy loading, and render optimization ensure fast load times and smooth interactions.
10. State Management (state)
Impact: LOW-MEDIUM Description: Controlled vs uncontrolled patterns and state lifting decisions affect component predictability and debugging.
Use aria-invalid for Form Error States
Set aria-invalid={true} on inputs with validation errors. This announces the error state to screen readers and triggers error styling.
Incorrect (visual-only error indication):
import { Input } from "@/components/ui/input"
function EmailInput({ error }) {
return (
<div>
<Input
type="email"
className={error ? "border-red-500" : ""}
/>
{error && <span className="text-red-500">{error}</span>}
</div>
)
// Screen reader: Cannot detect error state
}Correct (aria-invalid with error message):
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
function EmailInput({ error }) {
return (
<Field data-invalid={!!error}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
/>
{error && <FieldError id="email-error">{error}</FieldError>}
</Field>
)
// Screen reader: "Email, invalid entry, Enter a valid email address"
}With React Hook Form:
<Input
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError>{fieldState.error?.message}</FieldError>
)}Reference: shadcn/ui Forms
Wrap Checkbox with Label for Click Target
Checkboxes should be wrapped with or associated to labels. This expands the click target and ensures screen readers announce the label with the checkbox.
Incorrect (checkbox without label association):
import { Checkbox } from "@/components/ui/checkbox"
function TermsCheckbox() {
return (
<div className="flex items-center gap-2">
<Checkbox id="terms" />
<span>I agree to the terms</span> {/* Not a label */}
</div>
)
// Clicking text doesn't toggle checkbox
// Screen reader: "checkbox, unchecked" - no context
}Correct (label with htmlFor):
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
function TermsCheckbox() {
return (
<div className="flex items-center gap-2">
<Checkbox id="terms" />
<Label htmlFor="terms">I agree to the terms and conditions</Label>
</div>
)
// Clicking label toggles checkbox
// Screen reader: "I agree to the terms and conditions, checkbox, unchecked"
}Alternative (wrapping label):
<Label className="flex items-center gap-2">
<Checkbox />
<span>I agree to the terms</span>
</Label>Reference: shadcn/ui Checkbox
Always Include DialogTitle for Screen Readers
Every Dialog must have a DialogTitle. This sets the aria-labelledby attribute that screen readers use to announce the dialog's purpose when it opens.
Incorrect (missing DialogTitle):
import { Dialog, DialogContent } from "@/components/ui/dialog"
function ConfirmDialog({ open, onClose }) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<p>Are you sure you want to delete this item?</p>
<Button onClick={onClose}>Cancel</Button>
<Button variant="destructive">Delete</Button>
</DialogContent>
</Dialog>
)
// Screen reader: "Dialog" (no context about purpose)
}Correct (with DialogTitle):
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function ConfirmDialog({ open, onClose }) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Item</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<p>Are you sure you want to delete this item?</p>
<Button onClick={onClose}>Cancel</Button>
<Button variant="destructive">Delete</Button>
</DialogContent>
</Dialog>
)
// Screen reader: "Delete Item dialog"
}For visually hidden titles:
<DialogTitle className="sr-only">Search</DialogTitle>Reference: WAI-ARIA Dialog Pattern
Ensure Color Contrast Meets WCAG Standards
When customizing shadcn/ui theme colors, ensure text meets WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text). The default theme is compliant; custom themes may not be.
Incorrect (insufficient contrast ratio):
:root {
--primary: 200 80% 70%;
--primary-foreground: 200 80% 90%;
/* Light blue on lighter blue = ~1.5:1 ratio - fails WCAG */
}
.dark {
--muted: 220 10% 20%;
--muted-foreground: 220 10% 40%;
/* Dark gray on slightly lighter gray = ~2:1 ratio - fails WCAG */
}Correct (WCAG AA compliant contrast):
:root {
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* Dark blue on near-white = ~12:1 ratio - passes WCAG AAA */
}
.dark {
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
/* Dark slate on light gray = ~6:1 ratio - passes WCAG AA */
}Testing contrast:
// Use browser DevTools or tools like WebAIM Contrast Checker
// shadcn/ui default colors are pre-tested for WCAG AA
// When adding custom colors, verify each combination:
// - foreground on background
// - primary-foreground on primary
// - destructive-foreground on destructive
// - muted-foreground on mutedWCAG requirements:
- Normal text (< 18pt): 4.5:1 minimum
- Large text (≥ 18pt or 14pt bold): 3:1 minimum
- UI components and graphics: 3:1 minimum
Reference: WCAG Contrast Requirements
Preserve Focus Visible Styles for Keyboard Navigation
Never remove focus ring styles. Keyboard users rely on visible focus indicators to navigate the interface. shadcn/ui components include focus-visible styles by default.
Incorrect (focus styles removed):
const buttonVariants = cva(
"inline-flex items-center justify-center outline-none", // Removed focus ring
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
},
},
}
)
// Keyboard users cannot see which button is focusedCorrect (focus-visible styles preserved):
const buttonVariants = cva(
"inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
},
},
}
)Custom focus styles (if needed):
// Still visible, but customized
<Button className="focus-visible:ring-brand focus-visible:ring-offset-4">
Custom Focus
</Button>Why focus-visible (not focus):
focus: Shows ring on mouse click toofocus-visible: Only shows ring for keyboard navigation
Reference: WCAG Focus Visible
Associate Labels with Form Controls
Every form input must have an associated label using the htmlFor attribute or wrapping. Placeholder text alone is not accessible.
Incorrect (placeholder without label):
import { Input } from "@/components/ui/input"
function SearchForm() {
return (
<form>
<Input placeholder="Enter email address" type="email" />
{/* Screen reader: "Edit text" - no context */}
{/* Placeholder disappears when typing */}
</form>
)
}Correct (Field component with label):
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
function SearchForm() {
return (
<form>
<Field>
<FieldLabel htmlFor="email">Email Address</FieldLabel>
<Input id="email" type="email" placeholder="you@example.com" />
</Field>
</form>
)
// Screen reader: "Email Address, edit text"
}Alternative (Label component):
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
function SearchForm() {
return (
<form>
<div className="grid gap-2">
<Label htmlFor="email">Email Address</Label>
<Input id="email" type="email" />
</div>
</form>
)
}For icon-only inputs (visually hidden label):
<Label htmlFor="search" className="sr-only">Search</Label>
<Input id="search" placeholder="Search..." />Reference: WAI-ARIA Forms
Maintain Focus Management in Modals
Radix Dialog/Sheet components trap focus within the modal and return focus on close. Custom modal implementations must replicate this behavior for keyboard accessibility.
Incorrect (focus escapes modal):
function CustomModal({
open,
onClose,
children,
}: {
open: boolean
onClose: () => void
children: React.ReactNode
}) {
if (!open) return null
return (
<div className="fixed inset-0 bg-black/50" onClick={onClose}>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<button onClick={onClose}>Close</button>
{children}
{/* Tab key can focus elements behind modal */}
{/* Escape key doesn't close modal */}
{/* Focus not moved to modal on open */}
</div>
</div>
)
}Correct (using shadcn/ui Dialog):
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog"
function CustomModal({
open,
onOpenChange,
title,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
children: React.ReactNode
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children}
{/* Focus trapped within DialogContent */}
{/* Escape key closes modal automatically */}
{/* Focus returns to trigger on close */}
</DialogContent>
</Dialog>
)
}Focus management behaviors:
- Focus moves to first focusable element on open
- Tab cycles through modal content only
- Shift+Tab cycles backwards
- Escape closes and returns focus to trigger
Reference: WAI-ARIA Dialog Pattern
Preserve ARIA Attributes from Radix Primitives
Radix primitives automatically manage ARIA attributes for accessibility. Overriding or omitting these attributes breaks screen reader functionality.
Incorrect (ARIA attributes overridden):
function CustomAccordion({ items }: { items: AccordionItem[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
<button
onClick={() => setOpenIndex(openIndex === index ? null : index)}
>
{/* Missing aria-expanded, aria-controls */}
{item.title}
</button>
{openIndex === index && (
<div>
{/* Missing aria-labelledby, role="region" */}
{item.content}
</div>
)}
</div>
))}
</div>
)
}Correct (using Radix primitives with automatic ARIA):
import {
Accordion,
AccordionContent,
AccordionItem as AccordionItemComponent,
AccordionTrigger,
} from "@/components/ui/accordion"
function CustomAccordion({ items }: { items: AccordionItem[] }) {
return (
<Accordion type="single" collapsible>
{items.map((item) => (
<AccordionItemComponent key={item.id} value={item.id}>
<AccordionTrigger>
{/* Radix adds aria-expanded, aria-controls automatically */}
{item.title}
</AccordionTrigger>
<AccordionContent>
{/* Radix adds aria-labelledby, role="region" automatically */}
{item.content}
</AccordionContent>
</AccordionItemComponent>
))}
</Accordion>
)
}ARIA attributes managed by Radix:
aria-expandedon triggers (Accordion, Collapsible, Dialog)aria-controls/aria-labelledbyfor content relationshipsroleattributes (dialog, menu, tablist, etc.)aria-selected/aria-checkedfor selection states
Reference: Radix Accessibility
Preserve Keyboard Navigation Patterns
Radix components implement WAI-ARIA keyboard navigation patterns. Custom styling or structure changes must not break these patterns.
Incorrect (keyboard navigation broken):
function CustomTabs({ tabs }: { tabs: TabData[] }) {
const [activeTab, setActiveTab] = useState(0)
return (
<div>
<div className="flex gap-2">
{tabs.map((tab, index) => (
<div
key={tab.id}
onClick={() => setActiveTab(index)}
className={activeTab === index ? "border-b-2" : ""}
>
{/* div is not focusable, arrow keys don't work */}
{tab.label}
</div>
))}
</div>
<div>{tabs[activeTab].content}</div>
</div>
)
}Correct (shadcn/ui Tabs with full keyboard support):
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
function CustomTabs({ tabs }: { tabs: TabData[] }) {
return (
<Tabs defaultValue={tabs[0].id}>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{/* Left/Right arrows navigate tabs */}
{/* Home/End jump to first/last tab */}
{/* Enter/Space selects tab */}
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
{tab.content}
</TabsContent>
))}
</Tabs>
)
}Keyboard patterns by component:
- Tabs: Left/Right arrows, Home/End
- Menu/Dropdown: Up/Down arrows, Enter to select
- Accordion: Up/Down arrows, Enter to toggle
- Combobox: Up/Down arrows, Enter to select, Escape to close
Reference: WAI-ARIA Patterns
Provide Screen Reader Labels for Icon Buttons
Icon-only buttons must have accessible labels. Without them, screen readers announce "button" with no context about the action.
Incorrect (icon button without accessible name):
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<SunIcon className="h-4 w-4 dark:hidden" />
<MoonIcon className="h-4 w-4 hidden dark:block" />
{/* Screen reader announces: "button" - no context */}
</Button>
)
}Correct (sr-only text provides context):
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<SunIcon className="h-4 w-4 dark:hidden" />
<MoonIcon className="h-4 w-4 hidden dark:block" />
<span className="sr-only">Toggle theme</span>
{/* Screen reader announces: "Toggle theme, button" */}
</Button>
)
}Alternative (using aria-label):
function CloseButton({ onClose }: { onClose: () => void }) {
return (
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close dialog"
>
<XIcon className="h-4 w-4" />
</Button>
)
}Use sr-only when:
- The label is longer or more descriptive
- Multiple icons need different labels in the same context
- You want visible fallback if CSS fails
Reference: Tailwind Screen Reader
Extend Variants with Class Variance Authority
When adding new variants to shadcn/ui components, extend the existing CVA configuration rather than using conditional className logic. This maintains type safety and design system consistency.
Incorrect (inline conditional classes):
function StatusBadge({ status }: { status: string }) {
return (
<Badge
className={
status === "success"
? "bg-green-500"
: status === "warning"
? "bg-yellow-500"
: status === "error"
? "bg-red-500"
: ""
}
>
{status}
</Badge>
)
}
// No type safety, classes can conflict with base Badge stylesCorrect (extended CVA configuration):
import { cva, type VariantProps } from "class-variance-authority"
const statusBadgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold",
{
variants: {
status: {
success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
info: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
},
},
defaultVariants: {
status: "info",
},
}
)
interface StatusBadgeProps extends VariantProps<typeof statusBadgeVariants> {
children: React.ReactNode
}
function StatusBadge({ status, children }: StatusBadgeProps) {
return <span className={statusBadgeVariants({ status })}>{children}</span>
}
// Type-safe: status prop is typed as "success" | "warning" | "error" | "info"Reference: Class Variance Authority
Forward Refs for Composable Components
Custom components wrapping shadcn/ui primitives must forward refs to enable form library integration, focus management, and imperative handles.
Incorrect (ref not forwarded):
interface SearchInputProps {
onSearch: (query: string) => void
}
function SearchInput({ onSearch }: SearchInputProps) {
const [query, setQuery] = useState("")
return (
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSearch(query)}
/>
)
}
// Parent cannot focus the input
function SearchForm() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus() // null - ref not forwarded
}, [])
return <SearchInput ref={inputRef} onSearch={handleSearch} />
}Correct (ref forwarded to underlying element):
interface SearchInputProps {
onSearch: (query: string) => void
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ onSearch }, ref) => {
const [query, setQuery] = useState("")
return (
<Input
ref={ref}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSearch(query)}
/>
)
}
)
SearchInput.displayName = "SearchInput"
// Parent can now focus the input
function SearchForm() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus() // Works - ref forwarded to Input
}, [])
return <SearchInput ref={inputRef} onSearch={handleSearch} />
}Always forward refs when:
- Wrapping form inputs (Input, Select, Textarea)
- Creating trigger components for modals/popovers
- Building components used with React Hook Form
Reference: React forwardRef
Isolate Component Variants from Base Styles
Keep variant-specific styles separate from base component styles. Mixing them creates tightly coupled components that are difficult to extend or override.
Incorrect (base and variant styles mixed):
function AlertBanner({
type,
children,
}: {
type: "info" | "success" | "error"
children: React.ReactNode
}) {
return (
<div
className={`rounded-lg p-4 ${
type === "info"
? "border-blue-200 bg-blue-50 text-blue-800"
: type === "success"
? "border-green-200 bg-green-50 text-green-800"
: "border-red-200 bg-red-50 text-red-800"
}`}
>
{/* Border style missing from base, must be repeated in each variant */}
{children}
</div>
)
}Correct (separated base and variant definitions):
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
// Base styles applied to all variants
"rounded-lg border p-4",
{
variants: {
type: {
// Only color-related styles in variants
info: "border-blue-200 bg-blue-50 text-blue-800",
success: "border-green-200 bg-green-50 text-green-800",
error: "border-red-200 bg-red-50 text-red-800",
},
},
defaultVariants: {
type: "info",
},
}
)
interface AlertBannerProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {}
function AlertBanner({ type, className, children, ...props }: AlertBannerProps) {
return (
<div className={cn(alertVariants({ type }), className)} {...props}>
{children}
</div>
)
}
// Base styles (rounded-lg, border, p-4) guaranteed on all variantsBenefits:
- Base styles guaranteed on all variants
- Easy to add new variants without duplicating structure
- Clear separation enables easier maintenance
Reference: CVA Documentation
Preserve Radix Primitive Structure
shadcn/ui components are built on Radix primitives with specific parent-child relationships. Breaking this structure disables keyboard navigation, focus trapping, and ARIA attributes.
Incorrect (broken primitive hierarchy):
function CustomDialog({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
{open && (
<div className="fixed inset-0 bg-black/50">
<DialogContent>
{/* DialogContent outside Dialog - focus trap broken */}
{children}
</DialogContent>
</div>
)}
</>
)
}Correct (preserved compound component structure):
function CustomDialog({ children }: { children: React.ReactNode }) {
return (
<Dialog>
<DialogTrigger asChild>
<button>Open</button>
</DialogTrigger>
<DialogContent>
{/* Proper hierarchy: Dialog > DialogContent */}
{children}
</DialogContent>
</Dialog>
)
}Required hierarchies for common components:
Dialog→DialogTrigger+DialogContent→DialogHeader/FooterDropdownMenu→DropdownMenuTrigger+DropdownMenuContent→DropdownMenuItemTabs→TabsList→TabsTrigger+TabsContent
Reference: shadcn/ui Dialog
Use asChild for Custom Trigger Elements
When using custom elements as triggers for Radix-based components, use the asChild prop to merge behavior onto your custom element instead of wrapping it.
Incorrect (nested button elements, broken a11y):
function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost">
<UserIcon className="h-4 w-4" />
Account
</Button>
</DropdownMenuTrigger>
{/* Creates <button><button>...</button></button> - invalid HTML */}
<DropdownMenuContent>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Correct (single button element with merged props):
function UserMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<UserIcon className="h-4 w-4" />
Account
</Button>
</DropdownMenuTrigger>
{/* Renders single <button> with all Radix props merged */}
<DropdownMenuContent>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}When to use asChild:
- Trigger components (DialogTrigger, PopoverTrigger, DropdownMenuTrigger)
- When your custom component already renders a focusable element
- When you need to preserve your component's styling and props
Reference: Radix UI Composition
Use cn() for Safe Class Merging
Always use the cn() utility (which wraps clsx and tailwind-merge) when combining classes. Direct string concatenation causes Tailwind class conflicts where later classes don't override earlier ones.
Incorrect (string concatenation causes conflicts):
interface CardProps {
className?: string
variant?: "default" | "highlighted"
}
function Card({ className, variant }: CardProps) {
const baseClasses = "rounded-lg border bg-card p-6"
const variantClasses = variant === "highlighted" ? "bg-primary" : ""
return (
<div className={`${baseClasses} ${variantClasses} ${className}`}>
{/* bg-card and bg-primary both in class string - unpredictable result */}
</div>
)
}Correct (cn() handles conflicts intelligently):
import { cn } from "@/lib/utils"
interface CardProps {
className?: string
variant?: "default" | "highlighted"
}
function Card({ className, variant }: CardProps) {
return (
<div
className={cn(
"rounded-lg border bg-card p-6",
variant === "highlighted" && "bg-primary text-primary-foreground",
className
)}
>
{/* tailwind-merge ensures bg-primary overrides bg-card */}
</div>
)
}How cn() works: 1. clsx handles conditional classes and arrays 2. tailwind-merge resolves conflicts (last wins for same property) 3. User's className prop always takes precedence (passed last)
Reference: shadcn/ui Utilities
Combine Command with Popover for Searchable Selects
For searchable dropdown selection (combobox pattern), combine Command with Popover. Command provides search and keyboard navigation; Popover provides positioning.
Incorrect (native select with no search):
function CountrySelect({ value, onChange }: CountrySelectProps) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select country" />
</SelectTrigger>
<SelectContent>
{countries.map((country) => (
<SelectItem key={country.code} value={country.code}>
{country.name}
</SelectItem>
))}
{/* 200+ countries with no way to search - poor UX */}
</SelectContent>
</Select>
)
}Correct (Command + Popover combobox):
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
function CountrySelect({ value, onChange }: CountrySelectProps) {
const [open, setOpen] = useState(false)
const selectedCountry = countries.find((c) => c.code === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedCountry?.name ?? "Select country..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search countries..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
onChange(country.code)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === country.code ? "opacity-100" : "opacity-0"
)}
/>
{country.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}Combobox features:
- Type to filter (CommandInput)
- Arrow keys to navigate
- Enter to select
- Escape to close
- Accessible role="combobox" with aria-expanded
Reference: shadcn/ui Combobox
Compose with Compound Component Patterns
Build custom components using compound component patterns like shadcn/ui. This creates flexible, composable APIs that fit naturally with existing components.
Incorrect (monolithic component with many props):
interface SettingsCardProps {
title: string
description: string
icon: LucideIcon
switchLabel: string
switchChecked: boolean
onSwitchChange: (checked: boolean) => void
badge?: string
footer?: React.ReactNode
}
function SettingsCard({
title,
description,
icon: Icon,
switchLabel,
switchChecked,
onSwitchChange,
badge,
footer,
}: SettingsCardProps) {
// Rigid API - hard to customize layout or add new elements
return (
<Card>
<CardHeader>
<Icon className="h-5 w-5" />
<CardTitle>{title}</CardTitle>
{badge && <Badge>{badge}</Badge>}
</CardHeader>
<CardContent>
<p>{description}</p>
<Switch checked={switchChecked} onCheckedChange={onSwitchChange} />
</CardContent>
{footer && <CardFooter>{footer}</CardFooter>}
</Card>
)
}Correct (compound component pattern):
const SettingsCardContext = createContext<{ disabled?: boolean }>({})
function SettingsCard({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) {
return (
<SettingsCardContext.Provider value={{ disabled }}>
<Card className={cn(disabled && "opacity-50")}>{children}</Card>
</SettingsCardContext.Provider>
)
}
function SettingsCardHeader({ children }: { children: React.ReactNode }) {
return <CardHeader className="flex flex-row items-center gap-4">{children}</CardHeader>
}
function SettingsCardIcon({ icon: Icon }: { icon: LucideIcon }) {
return <Icon className="h-5 w-5 text-muted-foreground" />
}
function SettingsCardTitle({ children }: { children: React.ReactNode }) {
return <CardTitle className="text-base">{children}</CardTitle>
}
function SettingsCardContent({ children }: { children: React.ReactNode }) {
return <CardContent>{children}</CardContent>
}
function SettingsCardAction({ children }: { children: React.ReactNode }) {
const { disabled } = useContext(SettingsCardContext)
return <div className={cn(disabled && "pointer-events-none")}>{children}</div>
}
// Usage - flexible composition
<SettingsCard>
<SettingsCardHeader>
<SettingsCardIcon icon={Bell} />
<SettingsCardTitle>Notifications</SettingsCardTitle>
<Badge>Beta</Badge>
</SettingsCardHeader>
<SettingsCardContent>
<p className="text-muted-foreground">Receive alerts for important updates</p>
</SettingsCardContent>
<SettingsCardAction>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</SettingsCardAction>
</SettingsCard>Reference: Compound Components Pattern
Create Reusable Form Field Components
Extract common form field patterns into reusable components to reduce boilerplate and maintain consistency across forms.
Incorrect (repeated form field boilerplate):
function UserForm() {
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
})
return (
<Form {...form}>
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 10 more fields with identical structure... */}
</Form>
)
}Correct (reusable field components):
// components/form/text-field.tsx
interface TextFieldProps<T extends FieldValues> {
control: Control<T>
name: Path<T>
label: string
placeholder?: string
description?: string
type?: "text" | "email" | "password"
}
function TextField<T extends FieldValues>({
control,
name,
label,
placeholder,
description,
type = "text",
}: TextFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input type={type} placeholder={placeholder} {...field} />
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
)
}
// components/form/select-field.tsx
interface SelectFieldProps<T extends FieldValues> {
control: Control<T>
name: Path<T>
label: string
placeholder?: string
options: { value: string; label: string }[]
}
function SelectField<T extends FieldValues>({
control,
name,
label,
placeholder,
options,
}: SelectFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)
}
// Usage - clean and consistent
function UserForm() {
const form = useForm<UserFormValues>({ resolver: zodResolver(userSchema) })
return (
<Form {...form}>
<TextField control={form.control} name="firstName" label="First Name" />
<TextField control={form.control} name="lastName" label="Last Name" />
<TextField control={form.control} name="email" label="Email" type="email" />
<SelectField
control={form.control}
name="role"
label="Role"
options={roleOptions}
/>
</Form>
)
}Reference: React Hook Form with TypeScript
Nest Dialogs with Proper Focus Management
When opening a dialog from within another dialog (e.g., confirmation from settings), manage focus correctly to prevent trapping issues.
Incorrect (nested Dialog loses focus context):
function SettingsDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Settings</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Settings content */}
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
{/* Inner dialog - focus management may break */}
<DialogTitle>Confirm Delete</DialogTitle>
</DialogContent>
</Dialog>
</div>
</DialogContent>
</Dialog>
)
}Correct (AlertDialog for confirmations):
function SettingsDialog() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
return (
<>
<Dialog>
<DialogTrigger asChild>
<Button>Settings</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Settings content */}
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)}>
Delete Account
</Button>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. Your data will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}Alternative (DropdownMenu with modal={false}):
<DropdownMenu modal={false}>
{/* When modal={false}, dropdown won't steal focus from parent dialog */}
<DropdownMenuTrigger asChild>
<Button variant="outline">Options</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => setShowDeleteConfirm(true)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>Guidelines:
- Use AlertDialog for confirmations (designed for this pattern)
- Set
modal={false}on DropdownMenu inside Dialogs - Manage nested dialog state in parent component
Reference: shadcn/ui AlertDialog
Use Drawer for Mobile Modal Interactions
On mobile devices, use Drawer (bottom sheet) instead of Dialog for better thumb reachability. Detect device type and render the appropriate component.
Incorrect (Dialog on all devices):
function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent>
{/* Center-screen dialog is hard to reach on mobile */}
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button variant="destructive" onClick={onConfirm}>Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Correct (responsive Dialog/Drawer):
import { useMediaQuery } from "@/hooks/use-media-query"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
DrawerFooter,
DrawerTrigger,
} from "@/components/ui/drawer"
function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
const [open, setOpen] = useState(false)
const isDesktop = useMediaQuery("(min-width: 768px)")
const content = (
<>
<p className="text-muted-foreground">This action cannot be undone.</p>
<div className="flex gap-2 mt-4">
<Button variant="outline" onClick={() => setOpen(false)} className="flex-1">
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} className="flex-1">
Delete
</Button>
</div>
</>
)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="destructive">Delete</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Are you sure?</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-4">{content}</div>
</DrawerContent>
</Drawer>
)
}useMediaQuery hook:
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false)
useEffect(() => {
const media = window.matchMedia(query)
setMatches(media.matches)
const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
media.addEventListener("change", listener)
return () => media.removeEventListener("change", listener)
}, [query])
return matches
}Reference: shadcn/ui Drawer
Use Slot Pattern for Flexible Content Areas
For components with multiple content areas (header, footer, actions), use named slot patterns instead of render props or excessive boolean props.
Incorrect (render props and booleans):
interface NotificationProps {
title: string
message: string
showIcon?: boolean
icon?: React.ReactNode
showDismiss?: boolean
onDismiss?: () => void
showAction?: boolean
actionLabel?: string
onAction?: () => void
renderFooter?: () => React.ReactNode
}
function Notification({
title,
message,
showIcon,
icon,
showDismiss,
onDismiss,
showAction,
actionLabel,
onAction,
renderFooter,
}: NotificationProps) {
// Props explosion - hard to extend, confusing API
return (
<div className="rounded-lg border p-4">
{showIcon && icon}
<div>
<h4>{title}</h4>
<p>{message}</p>
</div>
{showDismiss && <button onClick={onDismiss}>×</button>}
{showAction && <button onClick={onAction}>{actionLabel}</button>}
{renderFooter?.()}
</div>
)
}Correct (slot-based composition):
interface NotificationProps {
children: React.ReactNode
className?: string
}
function Notification({ children, className }: NotificationProps) {
return (
<div className={cn("rounded-lg border p-4", className)}>
{children}
</div>
)
}
function NotificationIcon({ children }: { children: React.ReactNode }) {
return <div className="flex-shrink-0">{children}</div>
}
function NotificationContent({ children }: { children: React.ReactNode }) {
return <div className="flex-1 ml-3">{children}</div>
}
function NotificationTitle({ children }: { children: React.ReactNode }) {
return <h4 className="font-medium">{children}</h4>
}
function NotificationDescription({ children }: { children: React.ReactNode }) {
return <p className="text-sm text-muted-foreground mt-1">{children}</p>
}
function NotificationActions({ children }: { children: React.ReactNode }) {
return <div className="flex gap-2 mt-3">{children}</div>
}
function NotificationDismiss({ onDismiss }: { onDismiss: () => void }) {
return (
<Button variant="ghost" size="icon" onClick={onDismiss} className="absolute top-2 right-2">
<X className="h-4 w-4" />
<span className="sr-only">Dismiss</span>
</Button>
)
}
// Usage - compose exactly what you need
<Notification className="relative">
<NotificationIcon>
<CheckCircle className="h-5 w-5 text-green-500" />
</NotificationIcon>
<NotificationContent>
<NotificationTitle>Success!</NotificationTitle>
<NotificationDescription>Your changes have been saved.</NotificationDescription>
<NotificationActions>
<Button size="sm">View</Button>
<Button size="sm" variant="outline">Undo</Button>
</NotificationActions>
</NotificationContent>
<NotificationDismiss onDismiss={() => setVisible(false)} />
</Notification>Reference: Composition vs Inheritance
Provide Actionable Empty States
When displaying empty data (no results, no items), provide context and clear actions rather than just "No data".
Incorrect (unhelpful empty state):
function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return <p className="text-muted-foreground p-4">No tasks found.</p>
// User doesn't know why or what to do next
}
return <ul>{/* render tasks */}</ul>
}Correct (actionable empty state):
import { Plus, Search, Filter } from "lucide-react"
function TaskList({
tasks,
searchQuery,
filter,
onCreateTask,
onClearFilters,
}: TaskListProps) {
if (tasks.length === 0) {
// Different empty states based on context
if (searchQuery) {
return (
<div className="flex flex-col items-center justify-center p-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No results for "{searchQuery}"</h3>
<p className="text-muted-foreground mt-1 mb-4">
Try adjusting your search or filters
</p>
<Button variant="outline" onClick={onClearFilters}>
Clear filters
</Button>
</div>
)
}
if (filter !== "all") {
return (
<div className="flex flex-col items-center justify-center p-12 text-center">
<Filter className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No {filter} tasks</h3>
<p className="text-muted-foreground mt-1 mb-4">
Tasks marked as {filter} will appear here
</p>
<Button variant="outline" onClick={() => onClearFilters()}>
View all tasks
</Button>
</div>
)
}
// Fresh start - no tasks yet
return (
<div className="flex flex-col items-center justify-center p-12 text-center border-2 border-dashed rounded-lg">
<Plus className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No tasks yet</h3>
<p className="text-muted-foreground mt-1 mb-4 max-w-sm">
Get started by creating your first task to track your work
</p>
<Button onClick={onCreateTask}>
<Plus className="h-4 w-4 mr-2" />
Create task
</Button>
</div>
)
}
return <ul>{/* render tasks */}</ul>
}Empty state guidelines:
- Use relevant icons to visually communicate the state
- Explain why the list is empty (search, filter, fresh start)
- Provide a clear primary action
- Keep copy concise and helpful
Reference: Empty States Design Patterns
Paginate Large Datasets Server-Side
For datasets over 100 items, implement server-side pagination. Client-side pagination requires loading all data upfront, bloating the initial payload.
Incorrect (client-side pagination):
function ProductTable() {
const { data: products } = useQuery(["products"], () =>
fetch("/api/products").then((r) => r.json())
)
// Fetches ALL 10,000 products on mount
const [page, setPage] = useState(0)
const pageSize = 10
const paginatedProducts = products?.slice(page * pageSize, (page + 1) * pageSize)
return (
<>
<Table>{/* render paginatedProducts */}</Table>
<Pagination>{/* ... */}</Pagination>
</>
)
}Correct (server-side pagination):
function ProductTable() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const { data, isLoading } = useQuery(
["products", pagination],
() =>
fetch(
`/api/products?page=${pagination.pageIndex}&limit=${pagination.pageSize}`
).then((r) => r.json()),
{ keepPreviousData: true } // Smooth transitions between pages
)
// Fetches only 10 products per page
const table = useReactTable({
data: data?.products ?? [],
columns,
pageCount: data?.totalPages ?? -1,
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true, // Tell TanStack Table pagination is server-side
getCoreRowModel: getCoreRowModel(),
})
return (
<>
<Table>
<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 py-4">
<p className="text-sm text-muted-foreground">
Page {pagination.pageIndex + 1} of {data?.totalPages}
</p>
<div className="flex gap-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>
</>
)
}Reference: TanStack Table Pagination
Use Skeleton Components for Loading States
Use shadcn/ui Skeleton components to show content placeholders during data loading. This prevents layout shifts and reduces perceived load time.
Incorrect (spinner or empty state):
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery(["user", userId], fetchUser)
if (isLoading) {
return (
<div className="flex justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin" />
{/* Content jumps when data loads - layout shift */}
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatar} />
</Avatar>
<div>
<CardTitle>{user.name}</CardTitle>
<p className="text-muted-foreground">{user.email}</p>
</div>
</div>
</CardHeader>
</Card>
)
}Correct (skeleton matching final layout):
import { Skeleton } from "@/components/ui/skeleton"
function UserProfileSkeleton() {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
</div>
</CardHeader>
</Card>
)
}
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery(["user", userId], fetchUser)
if (isLoading) {
return <UserProfileSkeleton />
// Same dimensions as loaded content - no layout shift
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatar} />
</Avatar>
<div>
<CardTitle>{user.name}</CardTitle>
<p className="text-muted-foreground">{user.email}</p>
</div>
</div>
</CardHeader>
</Card>
)
}Skeleton best practices:
- Match skeleton dimensions to final content exactly
- Use
animate-pulse(default) for subtle loading indication - Group related skeletons to show content hierarchy
- Create reusable skeleton components for repeated patterns
Reference: shadcn/ui Skeleton
Use TanStack Table for Complex Data Tables
For tables requiring sorting, filtering, or pagination, use TanStack Table with shadcn/ui's Table component. Manual implementations are error-prone and lack features.
Incorrect (manual sorting implementation):
function UserTable({ users }: { users: User[] }) {
const [sortField, setSortField] = useState<keyof User>("name")
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc")
const sortedUsers = [...users].sort((a, b) => {
// Manual sorting - breaks for nested fields, dates, null values
const aVal = a[sortField]
const bVal = b[sortField]
return sortDirection === "asc"
? aVal > bVal ? 1 : -1
: aVal < bVal ? 1 : -1
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => setSortField("name")}>Name</TableHead>
{/* Missing sort indicators, accessibility */}
</TableRow>
</TableHeader>
{/* ... */}
</Table>
)
}Correct (TanStack Table integration):
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table"
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <Badge>{row.getValue("status")}</Badge>,
},
]
function UserTable({ users }: { users: User[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
state: { sorting },
})
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</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>
)
}Reference: shadcn/ui Data Table
Virtualize Large Lists and Tables
For lists or tables with 100+ items, use virtualization to render only visible rows. Rendering all rows causes memory bloat and janky scrolling.
Incorrect (rendering all rows):
function LogViewer({ logs }: { logs: LogEntry[] }) {
return (
<div className="h-[600px] overflow-auto">
{logs.map((log) => (
<div key={log.id} className="p-2 border-b">
{/* Renders 10,000 DOM nodes for 10,000 logs */}
<span className="text-muted-foreground">{log.timestamp}</span>
<span className="ml-2">{log.message}</span>
</div>
))}
</div>
)
}Correct (virtualized with TanStack Virtual):
import { useVirtualizer } from "@tanstack/react-virtual"
function LogViewer({ logs }: { logs: LogEntry[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40, // Estimated row height in pixels
overscan: 5, // Render 5 extra rows above/below viewport
})
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const log = logs[virtualRow.index]
return (
<div
key={virtualRow.key}
className="absolute w-full p-2 border-b"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Only ~20 DOM nodes rendered at any time */}
<span className="text-muted-foreground">{log.timestamp}</span>
<span className="ml-2">{log.message}</span>
</div>
)
})}
</div>
</div>
)
}When to virtualize:
- Lists with 100+ items
- Tables with 50+ rows and complex cells
- Log viewers, chat histories, infinite scroll
- Any scrollable list causing jank
Reference: TanStack Virtual
Handle Async Validation with Debouncing
When validating against an API (username availability, email uniqueness), debounce the validation to prevent excessive network requests.
Incorrect (API call on every keystroke):
const schema = z.object({
username: z.string().min(3).refine(
async (username) => {
// Called on EVERY keystroke - floods server
const response = await fetch(`/api/check-username?u=${username}`)
return response.ok
},
{ message: "Username already taken" }
),
})
function UsernameForm() {
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange", // Triggers validation constantly
})
return <Form {...form}>{/* ... */}</Form>
}Correct (debounced async validation):
import { useDebouncedCallback } from "use-debounce"
const baseSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
})
type FormValues = z.infer<typeof baseSchema>
function UsernameForm() {
const [usernameError, setUsernameError] = useState<string | null>(null)
const [isChecking, setIsChecking] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(baseSchema),
mode: "onBlur",
})
const checkUsername = useDebouncedCallback(async (username: string) => {
if (username.length < 3) return
setIsChecking(true)
try {
const response = await fetch(`/api/check-username?u=${username}`)
if (!response.ok) {
setUsernameError("Username already taken")
form.setError("username", { message: "Username already taken" })
} else {
setUsernameError(null)
}
} finally {
setIsChecking(false)
}
}, 500) // 500ms debounce
return (
<Form {...form}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
onChange={(e) => {
field.onChange(e)
checkUsername(e.target.value)
}}
/>
{isChecking && (
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin" />
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
)
}Reference: use-debounce
Reset Form State Correctly After Submission
After successful form submission, reset the form state to prevent stale data, duplicate submissions, and confusion about form status.
Incorrect (form not reset after submission):
function ContactForm() {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
})
const onSubmit = async (data: ContactFormValues) => {
await submitContact(data)
toast.success("Message sent!")
// Form still shows old data
// User might accidentally resubmit
}
return <Form {...form}>{/* ... */}</Form>
}Correct (form reset with proper state management):
function ContactForm() {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
message: "",
},
})
const onSubmit = async (data: ContactFormValues) => {
try {
await submitContact(data)
toast.success("Message sent!")
form.reset() // Resets to defaultValues and clears errors
} catch (error) {
toast.error("Failed to send message")
// Keep form data so user can retry
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Sending..." : "Send Message"}
</Button>
</form>
</Form>
)
}Reset patterns:
form.reset()- Reset to defaultValuesform.reset(newValues)- Reset to specific valuesform.resetField("email")- Reset single fieldform.clearErrors()- Clear errors without resetting values
For edit forms (reset to fetched data):
const { data: user } = useQuery(["user", userId], fetchUser)
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
})
useEffect(() => {
if (user) {
form.reset(user) // Reset to fetched data when available
}
}, [user, form])Reference: React Hook Form reset
Show Validation Errors at Appropriate Times
Show validation errors on blur or submit, not on every keystroke. Immediate validation frustrates users typing valid input.
Incorrect (errors shown while typing):
const form = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onChange", // Validates on every keystroke
})
// User types "t" - sees "Email must be valid" immediately
// User types "te" - still sees error
// User types "test@" - still sees error
// Frustrating experience during normal typingCorrect (errors shown on blur or submit):
const form = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onBlur", // Validates when field loses focus
reValidateMode: "onChange", // Re-validates on change after first error
})
// User types entire email without interruption
// Error only shown when they leave the field
// Once error shown, it updates as they fix itAlternative (validate on submit only):
const form = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onSubmit", // Only validates on form submission
})
// Good for short forms where user submits quickly
// Shows all errors at once after submit attemptValidation mode guidelines:
onBlur- Recommended for most formsonChange- Only for real-time feedback (passwords)onSubmit- Short forms or wizardsreValidateMode: "onChange"- Always pair with onBlur for instant feedback during correction
Reference: React Hook Form Validation
Use React Hook Form with shadcn/ui Forms
shadcn/ui's Form components are designed for React Hook Form integration. Using controlled state with useState causes re-renders on every keystroke.
Incorrect (controlled state causes re-renders):
function LoginForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Manual validation logic...
// Re-renders entire form on every keystroke
}
return (
<form onSubmit={handleSubmit}>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
{errors.email && <p className="text-red-500">{errors.email}</p>}
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit">Login</Button>
</form>
)
}Correct (React Hook Form with shadcn/ui):
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
type LoginFormValues = z.infer<typeof loginSchema>
function LoginForm() {
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
})
const onSubmit = (data: LoginFormValues) => {
// Validated data, no re-renders during typing
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Login</Button>
</form>
</Form>
)
}Reference: shadcn/ui Forms
Use Zod for Schema Validation
Define form schemas with Zod for type-safe validation. Zod integrates with React Hook Form via @hookform/resolvers and provides TypeScript type inference.
Incorrect (manual validation without schema):
function RegistrationForm() {
const form = useForm()
const onSubmit = (data: any) => {
// Manual validation - no type safety
if (!data.email || !data.email.includes("@")) {
form.setError("email", { message: "Invalid email" })
return
}
if (!data.age || data.age < 18) {
form.setError("age", { message: "Must be 18 or older" })
return
}
// data is typed as 'any' - no autocomplete
}
return <form onSubmit={form.handleSubmit(onSubmit)}>{/* ... */}</form>
}Correct (Zod schema with type inference):
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
const registrationSchema = z.object({
email: z.string().email("Please enter a valid email"),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be at most 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),
age: z.coerce
.number()
.min(18, "You must be at least 18 years old")
.max(120, "Please enter a valid age"),
website: z.string().url("Please enter a valid URL").optional().or(z.literal("")),
})
type RegistrationFormValues = z.infer<typeof registrationSchema>
// TypeScript knows: { email: string; username: string; age: number; website?: string }
function RegistrationForm() {
const form = useForm<RegistrationFormValues>({
resolver: zodResolver(registrationSchema),
defaultValues: {
email: "",
username: "",
age: undefined,
website: "",
},
})
const onSubmit = (data: RegistrationFormValues) => {
// data is fully typed with validation passed
console.log(data.email) // TypeScript knows this is a valid email string
}
return <Form {...form}>{/* ... */}</Form>
}Common Zod patterns:
z.coerce.number()- Converts string input to number.optional().or(z.literal(""))- Allow empty string for optional fields.refine()- Custom validation logic.transform()- Transform values after validation
Reference: Zod Documentation
Implement Breadcrumbs for Deep Navigation
Use Breadcrumb component for pages more than one level deep. Users need context about their location and quick access to parent pages.
Incorrect (no breadcrumbs on deep pages):
// pages/products/[id]/edit.tsx
function EditProductPage({ product }) {
return (
<div>
<h1>Edit {product.name}</h1>
{/* User has no idea how to get back to products list */}
</div>
)
}Correct (breadcrumb navigation):
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import Link from "next/link"
function EditProductPage({ product }) {
return (
<div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/products">Products</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/products/${product.id}`}>{product.name}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Edit</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="mt-4">Edit {product.name}</h1>
</div>
)
}Note: Use BreadcrumbPage for the current page (not a link) and BreadcrumbLink with asChild for navigable items.
Reference: shadcn/ui Breadcrumb
Use Sheet for Mobile Navigation Overlay
Use Sheet component for mobile navigation that slides in from the edge. Dialog-based mobile menus feel awkward and don't match mobile UX patterns.
Incorrect (Dialog for mobile nav):
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
function MobileNav() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu />
</Button>
</DialogTrigger>
<DialogContent>
<nav>{/* Navigation items */}</nav>
</DialogContent>
</Dialog>
)
// Dialog centers on screen, doesn't feel like mobile nav
}Correct (Sheet with side positioning):
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Menu } from "lucide-react"
function MobileNav() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[300px]">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-4 py-4">
<a href="/dashboard" className="text-lg font-medium">Dashboard</a>
<a href="/settings" className="text-lg font-medium">Settings</a>
<a href="/help" className="text-lg font-medium">Help</a>
</nav>
</SheetContent>
</Sheet>
)
}Sheet sides:
left: Standard mobile nav (slides from left edge)right: Settings/filters paneltop: Notifications, searchbottom: Action sheets, mobile modals
Reference: shadcn/ui Sheet
Configure Sidebar Collapsible Behavior
Set the collapsible prop to control sidebar collapse behavior. The wrong mode creates poor UX on mobile or wastes space on desktop.
Incorrect (no collapsible configuration):
import { Sidebar } from "@/components/ui/sidebar"
function AppSidebar() {
return (
<Sidebar> {/* No collapsible prop - defaults may not match your needs */}
<SidebarContent>{/* ... */}</SidebarContent>
</Sidebar>
)
}Correct (explicit collapsible mode):
import { Sidebar } from "@/components/ui/sidebar"
// Icon mode: Collapses to icons only (good for desktop apps)
function AppSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarContent>{/* ... */}</SidebarContent>
</Sidebar>
)
}
// Offcanvas mode: Slides in/out (good for mobile-first)
function MobileSidebar() {
return (
<Sidebar collapsible="offcanvas">
<SidebarContent>{/* ... */}</SidebarContent>
</Sidebar>
)
}
// None: Never collapses (fixed sidebar)
function FixedSidebar() {
return (
<Sidebar collapsible="none">
<SidebarContent>{/* ... */}</SidebarContent>
</Sidebar>
)
}| Mode | Collapsed State | Best For |
|---|---|---|
icon | Shows icons only | Desktop apps with frequent navigation |
offcanvas | Fully hidden, slides in | Mobile-first, content-heavy apps |
none | Never collapses | Admin panels, always-visible nav |
Reference: shadcn/ui Sidebar
Organize Sidebar Navigation with Groups
Use SidebarGroup to organize related navigation items. Flat item lists become hard to scan as navigation grows.
Incorrect (flat navigation list):
import { Sidebar, SidebarContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from "@/components/ui/sidebar"
function AppSidebar() {
return (
<Sidebar>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem><SidebarMenuButton>Dashboard</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Analytics</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Users</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Products</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Orders</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Settings</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Help</SidebarMenuButton></SidebarMenuItem>
</SidebarMenu>
</SidebarContent>
</Sidebar>
)
// 7+ items become hard to scan without grouping
}Correct (grouped navigation):
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from "@/components/ui/sidebar"
function AppSidebar() {
return (
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Overview</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem><SidebarMenuButton>Dashboard</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Analytics</SidebarMenuButton></SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Management</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem><SidebarMenuButton>Users</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Products</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Orders</SidebarMenuButton></SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Support</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem><SidebarMenuButton>Settings</SidebarMenuButton></SidebarMenuItem>
<SidebarMenuItem><SidebarMenuButton>Help</SidebarMenuButton></SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
)
}Reference: shadcn/ui Sidebar
Wrap Layout with SidebarProvider
SidebarProvider must wrap any component that uses sidebar state or controls. Without it, useSidebar hook and SidebarTrigger fail.
Incorrect (sidebar without provider):
// app/layout.tsx
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarTrigger } from "@/components/ui/sidebar"
export default function Layout({ children }) {
return (
<div className="flex">
<AppSidebar /> {/* Error: useSidebar must be used within SidebarProvider */}
<main>
<SidebarTrigger /> {/* Also fails */}
{children}
</main>
</div>
)
}Correct (wrapped with provider):
// app/layout.tsx
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/app-sidebar"
export default function Layout({ children }) {
return (
<SidebarProvider>
<AppSidebar />
<main className="flex-1">
<header className="flex items-center gap-2 p-4">
<SidebarTrigger />
<h1>Dashboard</h1>
</header>
{children}
</main>
</SidebarProvider>
)
}Persisting sidebar state:
<SidebarProvider defaultOpen={true}>
{/* Sidebar starts expanded */}
</SidebarProvider>
// Or with cookies/localStorage
<SidebarProvider defaultOpen={cookies.get("sidebar_open") !== "false"}>Reference: shadcn/ui Sidebar
Avoid Unnecessary Re-renders in Forms
Isolate frequently updating form state to prevent entire form re-renders. Watch specific fields instead of the entire form state.
Incorrect (watching entire form state):
function CheckoutForm() {
const form = useForm<CheckoutFormValues>()
const values = form.watch() // Re-renders entire form on ANY field change
const total = calculateTotal(values.items, values.coupon)
return (
<Form {...form}>
{/* All 20 form fields re-render on every keystroke */}
<FormField name="name" control={form.control} render={...} />
<FormField name="email" control={form.control} render={...} />
<FormField name="address" control={form.control} render={...} />
{/* ... 17 more fields */}
<div>Total: ${total}</div>
</Form>
)
}Correct (isolated watch with useWatch):
function CheckoutForm() {
const form = useForm<CheckoutFormValues>()
return (
<Form {...form}>
<FormField name="name" control={form.control} render={...} />
<FormField name="email" control={form.control} render={...} />
<FormField name="address" control={form.control} render={...} />
{/* Fields don't re-render when unrelated fields change */}
{/* Isolated component for reactive total */}
<OrderTotal control={form.control} />
</Form>
)
}
function OrderTotal({ control }: { control: Control<CheckoutFormValues> }) {
// Only this component re-renders when items or coupon change
const items = useWatch({ control, name: "items" })
const coupon = useWatch({ control, name: "coupon" })
const total = calculateTotal(items, coupon)
return <div className="text-lg font-bold">Total: ${total}</div>
}Alternative (watch specific fields at form level):
function CheckoutForm() {
const form = useForm<CheckoutFormValues>()
// Only watch specific fields needed for calculations
const [items, coupon] = form.watch(["items", "coupon"])
// Still causes re-renders but only for these 2 fields
return (
<Form {...form}>
{/* ... */}
</Form>
)
}Best practices:
- Use
useWatchin isolated child components - Watch specific field names, not entire form
- Use
useFormStatefor submission/validation state - Use
useControllerfor complex controlled components
Reference: React Hook Form useWatch
Debounce Search and Filter Inputs
Debounce search inputs to prevent API calls on every keystroke. Users type 3-5 characters per second; calling the API each time overwhelms the server and UI.
Incorrect (API call on every keystroke):
function SearchUsers() {
const [query, setQuery] = useState("")
const { data, isLoading } = useQuery(
["users", query],
() => searchUsers(query),
{ enabled: query.length > 0 }
)
// User types "john" = 4 API calls in < 1 second
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isLoading && <Skeleton className="h-20" />}
{/* Results flicker between each keystroke */}
</div>
)
}Correct (debounced search):
import { useDebouncedValue } from "@/hooks/use-debounced-value"
function SearchUsers() {
const [query, setQuery] = useState("")
const debouncedQuery = useDebouncedValue(query, 300) // 300ms delay
const { data, isLoading } = useQuery(
["users", debouncedQuery],
() => searchUsers(debouncedQuery),
{ enabled: debouncedQuery.length > 0 }
)
// User types "john" = 1 API call after they stop typing
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isLoading && <Skeleton className="h-20" />}
{/* Stable results, no flickering */}
</div>
)
}useDebouncedValue hook:
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}Recommended delays:
- Search inputs: 300-500ms
- Autocomplete: 150-300ms
- Filter updates: 200-400ms
- Form field validation: 500ms
Reference: use-debounce
Lazy Load Heavy Components
Use dynamic imports for heavy components (charts, editors, modals with complex content) to reduce initial bundle size and improve Time to Interactive.
Incorrect (all components in initial bundle):
import { DataChart } from "@/components/data-chart" // 150KB
import { RichTextEditor } from "@/components/rich-text-editor" // 200KB
import { CodeEditor } from "@/components/code-editor" // 300KB
function Dashboard() {
const [showChart, setShowChart] = useState(false)
const [showEditor, setShowEditor] = useState(false)
return (
<div>
{/* All 650KB loaded even if never used */}
{showChart && <DataChart data={chartData} />}
{showEditor && <RichTextEditor />}
</div>
)
}Correct (lazy loaded with Suspense):
import dynamic from "next/dynamic"
import { Skeleton } from "@/components/ui/skeleton"
const DataChart = dynamic(() => import("@/components/data-chart"), {
loading: () => <Skeleton className="h-[400px] w-full" />,
})
const RichTextEditor = dynamic(() => import("@/components/rich-text-editor"), {
loading: () => <Skeleton className="h-[300px] w-full" />,
ssr: false, // Disable SSR for browser-only components
})
const CodeEditor = dynamic(() => import("@/components/code-editor"), {
loading: () => <Skeleton className="h-[400px] w-full" />,
ssr: false,
})
function Dashboard() {
const [showChart, setShowChart] = useState(false)
const [showEditor, setShowEditor] = useState(false)
return (
<div>
{/* Components loaded only when rendered */}
{showChart && <DataChart data={chartData} />}
{showEditor && <RichTextEditor />}
</div>
)
}For React without Next.js:
import { lazy, Suspense } from "react"
import { Skeleton } from "@/components/ui/skeleton"
const DataChart = lazy(() => import("@/components/data-chart"))
function Dashboard() {
return (
<Suspense fallback={<Skeleton className="h-[400px] w-full" />}>
<DataChart data={chartData} />
</Suspense>
)
}When to lazy load:
- Components over 50KB
- Components not visible on initial render
- Components behind user interaction (modals, tabs)
- Heavy third-party integrations (charts, maps, editors)
Reference: Next.js Dynamic Imports
Memoize Expensive Component Renders
Use React.memo for list items and expensive components to prevent re-renders when parent state changes but props remain the same.
Incorrect (re-renders all rows on any change):
function DataTable({ data, onRowSelect }: DataTableProps) {
const [selectedId, setSelectedId] = useState<string | null>(null)
return (
<Table>
<TableBody>
{data.map((row) => (
<TableRow key={row.id} onClick={() => onRowSelect(row.id)}>
{/* All 100 rows re-render when selectedId changes */}
<TableCell>{row.name}</TableCell>
<TableCell>{row.email}</TableCell>
<TableCell>
<Badge variant={row.status === "active" ? "default" : "secondary"}>
{row.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Correct (memoized row component):
const DataTableRow = memo(function DataTableRow({
row,
onSelect,
}: {
row: DataRow
onSelect: (id: string) => void
}) {
return (
<TableRow onClick={() => onSelect(row.id)}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.email}</TableCell>
<TableCell>
<Badge variant={row.status === "active" ? "default" : "secondary"}>
{row.status}
</Badge>
</TableCell>
</TableRow>
)
})
function DataTable({ data, onRowSelect }: DataTableProps) {
const [selectedId, setSelectedId] = useState<string | null>(null)
// Stable callback reference
const handleSelect = useCallback((id: string) => {
setSelectedId(id)
onRowSelect(id)
}, [onRowSelect])
return (
<Table>
<TableBody>
{data.map((row) => (
<DataTableRow key={row.id} row={row} onSelect={handleSelect} />
// Only rows with changed props re-render
))}
</TableBody>
</Table>
)
}Memoization guidelines:
- Use
memofor list items rendered 10+ times - Use
useCallbackfor handlers passed to memoized children - Use
useMemofor expensive computations - Don't memoize everything - measure first
Reference: React memo
Optimize Icon Imports from Lucide
Import Lucide icons directly from their paths or use Next.js optimizePackageImports to avoid loading the entire icon library.
Incorrect (barrel import loads all icons):
import { Check, X, Menu, Settings, User, Bell } from "lucide-react"
// In dev mode: loads 1,500+ icons, adds ~2.8s to startup
// In production: tree-shaking may not fully eliminate unused iconsCorrect (direct imports):
import Check from "lucide-react/dist/esm/icons/check"
import X from "lucide-react/dist/esm/icons/x"
import Menu from "lucide-react/dist/esm/icons/menu"
import Settings from "lucide-react/dist/esm/icons/settings"
import User from "lucide-react/dist/esm/icons/user"
import Bell from "lucide-react/dist/esm/icons/bell"
// Loads only 6 icons (~2KB each)Alternative (Next.js 13.5+ optimizePackageImports):
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ["lucide-react"],
},
}// Now barrel imports are automatically optimized
import { Check, X, Menu, Settings, User, Bell } from "lucide-react"
// Next.js transforms this to direct imports at build timeCreating an icon wrapper for consistency:
// components/icons.tsx
export { default as CheckIcon } from "lucide-react/dist/esm/icons/check"
export { default as XIcon } from "lucide-react/dist/esm/icons/x"
export { default as MenuIcon } from "lucide-react/dist/esm/icons/menu"
export { default as SettingsIcon } from "lucide-react/dist/esm/icons/settings"
// Centralized icon exports with consistent namingReference: Vercel Package Import Optimization
Create the cn Utility Before Using Components
Every shadcn/ui component uses the cn utility to merge Tailwind classes. Missing this utility causes runtime errors in all components.
Incorrect (missing cn utility):
// components/ui/button.tsx
import { cn } from "@/lib/utils"
// Error: Cannot find module '@/lib/utils'
export function Button({ className, ...props }) {
return (
<button className={cn("px-4 py-2", className)} {...props} />
)
}Correct (cn utility defined):
// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}Why both libraries:
clsxhandles conditional classes:cn("base", isActive && "active")tailwind-mergeresolves conflicts:cn("px-2", "px-4")returns"px-4"
Reference: shadcn/ui Manual Installation
Related skills
How it compares
Use this skill for opinionated shadcn/ui pattern enforcement instead of generic React style guides when Radix and Tailwind are in stack.
FAQ
How many rules does the shadcn skill include?
The shadcn skill bundles 58 community rules organized into 10 categories, prioritized by impact to guide shadcn/ui writing, review, and automated refactoring.
When should the shadcn skill activate?
Activate shadcn when tasks involve shadcn/ui components, Radix primitives, Tailwind styling, React Hook Form, data tables, theming, or component composition patterns.
Is Shadcn safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.