references/ally-ensure-color-contrast.md
---
title: Ensure Color Contrast Meets WCAG Standards
impact: CRITICAL
impactDescription: enables readability for low vision users
tags: ally, contrast, wcag, colors, theming
---
## 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):**
```css
: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):**
```css
: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:**
```tsx
// 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 muted
```
**WCAG 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](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
references/ally-maintain-focus-management.md
---
title: Maintain Focus Management in Modals
impact: CRITICAL
impactDescription: prevents 100% keyboard user navigation failure
tags: ally, focus, modals, keyboard, dialog
---
## 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):**
```tsx
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):**
```tsx
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](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
references/ally-preserve-aria-attributes.md
---
title: Preserve ARIA Attributes from Radix Primitives
impact: CRITICAL
impactDescription: maintains screen reader compatibility
tags: ally, aria, radix, screen-readers, accessibility
---
## 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):**
```tsx
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):**
```tsx
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-expanded` on triggers (Accordion, Collapsible, Dialog)
- `aria-controls` / `aria-labelledby` for content relationships
- `role` attributes (dialog, menu, tablist, etc.)
- `aria-selected` / `aria-checked` for selection states
Reference: [Radix Accessibility](https://www.radix-ui.com/primitives/docs/overview/accessibility)
references/ally-preserve-keyboard-navigation.md
---
title: Preserve Keyboard Navigation Patterns
impact: CRITICAL
impactDescription: enables non-mouse users to navigate components
tags: ally, keyboard, navigation, radix, arrow-keys
---
## 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):**
```tsx
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):**
```tsx
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](https://www.w3.org/WAI/ARIA/apg/patterns/)
references/ally-provide-sr-only-labels.md
---
title: Provide Screen Reader Labels for Icon Buttons
impact: CRITICAL
impactDescription: enables navigation for visually impaired users
tags: ally, sr-only, screen-readers, icons, labels
---
## 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):**
```tsx
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):**
```tsx
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):**
```tsx
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](https://tailwindcss.com/docs/screen-readers)
references/arch-current-cli-registry.md
---
title: Use the Current CLI for Component Lifecycle
impact: MEDIUM-HIGH
impactDescription: dry-run installs, namespaced registries, and agent-friendly inspection
tags: shadcn, cli, registry, mcp, tooling
---
## Use the Current CLI for Component Lifecycle
The shadcn CLI (v3 Aug 2025, v4 Mar 2026) is the supported path for every component lifecycle action. It resolves namespaced registries, previews changes before they land, and exposes an MCP server so agents can search and install from any registry.
**Incorrect (hand-copying component code):**
```bash
# Pasting a Button source from a blog post into components/ui/
# Skips dependency resolution, theme tokens, and registry updates;
# drifts from the checked-in component contract immediately
```
**Correct (CLI with inspection flags):**
```bash
# Search and view before installing
npx shadcn@latest search "data table"
npx shadcn@latest view dialog
# Inspect what will change before it changes
npx shadcn@latest add data-table --dry-run
npx shadcn@latest add data-table --diff
# Install
npx shadcn@latest add data-table
```
**Namespaced registries (`@registry/name`):**
```bash
# Community or private registries, configured in components.json
npx shadcn@latest add @acme/button @internal/auth-system
```
```jsonc
// components.json
{
"registries": {
"@acme": "https://registry.acme.dev/{name}.json",
"@internal": {
"url": "https://internal.example.com/api/registry/{name}.json",
"headers": { "Authorization": "Bearer ${REGISTRY_TOKEN}" }
}
}
}
```
**Agent workflows:** the built-in MCP server (`npx shadcn@latest mcp`) lets coding agents browse, search, and install components; `shadcn preset`/`apply` applies design-system presets, `shadcn eject` inlines registry components for full ownership, and `npx shadcn@latest create` scaffolds new apps.
**Notes:**
- New projects default to **Base UI** primitives (July 2026); Radix remains fully supported — inspect checked-in components before choosing composition APIs.
- GitHub registries support private repositories; dynamic search lets large registries handle search server-side.
- Run installs on a clean branch and review the diff — installed files and dependency changes are ordinary project code.
references/arch-extend-variants-with-cva.md
---
title: Extend Variants with Class Variance Authority
impact: CRITICAL
impactDescription: maintains type safety and design consistency
tags: arch, cva, variants, class-variance-authority, typescript
---
## 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):**
```tsx
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 styles
```
**Correct (extended CVA configuration):**
```tsx
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](https://cva.style/docs)
references/arch-forward-refs-for-composable-components.md
---
title: Forward Refs for Composable Components
impact: CRITICAL
impactDescription: enables integration with form libraries and focus management
tags: arch, ref, refs, composition, forms
---
## Forward Refs for Composable Components
Custom components wrapping shadcn/ui primitives must let a ref reach the underlying element to enable form library integration, focus management, and imperative handles. In React 19, `ref` is a regular prop — accept it and pass it through. The legacy `forwardRef` wrapper is no longer needed (and shadcn/ui's own components have dropped it).
**Incorrect (ref not passed through):**
```tsx
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 never reaches <Input>
}, [])
return <SearchInput ref={inputRef} onSearch={handleSearch} />
}
```
**Correct (React 19 — accept `ref` as a prop):**
```tsx
interface SearchInputProps {
onSearch: (query: string) => void
ref?: React.Ref<HTMLInputElement>
}
function SearchInput({ onSearch, ref }: SearchInputProps) {
const [query, setQuery] = useState("")
return (
<Input
ref={ref}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSearch(query)}
/>
)
}
// 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 accept a ref prop when:**
- Wrapping form inputs (Input, Select, Textarea)
- Creating trigger components for modals/popovers
- Building components used with React Hook Form
> On React 18 or earlier, use `forwardRef` instead — `ref` as a plain prop requires React 19.
Reference: [React 19 — ref as a prop](https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop)
references/arch-isolate-component-variants.md
---
title: Isolate Component Variants from Base Styles
impact: CRITICAL
impactDescription: prevents style bleeding and maintains component reusability
tags: arch, variants, isolation, cva, separation-of-concerns
---
## 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):**
```tsx
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):**
```tsx
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 variants
```
**Benefits:**
- Base styles guaranteed on all variants
- Easy to add new variants without duplicating structure
- Clear separation enables easier maintenance
Reference: [CVA Documentation](https://cva.style/docs/getting-started/variants)
references/arch-preserve-radix-primitive-structure.md
---
title: Preserve Radix Primitive Structure
impact: CRITICAL
impactDescription: maintains keyboard navigation and focus management
tags: arch, radix, primitives, compound-components, structure
---
## 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):**
```tsx
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):**
```tsx
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/Footer`
- `DropdownMenu` → `DropdownMenuTrigger` + `DropdownMenuContent` → `DropdownMenuItem`
- `Tabs` → `TabsList` → `TabsTrigger` + `TabsContent`
Reference: [shadcn/ui Dialog](https://ui.shadcn.com/docs/components/dialog)
references/arch-unified-radix-package.md
---
title: Import Primitives from the Unified radix-ui Package
impact: LOW-MEDIUM
impactDescription: one import source instead of dozens of @radix-ui packages
tags: shadcn, radix, imports, dependencies
---
## Import Primitives from the Unified radix-ui Package
Since February 2026, Radix UI ships as one unified `radix-ui` package. Generated shadcn components increasingly import primitives from `radix-ui` instead of individual `@radix-ui/react-*` packages; mixing both styles in one codebase doubles the dependency tree and risks version drift between the two sources of the same primitive.
**Incorrect (mixed import sources):**
```tsx
// Old-style per-primitive package, still valid but legacy...
import * as DialogPrimitive from '@radix-ui/react-dialog'
// ...next to the unified package for another component
import { Tooltip } from 'radix-ui'
// Two dependency trees for the same primitives
```
**Correct (single unified package):**
```tsx
import { Dialog, Tooltip } from 'radix-ui'
export const DialogRoot = Dialog.Root
export const DialogTrigger = Dialog.Trigger
export const DialogContent = Dialog.Content
```
**Migrating a component:**
```bash
# Let the CLI rewrite imports when upgrading a component
npx shadcn@latest add dialog --overwrite
npm uninstall @radix-ui/react-dialog
```
**Notes:**
- The unified package is a re-export of the same primitives — behavior, ARIA attributes, and composition APIs (`asChild`) are unchanged.
- Do not blindly remove `@radix-ui/react-*` packages until no checked-in file imports them: `grep -r "@radix-ui" components/` first.
- Base UI-based components (the current default) import from `@base-ui/react` (formerly `@base-ui-components/react`) instead; keep the two primitive sources clearly separated per component.
references/arch-use-asChild-for-custom-triggers.md
---
title: Use the Generated Primitive's Composition API
impact: CRITICAL
impactDescription: avoids nested interactive elements while preserving primitive behavior
tags: arch, radix, base-ui, asChild, render, composition, triggers
---
## Use the Generated Primitive's Composition API
Current shadcn projects can be generated for Radix UI or Base UI. Inspect the checked-in component implementation or project configuration before composing a custom trigger:
- Radix variants commonly use `asChild`.
- Base UI variants commonly use a `render` element prop.
Do not mix the two APIs.
**Incorrect (nested interactive elements):**
```tsx
<DropdownMenuTrigger>
<Button variant="ghost">Account</Button>
</DropdownMenuTrigger>
```
If both components render `<button>`, this creates invalid nested interactive content.
**Correct (use the generated primitive's composition API):**
**Radix-generated component:**
```tsx
<DropdownMenuTrigger asChild>
<Button variant="ghost">Account</Button>
</DropdownMenuTrigger>
```
**Base UI-generated component:**
```tsx
<DropdownMenuTrigger render={<Button variant="ghost" />}>
Account
</DropdownMenuTrigger>
```
The custom component must forward the props and ref required by the underlying primitive. Verify the rendered element, keyboard behavior, accessible name, focus restoration, and event composition after wrapping it.
References:
- [shadcn Radix dropdown menu](https://ui.shadcn.com/docs/components/radix/dropdown-menu)
- [shadcn Base UI dropdown menu](https://ui.shadcn.com/docs/components/base/dropdown-menu)
references/arch-use-cn-for-class-merging.md
---
title: Use cn() for Safe Class Merging
impact: CRITICAL
impactDescription: prevents Tailwind class conflicts
tags: arch, cn, tailwind-merge, className, utilities
---
## 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):**
```tsx
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):**
```tsx
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](https://ui.shadcn.com/docs/installation/manual)
references/comp-combine-command-with-popover.md
---
title: Combine Command with Popover for Searchable Selects
impact: MEDIUM
impactDescription: reduces selection time by 3-5× for long lists
tags: comp, command, popover, combobox, search
---
## 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):**
```tsx
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):**
```tsx
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](https://ui.shadcn.com/docs/components/combobox)
references/comp-compose-with-compound-components.md
---
title: Compose with Compound Component Patterns
impact: MEDIUM
impactDescription: reduces prop count by 60-80% vs monolithic components
tags: comp, compound-components, composition, api-design, patterns
---
## 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):**
```tsx
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):**
```tsx
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](https://www.patterns.dev/react/compound-pattern/)
references/comp-create-reusable-form-fields.md
---
title: Create Reusable Form Field Components
impact: MEDIUM
impactDescription: reduces boilerplate and ensures consistency
tags: comp, form, field, reusable, composition
---
## 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):**
```tsx
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):**
```tsx
// 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](https://react-hook-form.com/ts)
references/comp-nest-dialogs-correctly.md
---
title: Nest Dialogs with Proper Focus Management
impact: MEDIUM
impactDescription: maintains focus trap hierarchy in nested modals
tags: comp, dialog, nesting, focus, modals
---
## 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):**
```tsx
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):**
```tsx
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}):**
```tsx
<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](https://ui.shadcn.com/docs/components/alert-dialog)
references/comp-use-drawer-for-mobile-modals.md
---
title: Use Drawer for Mobile Modal Interactions
impact: MEDIUM
impactDescription: reduces touch distance by 40-60% on mobile
tags: comp, drawer, mobile, responsive, modal
---
## 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):**
```tsx
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):**
```tsx
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:**
```tsx
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](https://ui.shadcn.com/docs/components/drawer)
references/comp-use-slot-pattern-for-flexibility.md
---
title: Use Slot Pattern for Flexible Content Areas
impact: MEDIUM
impactDescription: enables custom content injection without prop explosion
tags: comp, slot, composition, flexibility, children
---
## 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):**
```tsx
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):**
```tsx
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](https://react.dev/learn/passing-props-to-a-component)
references/data-empty-states-with-guidance.md
---
title: Provide Actionable Empty States
impact: MEDIUM-HIGH
impactDescription: increases user action rate by 2-4×
tags: data, empty-state, ux, guidance, onboarding
---
## 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):**
```tsx
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):**
```tsx
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](https://www.nngroup.com/articles/empty-state-interface-design/)
references/data-paginate-server-side.md
---
title: Paginate Large Datasets Server-Side
impact: MEDIUM-HIGH
impactDescription: reduces initial payload for large datasets
tags: data, pagination, server-side, api, performance
---
## Paginate Large Datasets Server-Side
Use server-side pagination when loading and rendering the full dataset would exceed the product's payload, latency, memory, or freshness budget. Small bounded datasets can remain client-side; do not use an arbitrary item-count threshold.
**Incorrect (client-side pagination):**
```tsx
function ProductTable() {
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((response) => response.json()),
})
// Fetches the full collection 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):**
```tsx
import { keepPreviousData, useQuery } from '@tanstack/react-query'
function ProductTable() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const { data } = useQuery({
queryKey: ['products', pagination],
queryFn: async () => {
const response = await fetch(
`/api/products?page=${pagination.pageIndex}&limit=${pagination.pageSize}`
)
if (!response.ok) throw new Error('Failed to load products')
return response.json()
},
placeholderData: keepPreviousData,
})
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](https://tanstack.com/table/v8/docs/guide/pagination)
references/data-use-skeleton-loading-states.md
---
title: Use Skeleton Components for Loading States
impact: MEDIUM-HIGH
impactDescription: reduces perceived load time and prevents layout shift
tags: data, skeleton, loading, ux, suspense
---
## 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):**
```tsx
function UserProfile({ userId }: { userId: string }) {
const { data: user, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
if (isPending) {
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):**
```tsx
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, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
if (isPending) {
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](https://ui.shadcn.com/docs/components/skeleton)
references/data-use-tanstack-table-for-complex-tables.md
---
title: Use TanStack Table for Complex Data Tables
impact: MEDIUM-HIGH
impactDescription: eliminates 200-500 lines of manual table logic
tags: data, tanstack-table, sorting, filtering, pagination
---
## 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):**
```tsx
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):**
```tsx
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](https://ui.shadcn.com/docs/components/data-table)
references/data-virtualize-large-lists.md
---
title: Virtualize Large Lists and Tables
impact: MEDIUM-HIGH
impactDescription: 10-100× rendering performance for large lists
tags: data, virtualization, tanstack-virtual, performance, lists
---
## 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):**
```tsx
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):**
```tsx
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](https://tanstack.com/virtual/latest)
references/form-handle-async-validation.md
---
title: Handle Async Validation with Debouncing
impact: HIGH
impactDescription: prevents excessive API calls during validation
tags: form, async, validation, debounce, api
---
## 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):**
```tsx
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):**
```tsx
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](https://github.com/xnimorz/use-debounce)
references/form-reset-form-state-correctly.md
---
title: Reset Form State Correctly After Submission
impact: HIGH
impactDescription: prevents stale data and submission errors
tags: form, reset, state, react-hook-form, submission
---
## 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):**
```tsx
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):**
```tsx
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 defaultValues
- `form.reset(newValues)` - Reset to specific values
- `form.resetField("email")` - Reset single field
- `form.clearErrors()` - Clear errors without resetting values
**For edit forms (reset to fetched data):**
```tsx
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
})
const { reset } = form
useEffect(() => {
if (user) reset(user)
}, [user, reset])
```
Reference: [React Hook Form reset](https://react-hook-form.com/docs/useform/reset)
references/form-show-validation-errors-correctly.md
---
title: Show Validation Errors at Appropriate Times
impact: HIGH
impactDescription: improves user experience and reduces frustration
tags: form, validation, errors, ux, timing
---
## 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):**
```tsx
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 typing
```
**Correct (errors shown on blur or submit):**
```tsx
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 it
```
**Alternative (validate on submit only):**
```tsx
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 attempt
```
**Validation mode guidelines:**
- `onBlur` - Recommended for most forms
- `onChange` - Only for real-time feedback (passwords)
- `onSubmit` - Short forms or wizards
- `reValidateMode: "onChange"` - Always pair with onBlur for instant feedback during correction
Reference: [React Hook Form Validation](https://react-hook-form.com/docs/useform#mode)
references/form-use-react-hook-form-integration.md
---
title: Use React Hook Form with shadcn/ui Forms
impact: HIGH
impactDescription: eliminates re-renders and provides validation
tags: form, react-hook-form, validation, performance, integration
---
## Use React Hook Form with shadcn/ui Forms
shadcn/ui provides a React Hook Form integration, but current shadcn projects may also choose other form libraries. When a project uses React Hook Form, wire shadcn fields through its documented `FormField`/`Controller` contract instead of duplicating form state in local `useState`.
**Incorrect (controlled state causes re-renders):**
```tsx
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):**
```tsx
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.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, typed data. Subscription scope determines re-render behavior.
}
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](https://ui.shadcn.com/docs/components/form)
references/form-use-zod-for-schema-validation.md
---
title: Use Zod for Schema Validation
impact: HIGH
impactDescription: eliminates runtime type errors with full TS inference
tags: form, zod, validation, typescript, schema
---
## 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):**
```tsx
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):**
```tsx
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
const registrationSchema = z.object({
// Zod 4: use top-level z.email()/z.url() — the chained
// z.string().email()/.url() format methods are deprecated.
email: z.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.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](https://zod.dev/)
references/perf-avoid-unnecessary-rerenders-in-forms.md
---
title: Avoid Unnecessary Re-renders in Forms
impact: MEDIUM
impactDescription: prevents full form re-render on every keystroke
tags: perf, forms, re-renders, react-hook-form, isolation
---
## 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):**
```tsx
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):**
```tsx
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):**
```tsx
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 `useWatch` in isolated child components
- Watch specific field names, not entire form
- Use `useFormState` for submission/validation state
- Use `useController` for complex controlled components
Reference: [React Hook Form useWatch](https://react-hook-form.com/docs/usewatch)
references/perf-debounce-search-inputs.md
---
title: Debounce Search and Filter Inputs
impact: MEDIUM
impactDescription: reduces API calls during typing
tags: perf, debounce, search, filtering, api
---
## Debounce Search and Filter Inputs
Debounce server-bound or expensive search work when intermediate keystrokes are not useful. Do not debounce local filtering by default, and abort or ignore stale requests so older results cannot overwrite newer input.
**Incorrect (API call on every keystroke):**
```tsx
function SearchUsers() {
const [query, setQuery] = useState("")
const { data, isLoading } = useQuery({
queryKey: ["users", query],
queryFn: () => searchUsers(query),
enabled: query.length > 0,
})
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isLoading && <Skeleton className="h-20" />}
{/* Intermediate queries may replace each other while typing. */}
</div>
)
}
```
**Correct (debounced search):**
```tsx
import { useDebouncedValue } from "@/hooks/use-debounced-value"
function SearchUsers() {
const [query, setQuery] = useState("")
const debouncedQuery = useDebouncedValue(query, 300)
const { data, isLoading } = useQuery({
queryKey: ["users", debouncedQuery],
queryFn: ({ signal }) => searchUsers(debouncedQuery, { signal }),
enabled: debouncedQuery.length > 0,
})
return (
<div className="space-y-4">
<Input
placeholder="Search users..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isLoading && <Skeleton className="h-20" />}
{/* Requests receive TanStack Query's AbortSignal. */}
</div>
)
}
```
**useDebouncedValue hook:**
```tsx
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
}
```
Choose the delay from measured backend cost, product latency goals, and user behavior. Clearing, Enter submission, and explicit selections commonly need immediate handling.
References:
- [TanStack Query cancellation](https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation)
- [React: you might not need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
references/perf-lazy-load-heavy-components.md
---
title: Lazy Load Heavy Components
impact: MEDIUM
impactDescription: reduces initial bundle by 30-50%
tags: perf, lazy-loading, dynamic-import, code-splitting, bundle
---
## 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):**
```tsx
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):**
```tsx
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:**
```tsx
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](https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading)
references/perf-memoize-expensive-renders.md
---
title: Memoize Expensive Component Renders
impact: MEDIUM
impactDescription: prevents unnecessary re-renders in lists and data displays
tags: perf, memo, useMemo, useCallback, re-renders
---
## 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):**
```tsx
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):**
```tsx
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 `memo` for list items rendered 10+ times
- Use `useCallback` for handlers passed to memoized children
- Use `useMemo` for expensive computations
- Don't memoize everything - measure first
Reference: [React memo](https://react.dev/reference/react/memo)
references/perf-optimize-icon-imports.md
---
title: Optimize Icon Imports from Lucide
impact: MEDIUM
impactDescription: reduces bundle by 200-500KB with direct imports
tags: perf, icons, lucide, tree-shaking, imports
---
## 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):**
```tsx
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 icons
```
**Correct (direct imports):**
```tsx
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):**
```js
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ["lucide-react"],
},
}
```
```tsx
// 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 time
```
**Creating an icon wrapper for consistency:**
```tsx
// 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 naming
```
Reference: [Vercel Package Import Optimization](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
references/state-colocate-state-with-components.md
---
title: Colocate State with the Components That Use It
impact: LOW-MEDIUM
impactDescription: improves code organization and reduces unnecessary coupling
tags: state, colocation, organization, local-state, encapsulation
---
## Colocate State with the Components That Use It
Keep state as close as possible to where it's used. Don't put all state in a global store or lift it unnecessarily.
**Incorrect (all state in global store):**
```tsx
// store.ts
interface GlobalState {
user: User | null
theme: "light" | "dark"
sidebarOpen: boolean
accordionOpenItems: string[]
selectedTabIndex: number
searchQuery: string
filterOptions: FilterOptions
// UI state mixed with app state
}
function Sidebar() {
const { sidebarOpen, accordionOpenItems, setSidebarOpen, setAccordionOpenItems } = useGlobalStore()
// Component depends on global store for purely local UI state
}
```
**Correct (colocated local state):**
```tsx
function Sidebar() {
// Local UI state - only this component cares
const [accordionOpen, setAccordionOpen] = useState<string[]>(["nav"])
return (
<Accordion type="multiple" value={accordionOpen} onValueChange={setAccordionOpen}>
<AccordionItem value="nav">
<AccordionTrigger>Navigation</AccordionTrigger>
<AccordionContent>
<NavLinks />
</AccordionContent>
</AccordionItem>
<AccordionItem value="settings">
<AccordionTrigger>Settings</AccordionTrigger>
<AccordionContent>
<SettingsLinks />
</AccordionContent>
</AccordionItem>
</Accordion>
)
}
// Global store only for truly shared state
interface GlobalState {
user: User | null
theme: "light" | "dark"
// Only app-level state that multiple components need
}
```
**State location decision tree:**
```tsx
// 1. Only used in one component? → useState in that component
function SearchInput() {
const [query, setQuery] = useState("")
// ...
}
// 2. Shared by siblings? → Lift to parent
function ProductPage() {
const [selectedVariant, setSelectedVariant] = useState("default")
return (
<>
<VariantSelector value={selectedVariant} onChange={setSelectedVariant} />
<PriceDisplay variant={selectedVariant} />
</>
)
}
// 3. Needed across distant components? → Context or global store
const CartContext = createContext<CartState | null>(null)
function CartProvider({ children }) {
const [items, setItems] = useState<CartItem[]>([])
// Cart used by Header, ProductPage, Checkout, etc.
}
// 4. Server state (fetched data)? → React Query/SWR
function UserProfile() {
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
// Server state managed separately from UI state
}
```
Reference: [Kent C. Dodds - Colocation](https://kentcdodds.com/blog/colocation)
references/state-lift-state-to-appropriate-level.md
---
title: Lift State to the Appropriate Level
impact: LOW-MEDIUM
impactDescription: prevents prop drilling and enables component communication
tags: state, lifting, props, context, composition
---
## Lift State to the Appropriate Level
Lift shared state to the lowest common ancestor of components that need it. Don't lift higher than necessary (causes unnecessary re-renders) or leave too low (causes prop drilling).
**Incorrect (state too low, prop drilling):**
```tsx
function ProductPage() {
return (
<div className="grid grid-cols-2 gap-8">
<ProductGallery />
<ProductDetails />
</div>
)
}
function ProductGallery() {
const [selectedImage, setSelectedImage] = useState(0)
// selectedImage needed by ProductDetails for zoom feature
// but state is isolated in ProductGallery
}
function ProductDetails() {
// No way to access selectedImage without prop drilling
}
```
**Correct (state at common ancestor):**
```tsx
function ProductPage() {
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
return (
<div className="grid grid-cols-2 gap-8">
<ProductGallery
selectedIndex={selectedImageIndex}
onSelectImage={setSelectedImageIndex}
/>
<ProductDetails selectedImageIndex={selectedImageIndex} />
</div>
)
}
function ProductGallery({
selectedIndex,
onSelectImage,
}: {
selectedIndex: number
onSelectImage: (index: number) => void
}) {
return (
<div className="space-y-4">
<div className="aspect-square">
<img src={images[selectedIndex].src} alt="" />
</div>
<div className="flex gap-2">
{images.map((image, index) => (
<Button
key={image.id}
variant={index === selectedIndex ? "default" : "outline"}
onClick={() => onSelectImage(index)}
>
<img src={image.thumbnail} alt="" className="h-12 w-12" />
</Button>
))}
</div>
</div>
)
}
```
**When to use Context instead:**
```tsx
// When prop drilling goes beyond 2-3 levels
const SelectedImageContext = createContext<{
selectedIndex: number
setSelectedIndex: (index: number) => void
} | null>(null)
function ProductPage() {
const [selectedIndex, setSelectedIndex] = useState(0)
return (
<SelectedImageContext.Provider value={{ selectedIndex, setSelectedIndex }}>
<div className="grid grid-cols-2 gap-8">
<ProductGallery />
<ProductDetails />
</div>
</SelectedImageContext.Provider>
)
}
```
Reference: [Lifting State Up](https://react.dev/learn/sharing-state-between-components)
references/state-prefer-uncontrolled-for-simple-inputs.md
---
title: Prefer Uncontrolled Components for Simple Inputs
impact: LOW-MEDIUM
impactDescription: reduces state management overhead for simple cases
tags: state, uncontrolled, controlled, forms, simplicity
---
## Prefer Uncontrolled Components for Simple Inputs
For inputs that don't need real-time value access (search forms, quick filters), use uncontrolled components with refs or form data to reduce state overhead.
**Incorrect (controlled state for simple search):**
```tsx
function SimpleSearch({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState("")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSearch(query)
}
return (
<form onSubmit={handleSubmit}>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{/* Re-renders component on every keystroke */}
<Button type="submit">Search</Button>
</form>
)
}
```
**Correct (uncontrolled with form data):**
```tsx
function SimpleSearch({ onSearch }: { onSearch: (query: string) => void }) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const query = formData.get("query") as string
onSearch(query)
}
return (
<form onSubmit={handleSubmit}>
<Input name="query" placeholder="Search..." />
{/* No re-renders during typing */}
<Button type="submit">Search</Button>
</form>
)
}
```
**Alternative (useRef for imperative access):**
```tsx
function QuickFilter({ onFilter }: { onFilter: (value: string) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
onFilter(inputRef.current?.value ?? "")
}
}
return (
<Input
ref={inputRef}
placeholder="Filter..."
onKeyDown={handleKeyDown}
/>
)
}
```
**Use controlled state when:**
- You need real-time validation feedback
- The value is used elsewhere in the UI (character count, preview)
- You need to programmatically change the value
- Using React Hook Form (which handles this efficiently)
**Use uncontrolled when:**
- Value only needed on submit
- Simple forms with no real-time requirements
- Performance is critical in large forms
Reference: [React Uncontrolled Components](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components)
references/state-use-controlled-dialog-state.md
---
title: Use Controlled State for Dialogs Triggered Externally
impact: LOW-MEDIUM
impactDescription: enables programmatic dialog control from parent components
tags: state, controlled, dialog, modal, programmatic
---
## Use Controlled State for Dialogs Triggered Externally
When dialogs need to be opened from parent components or through programmatic events, use controlled state (`open`/`onOpenChange` props) instead of relying on the internal trigger.
**Incorrect (uncontrolled dialog, hard to open programmatically):**
```tsx
function UserRow({ user }: { user: User }) {
return (
<TableRow>
<TableCell>{user.name}</TableCell>
<TableCell>
<Dialog>
<DialogTrigger asChild>
<Button size="sm">Edit</Button>
</DialogTrigger>
<DialogContent>
<EditUserForm user={user} />
</DialogContent>
</Dialog>
{/* Cannot open this dialog from parent table component */}
</TableCell>
</TableRow>
)
}
function UsersTable() {
const handleBulkEdit = () => {
// No way to programmatically open edit dialog for selected users
}
}
```
**Correct (controlled dialog state):**
```tsx
function UserRow({
user,
editOpen,
onEditOpenChange,
}: {
user: User
editOpen: boolean
onEditOpenChange: (open: boolean) => void
}) {
return (
<TableRow>
<TableCell>{user.name}</TableCell>
<TableCell>
<Button size="sm" onClick={() => onEditOpenChange(true)}>
Edit
</Button>
<Dialog open={editOpen} onOpenChange={onEditOpenChange}>
<DialogContent>
<EditUserForm user={user} onSuccess={() => onEditOpenChange(false)} />
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
)
}
function UsersTable({ users }: { users: User[] }) {
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const handleBulkEdit = (userId: string) => {
setEditingUserId(userId) // Programmatically open dialog
}
return (
<Table>
<TableBody>
{users.map((user) => (
<UserRow
key={user.id}
user={user}
editOpen={editingUserId === user.id}
onEditOpenChange={(open) => setEditingUserId(open ? user.id : null)}
/>
))}
</TableBody>
</Table>
)
}
```
**Alternative (dialog state in parent, dialog content separate):**
```tsx
function UsersTable({ users }: { users: User[] }) {
const [editingUser, setEditingUser] = useState<User | null>(null)
return (
<>
<Table>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>
<Button size="sm" onClick={() => setEditingUser(user)}>
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={!!editingUser} onOpenChange={(open) => !open && setEditingUser(null)}>
<DialogContent>
{editingUser && <EditUserForm user={editingUser} />}
</DialogContent>
</Dialog>
</>
)
}
```
Reference: [Radix Dialog Controlled](https://www.radix-ui.com/primitives/docs/components/dialog#api-reference)
references/style-avoid-important-overrides.md
---
title: Prefer Variant and Class Composition Over Important Overrides
impact: HIGH
impactDescription: preserves predictable customization while allowing documented escape hatches
tags: style, important, specificity, tailwind-v4, overrides
---
## Prefer Variant and Class Composition Over Important Overrides
Prefer component variants, CSS variables, and `cn()`/`tailwind-merge` ordering over `!important`. Tailwind v4 supports the important modifier, but it should be a targeted escape hatch for specificity you cannot otherwise control, not the default component API.
**Incorrect (locking every consumer out):**
```tsx
function BrandButton({ children }: { children: React.ReactNode }) {
return (
<Button className="bg-brand! text-white! hover:bg-brand/90!">
{children}
</Button>
)
}
```
**Correct (composable defaults):**
```tsx
import { cn } from '@/lib/utils'
function BrandButton({
className,
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<Button
className={cn(
'bg-brand text-brand-foreground hover:bg-brand/90',
className
)}
>
{children}
</Button>
)
}
```
When an important override is necessary, document why and use Tailwind v4's trailing syntax (`bg-red-500!`). The old leading syntax (`!bg-red-500`) is deprecated.
If a class does not win as expected:
1. Confirm the component forwards `className` and places it last in `cn()`.
2. Confirm the conflicting utilities are understood by the installed `tailwind-merge` version.
3. Prefer a component variant or semantic token over increasing specificity.
4. Inspect portal/content elements separately; the class may be applied to the wrong primitive.
Reference: [Tailwind CSS important modifier](https://tailwindcss.com/docs/styling-with-utility-classes#using-the-important-modifier)
references/style-consistent-spacing-scale.md
---
title: Use Consistent Spacing Scale
impact: HIGH
impactDescription: creates visual rhythm and reduces design inconsistency
tags: style, spacing, tailwind, consistency, layout
---
## Use Consistent Spacing Scale
Use Tailwind's spacing scale consistently rather than mixing arbitrary values. Consistent spacing creates visual rhythm and professional appearance.
**Incorrect (inconsistent spacing):**
```tsx
function ProfileCard({ user }: { user: User }) {
return (
<Card className="p-5">
<CardHeader className="pb-3">
<div className="flex gap-[14px] items-center">
{/* Mixing scales: p-5, pb-3, gap-[14px], mt-[10px] */}
<Avatar className="h-12 w-12" />
<div>
<CardTitle className="mb-[6px]">{user.name}</CardTitle>
<p className="text-muted-foreground mt-[10px]">{user.email}</p>
</div>
</div>
</CardHeader>
</Card>
)
}
```
**Correct (consistent Tailwind spacing):**
```tsx
function ProfileCard({ user }: { user: User }) {
return (
<Card className="p-6">
<CardHeader className="pb-4">
<div className="flex gap-4 items-center">
{/* Consistent scale: p-6, pb-4, gap-4, space-y-1 */}
<Avatar className="h-12 w-12" />
<div className="space-y-1">
<CardTitle>{user.name}</CardTitle>
<p className="text-muted-foreground">{user.email}</p>
</div>
</div>
</CardHeader>
</Card>
)
}
```
**Tailwind spacing scale reference:**
- `1` = 0.25rem (4px)
- `2` = 0.5rem (8px)
- `4` = 1rem (16px)
- `6` = 1.5rem (24px)
- `8` = 2rem (32px)
**Guidelines:**
- Component padding: `p-4` or `p-6`
- Element gaps: `gap-2`, `gap-4`, or `gap-6`
- Section margins: `mt-8`, `mb-12`
- Use `space-y-*` and `space-x-*` for consistent child spacing
Reference: [Tailwind Spacing](https://tailwindcss.com/docs/customizing-spacing)
references/style-dark-mode-support.md
---
title: Support Dark Mode with CSS Variables
impact: HIGH
impactDescription: provides user preference compliance and reduces eye strain
tags: style, dark-mode, theming, css-variables, accessibility
---
## Support Dark Mode with CSS Variables
Use the shadcn/ui dark mode pattern with CSS variables. Define both light and dark values; components automatically adapt.
**Incorrect (hardcoded mode-specific styles):**
```tsx
function NotificationCard({ message }: { message: string }) {
return (
<Card className="bg-white text-gray-900 border-gray-200">
{/* No dark mode support - harsh white in dark environments */}
<CardContent className="p-4">
<p className="text-gray-600">{message}</p>
</CardContent>
</Card>
)
}
```
**Correct (CSS variables with dark mode support):**
```css
/* globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
}
}
```
```tsx
function NotificationCard({ message }: { message: string }) {
return (
<Card className="bg-card text-card-foreground border-border">
{/* Automatically adapts to light/dark mode */}
<CardContent className="p-4">
<p className="text-muted-foreground">{message}</p>
</CardContent>
</Card>
)
}
```
**Theme toggle implementation:**
```tsx
import { useTheme } from "next-themes"
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button variant="outline" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
<SunIcon className="h-4 w-4 rotate-0 scale-100 dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-4 w-4 rotate-90 scale-0 dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
```
Reference: [shadcn/ui Dark Mode](https://ui.shadcn.com/docs/dark-mode)
references/style-responsive-design-patterns.md
---
title: Apply Mobile-First Responsive Design
impact: HIGH
impactDescription: prevents mobile usability failures on 50%+ of traffic
tags: style, responsive, mobile-first, breakpoints, tailwind
---
## Apply Mobile-First Responsive Design
Build components mobile-first, then add responsive modifiers for larger screens. This matches Tailwind's design philosophy and ensures mobile usability.
**Incorrect (desktop-first, mobile as afterthought):**
```tsx
function DataTable({ data }: { data: TableRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Name</TableHead>
<TableHead className="w-[150px]">Status</TableHead>
<TableHead className="w-[200px]">Email</TableHead>
<TableHead className="text-right w-[100px]">Amount</TableHead>
{/* All columns visible - table overflows on mobile */}
</TableRow>
</TableHeader>
{/* ... */}
</Table>
)
}
```
**Correct (mobile-first with progressive enhancement):**
```tsx
function DataTable({ data }: { data: TableRow[] }) {
return (
<div className="w-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">Name</TableHead>
<TableHead className="hidden sm:table-cell">Status</TableHead>
<TableHead className="hidden md:table-cell">Email</TableHead>
<TableHead className="text-right">Amount</TableHead>
{/* Secondary columns hidden on mobile, revealed at breakpoints */}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant={row.status === "active" ? "default" : "secondary"}>
{row.status}
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{row.email}</TableCell>
<TableCell className="text-right">${row.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
```
**Responsive patterns:**
- `hidden sm:block` - Hidden on mobile, visible at 640px+
- `flex-col md:flex-row` - Stack on mobile, row on desktop
- `grid-cols-1 lg:grid-cols-3` - Single column to multi-column
- `text-sm md:text-base` - Smaller text on mobile
Reference: [Tailwind Responsive Design](https://tailwindcss.com/docs/responsive-design)
references/style-use-css-variables-for-theming.md
---
title: Use CSS Variables for Theme Colors
impact: HIGH
impactDescription: enables runtime theme switching and consistency
tags: style, css-variables, theming, dark-mode, colors
---
## Use CSS Variables for Theme Colors
Reference theme colors via CSS variables (--primary, --background, etc.) rather than hardcoded Tailwind colors. This enables theme switching and maintains design consistency.
**Incorrect (hardcoded colors break theming):**
```tsx
function StatusCard({ status }: { status: "active" | "inactive" }) {
return (
<Card className="bg-white dark:bg-gray-800 border-gray-200">
<CardHeader>
<CardTitle className={status === "active" ? "text-green-600" : "text-gray-500"}>
{/* Hardcoded colors don't adapt to theme changes */}
Status: {status}
</CardTitle>
</CardHeader>
</Card>
)
}
```
**Correct (CSS variables adapt to theme):**
```tsx
function StatusCard({ status }: { status: "active" | "inactive" }) {
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle
className={status === "active" ? "text-primary" : "text-muted-foreground"}
>
{/* Colors automatically update with theme */}
Status: {status}
</CardTitle>
</CardHeader>
</Card>
)
}
```
**shadcn/ui CSS variable naming:**
- `bg-background`, `text-foreground` - Base colors
- `bg-card`, `text-card-foreground` - Card surfaces
- `bg-primary`, `text-primary-foreground` - Primary actions
- `bg-muted`, `text-muted-foreground` - Subdued elements
- `bg-destructive`, `text-destructive` - Destructive actions
- `border-border`, `ring-ring` - Borders and focus rings
Reference: [shadcn/ui Theming](https://ui.shadcn.com/docs/theming)
references/style-use-tailwind-theme-extend.md
---
title: Define Tailwind v4 Theme Tokens in CSS
impact: HIGH
impactDescription: keeps shadcn and Tailwind tokens reusable and consistent with the CSS-first configuration model
tags: style, tailwind-v4, theme, design-tokens, configuration
---
## Define Tailwind v4 Theme Tokens in CSS
For Tailwind CSS v4 projects, define reusable design tokens with `@theme` or map existing shadcn CSS variables with `@theme inline`. Do not present `theme.extend` in `tailwind.config.js` as the default current setup; JavaScript config is compatibility-only in v4 and is not auto-detected.
**Incorrect (assuming v4 auto-detects a JavaScript config):**
```javascript
// tailwind.config.js
export default {
theme: {
extend: { colors: { brand: '#2563eb' } },
},
}
```
**Correct (CSS-first theme tokens):**
```css
/* app.css */
@import "tailwindcss";
:root {
--brand-surface: oklch(0.35 0.08 255);
--brand-foreground: oklch(0.95 0.02 255);
}
.dark {
--brand-surface: oklch(0.24 0.06 255);
--brand-foreground: oklch(0.97 0.01 255);
}
@theme inline {
--color-brand: var(--brand-surface);
--color-brand-foreground: var(--brand-foreground);
}
```
```tsx
function BrandedCard() {
return (
<Card className="bg-brand text-brand-foreground">
<CardHeader>
<CardTitle>Dashboard</CardTitle>
</CardHeader>
</Card>
)
}
```
Use semantic names when a token represents a role (`background`, `destructive`, `brand`) and scale names when consumers genuinely choose tonal steps. Arbitrary values remain appropriate for one-off values that are not design-system tokens.
If a migrated project must retain a JavaScript config, load it explicitly with `@config`; do not mix two sources of truth without a migration reason.
References:
- [Tailwind CSS theme variables](https://tailwindcss.com/docs/theme)
- [Tailwind CSS v4 upgrade guide](https://tailwindcss.com/docs/upgrade-guide#using-a-javascript-config-file)
SKILL.md
---
name: shadcn
description: shadcn/ui components on Radix or Base UI.
---
# shadcn/ui Community Best Practices
Current best-practices guide for shadcn/ui applications. Before applying primitive-specific guidance, inspect whether the checked-in components use Radix UI or Base UI; current shadcn supports both (Base UI is the default for new projects) and their composition APIs differ. Component installs and upgrades go through the current CLI (v4), which resolves namespaced registries.
## When to Apply
Reference these guidelines when:
- 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 | Component Architecture | CRITICAL | `arch-` |
| 2 | Accessibility Preservation | CRITICAL | `ally-` |
| 3 | Styling & Theming | HIGH | `style-` |
| 4 | Form Patterns | HIGH | `form-` |
| 5 | Data Display | MEDIUM-HIGH | `data-` |
| 6 | Component Composition | MEDIUM | `comp-` |
| 7 | Performance Optimization | MEDIUM | `perf-` |
| 8 | State Management | LOW-MEDIUM | `state-` |
## Quick Reference
### 1. Component Architecture (CRITICAL)
- [`arch-use-asChild-for-custom-triggers`](references/arch-use-asChild-for-custom-triggers.md) - Use the generated primitive's composition API
- [`arch-current-cli-registry`](references/arch-current-cli-registry.md) - Use the current CLI for component lifecycle (v4, registries, MCP)
- [`arch-unified-radix-package`](references/arch-unified-radix-package.md) - Import primitives from the unified radix-ui package
- [`arch-preserve-radix-primitive-structure`](references/arch-preserve-radix-primitive-structure.md) - Maintain Radix compound component hierarchy
- [`arch-extend-variants-with-cva`](references/arch-extend-variants-with-cva.md) - Use Class Variance Authority for type-safe variants
- [`arch-use-cn-for-class-merging`](references/arch-use-cn-for-class-merging.md) - Use cn() utility for safe Tailwind class merging
- [`arch-forward-refs-for-composable-components`](references/arch-forward-refs-for-composable-components.md) - Forward refs for form and focus integration
- [`arch-isolate-component-variants`](references/arch-isolate-component-variants.md) - Separate base styles from variant-specific styles
### 2. Accessibility Preservation (CRITICAL)
- [`ally-preserve-aria-attributes`](references/ally-preserve-aria-attributes.md) - Keep Radix ARIA attributes intact
- [`ally-provide-sr-only-labels`](references/ally-provide-sr-only-labels.md) - Add screen reader labels for icon buttons
- [`ally-maintain-focus-management`](references/ally-maintain-focus-management.md) - Preserve focus trapping in modals
- [`ally-preserve-keyboard-navigation`](references/ally-preserve-keyboard-navigation.md) - Keep WAI-ARIA keyboard patterns
- [`ally-ensure-color-contrast`](references/ally-ensure-color-contrast.md) - Maintain WCAG color contrast ratios
### 3. Styling & Theming (HIGH)
- [`style-use-css-variables-for-theming`](references/style-use-css-variables-for-theming.md) - Use CSS variables for theme colors
- [`style-avoid-important-overrides`](references/style-avoid-important-overrides.md) - Prefer variants and class composition over important overrides
- [`style-use-tailwind-theme-extend`](references/style-use-tailwind-theme-extend.md) - Define Tailwind v4 theme tokens in CSS
- [`style-consistent-spacing-scale`](references/style-consistent-spacing-scale.md) - Use consistent Tailwind spacing scale
- [`style-responsive-design-patterns`](references/style-responsive-design-patterns.md) - Apply mobile-first responsive design
- [`style-dark-mode-support`](references/style-dark-mode-support.md) - Support dark mode with CSS variables
### 4. Form Patterns (HIGH)
- [`form-use-react-hook-form-integration`](references/form-use-react-hook-form-integration.md) - Integrate with React Hook Form
- [`form-use-zod-for-schema-validation`](references/form-use-zod-for-schema-validation.md) - Use Zod for type-safe validation
- [`form-show-validation-errors-correctly`](references/form-show-validation-errors-correctly.md) - Show errors at appropriate times
- [`form-handle-async-validation`](references/form-handle-async-validation.md) - Debounce async validation calls
- [`form-reset-form-state-correctly`](references/form-reset-form-state-correctly.md) - Reset form state after submission
### 5. Data Display (MEDIUM-HIGH)
- [`data-use-tanstack-table-for-complex-tables`](references/data-use-tanstack-table-for-complex-tables.md) - Use TanStack Table for sorting/filtering
- [`data-virtualize-large-lists`](references/data-virtualize-large-lists.md) - Virtualize lists with 100+ items
- [`data-use-skeleton-loading-states`](references/data-use-skeleton-loading-states.md) - Use Skeleton for loading states
- [`data-paginate-server-side`](references/data-paginate-server-side.md) - Paginate large datasets server-side
- [`data-empty-states-with-guidance`](references/data-empty-states-with-guidance.md) - Provide actionable empty states
### 6. Component Composition (MEDIUM)
- [`comp-compose-with-compound-components`](references/comp-compose-with-compound-components.md) - Use compound component patterns
- [`comp-use-drawer-for-mobile-modals`](references/comp-use-drawer-for-mobile-modals.md) - Use Drawer on mobile devices
- [`comp-combine-command-with-popover`](references/comp-combine-command-with-popover.md) - Create searchable selects with Command
- [`comp-nest-dialogs-correctly`](references/comp-nest-dialogs-correctly.md) - Manage nested dialog focus correctly
- [`comp-create-reusable-form-fields`](references/comp-create-reusable-form-fields.md) - Extract reusable form field components
- [`comp-use-slot-pattern-for-flexibility`](references/comp-use-slot-pattern-for-flexibility.md) - Use slot pattern for flexible content
### 7. Performance Optimization (MEDIUM)
- [`perf-lazy-load-heavy-components`](references/perf-lazy-load-heavy-components.md) - Lazy load components over 50KB
- [`perf-memoize-expensive-renders`](references/perf-memoize-expensive-renders.md) - Memoize list items and expensive components
- [`perf-optimize-icon-imports`](references/perf-optimize-icon-imports.md) - Use direct imports for Lucide icons
- [`perf-avoid-unnecessary-rerenders-in-forms`](references/perf-avoid-unnecessary-rerenders-in-forms.md) - Isolate form field watching
- [`perf-debounce-search-inputs`](references/perf-debounce-search-inputs.md) - Debounce search and filter inputs
### 8. State Management (LOW-MEDIUM)
- [`state-prefer-uncontrolled-for-simple-inputs`](references/state-prefer-uncontrolled-for-simple-inputs.md) - Use uncontrolled for simple forms
- [`state-lift-state-to-appropriate-level`](references/state-lift-state-to-appropriate-level.md) - Lift state to lowest common ancestor
- [`state-use-controlled-dialog-state`](references/state-use-controlled-dialog-state.md) - Control dialogs for programmatic access
- [`state-colocate-state-with-components`](references/state-colocate-state-with-components.md) - Keep state close to where it's used
## How to Use
Run in the project root first:
```bash
npx -y @pymodel/react-frontend-skills detect # installed vs. targeted versions
```
Read `references/<rule-id>.md` for each rule above that matches the task: why it matters, then incorrect vs. correct code.