18 KiB
Component Implementation Patterns
Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.
1. shadcn/ui Setup
npx shadcn@latest init
npx shadcn@latest add button input form card dialog select sheet toast
Key concepts:
- Not an npm package -- components are copied into your project. You own the code and can modify it freely.
- Built on Radix UI primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
- Styled with Tailwind CSS utilities -- no CSS-in-JS runtime.
- Required dependencies:
class-variance-authority(CVA) -- variant managementclsx-- conditional class joiningtailwind-merge-- deduplicates conflicting Tailwind classeslucide-react-- icon librarytailwindcss-animate-- animation utilities
The cn() utility combines clsx and tailwind-merge:
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
2. CSS Variables for Theming (HSL Format)
shadcn uses HSL values without the hsl() wrapper so Tailwind can apply opacity modifiers:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* ... remaining dark overrides */
}
}
Usage in tailwind.config.ts:
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// ...
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
}
3. Button Patterns
Use CVA to define variants declaratively:
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
Design rules:
- Press feedback:
active:scale-[0.97]gives tactile response without layout shift. - Focus ring: Always visible via
focus-visible:ring-2. Never useoutline: nonewithout a replacement. - Loading state: Disable the button and show a spinner inline.
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
)
)
4. Form Patterns (React Hook Form + Zod)
Schema-first validation keeps validation logic co-located and type-safe:
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(2).max(50),
})
type FormValues = z.infer<typeof formSchema>
The shadcn Form components wire React Hook Form to accessible markup:
function SignUpForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: "", password: "", name: "" },
mode: "onBlur", // validate on blur, not keystroke
})
function onSubmit(values: FormValues) {
// handle submission
}
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="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ...more fields */}
<Button type="submit" isLoading={form.formState.isSubmitting}>
Sign Up
</Button>
</form>
</Form>
)
}
Accessibility rules:
FormMessagerenders error text witharia-describedbylinked to the input.- Inputs get
aria-invalid="true"when in error state automatically. - Mark required fields with
aria-required="true". - Validate on blur, not on every keystroke -- reduces noise and respects user flow.
- Use progressive disclosure for complex forms: show additional fields only when relevant.
5. Card Patterns
import {
Card, CardHeader, CardTitle, CardDescription,
CardContent, CardFooter,
} from "@/components/ui/card"
<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
<CardHeader>
<CardTitle>Project Settings</CardTitle>
<CardDescription>Manage your project configuration.</CardDescription>
</CardHeader>
<CardContent>
{/* form fields or content */}
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</CardFooter>
</Card>
Design rules:
- Concentric border radius: Outer radius = inner radius + padding. If inner elements have
rounded-md(6px) and padding is 16px, outer card should berounded-xl(12px) or greater. - Layered shadows: Use multiple shadow values for natural depth --
shadow-smat rest,shadow-lgon hover. - Hover lift: Subtle
translateY(-2px)on hover, never more than 4px. - Use semantic color tokens (
bg-card,text-card-foreground) so cards adapt to theme changes.
6. Dialog (Modal) Patterns
import {
Dialog, DialogTrigger, DialogContent,
DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogClose,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Make changes to your profile here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* form content */}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Accessibility and interaction rules (handled by Radix):
- Focus trap: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
- ESC to close: Always. No exceptions.
- Click outside overlay: Closes the dialog by default.
aria-modal="true"is set automatically.aria-labelledbypoints toDialogTitle,aria-describedbypoints toDialogDescription.- Restore focus: When dialog closes, focus returns to the trigger element.
- Animation origin:
transform-origin: center-- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.
7. Select/Dropdown Patterns
import {
Select, SelectTrigger, SelectValue,
SelectContent, SelectItem, SelectGroup, SelectLabel,
} from "@/components/ui/select"
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
Interaction rules:
- Keyboard navigation: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
- ARIA:
aria-haspopup="listbox"on trigger,aria-expandedtoggles with open state. - Transform origin: Popover should animate from the trigger position (origin-aware), not from center.
- Tooltip delay skip: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.
8. Sheet (Slide-over) Patterns
import {
Sheet, SheetTrigger, SheetContent,
SheetHeader, SheetTitle, SheetDescription,
SheetFooter, SheetClose,
} from "@/components/ui/sheet"
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open Menu</Button>
</SheetTrigger>
<SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Browse sections of the app.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-2 py-4">
{/* nav links */}
</nav>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
Use cases:
- Mobile navigation: Slide from left with full-height overlay.
- Detail panels: Slide from right to show item details without leaving the list view.
- Filters: Slide from bottom on mobile for filter controls.
Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.
9. Toast/Notification Patterns
Using the shadcn Toast (or Sonner for a lighter API):
// With shadcn toast
import { useToast } from "@/components/ui/use-toast"
function SaveButton() {
const { toast } = useToast()
return (
<Button
onClick={() => {
toast({
title: "Changes saved",
description: "Your settings have been updated.",
})
}}
>
Save
</Button>
)
}
// With Sonner (simpler API)
import { toast } from "sonner"
toast.success("Changes saved")
toast.error("Something went wrong")
toast.promise(saveSettings(), {
loading: "Saving...",
success: "Settings saved",
error: "Could not save",
})
Design and accessibility rules:
- Auto-dismiss: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
aria-live="polite"-- screen readers announce without stealing focus.- CSS transitions, not keyframes -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
- Pause timers when the browser tab is hidden (
document.visibilityState). - Swipe to dismiss: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).
10. Table Patterns
import {
Table, TableHeader, TableBody, TableFooter,
TableHead, TableRow, TableCell, TableCaption,
} from "@/components/ui/table"
<div className="overflow-x-auto rounded-md border">
<Table>
<TableCaption>A list of recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">{invoice.id}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
Rules:
- Responsive: Wrap table in
overflow-x-autocontainer. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns. - Sortable columns: Use
aria-sort="ascending"oraria-sort="descending"on the activeTableHead. Show a visual indicator (chevron icon). - Virtualization: For lists exceeding ~50 items, use
@tanstack/react-virtualor similar to render only visible rows. - Row distinction: Use zebra striping (
even:bg-muted/50) or subtle borders between rows. Never rely on color alone.
11. Chart Integration
When integrating charts (Recharts, Chart.js, or similar):
-
Match chart type to data intent:
- Trend over time: line chart
- Comparison across categories: bar chart
- Part-of-whole: pie/donut chart
- Distribution: histogram
- Correlation: scatter plot
-
Accessible color palettes: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.
-
Always include a legend and provide tooltips on hover/focus for precise values.
-
Screen reader alternative: Provide a visually hidden
<table>with the same data so screen readers can access it. -
Respect
prefers-reduced-motion: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches
<LineChart data={data}>
<Line
type="monotone"
dataKey="value"
animationDuration={prefersReducedMotion ? 0 : 500}
/>
</LineChart>
12. Server Component Wrapping (Next.js)
Most shadcn/ui components use React state or event handlers and require "use client". Structure your components to keep data fetching in server components:
// app/dashboard/page.tsx (Server Component -- no "use client")
import { getProjects } from "@/lib/data"
import { ProjectList } from "./project-list"
export default async function DashboardPage() {
const projects = await getProjects()
return <ProjectList projects={projects} />
}
// app/dashboard/project-list.tsx (Client Component)
"use client"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface ProjectListProps {
projects: { id: string; name: string; status: string }[]
}
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card key={project.id}>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
</CardHeader>
<CardContent>
<p>{project.status}</p>
<Button variant="outline" size="sm">View</Button>
</CardContent>
</Card>
))}
</div>
)
}
The pattern: Server component fetches data, passes to client component as serializable props. This keeps the client bundle small and data fetching on the server.
13. CVA (class-variance-authority) Deep Dive
CVA lets you define component variants declaratively, replacing sprawling conditional class logic:
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
Key patterns:
- Compose with
cn(): Always wrap CVA output withcn()so consumer-passedclassNamecan override defaults viatailwind-merge. - Type extraction:
VariantProps<typeof badgeVariants>generates the TypeScript type for variant props automatically. - Compound variants: Handle combinations of variant values that need special styling:
const inputVariants = cva("...", {
variants: {
size: { sm: "...", lg: "..." },
state: { error: "...", success: "..." },
},
compoundVariants: [
{ size: "sm", state: "error", class: "border-2 border-red-500" },
],
})
- Use CVA for any component with visual variants -- buttons, badges, alerts, inputs, cards. It replaces manual
if/elseclass concatenation with a declarative, type-safe API.