| name | shadcn-ui |
| description | shadcn/ui component patterns for Next.js 16 applications. This skill should be used when adding UI components, customizing component styles, composing primitives, or integrating forms with react-hook-form. Covers installation, customization, composition patterns, and Atlas-specific conventions using Tailwind CSS v4. |
shadcn/ui Component Usage
Purpose
Provide comprehensive patterns for implementing shadcn/ui components in Next.js 16 applications with Atlas-specific conventions. Focus on composition over props, accessibility-first design, and type-safe integration with tRPC and react-hook-form.
When To Use This Skill
Component Implementation:
- Adding new shadcn/ui components to the project
- Customizing component variants and styles
- Building composite components from primitives (Dialog, Form, Table)
- Implementing responsive mobile/desktop patterns
Form Integration:
- Integrating react-hook-form with shadcn Form components
- Validating forms with Zod schemas
- Connecting forms to tRPC mutations
- Handling form state and errors
Data Display:
- Creating data tables with sorting and filtering
- Building card layouts and list views
- Implementing skeleton loading states
Interactive Patterns:
- Building modal dialogs and drawers (sheets)
- Implementing toast notifications
- Creating dropdown menus and popovers
- Adding tooltips and hover states
Accessibility:
- Ensuring keyboard navigation works correctly
- Adding proper ARIA labels (especially icon buttons)
- Implementing focus management
- Meeting WCAG 2.1 AAA standards (44px minimum touch targets)
Core Principles
1. Copy-Not-Import Philosophy
shadcn/ui components are copied into the project, not imported as dependencies. Customize directly in src/components/ui/.
// ✅ Customize directly
// src/components/ui/button.tsx
const buttonVariants = cva("...", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
// Add your custom variant
atlas: "bg-blue-600 text-white hover:bg-blue-700",
},
},
});
2. Composition Over Props
Build complex components by composing primitives rather than adding props:
// ❌ Avoid: Too many props
<Dialog
title="Delete Item"
description="Are you sure?"
showCloseButton={true}
size="lg"
/>
// ✅ Prefer: Composition
<Dialog>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Item</DialogTitle>
<DialogDescription>Are you sure?</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
3. className Customization
Extend styles via className prop using Tailwind utilities:
<Button variant="default" className="w-full sm:w-auto shadow-lg">
Submit
</Button>
4. Accessibility First
All components are accessible by default (built on Radix UI):
- Keyboard navigation
- Screen reader support
- ARIA attributes
- Focus management
Icon buttons require labels for accessibility:
// ❌ Inaccessible
<Button size="icon">
<TrashIcon />
</Button>
// ✅ Accessible
<Button size="icon" aria-label="Delete item">
<TrashIcon />
</Button>
Quick Reference
Common Components
| Component | Use Case | Key Features |
|---|---|---|
Button |
Actions, triggers | Variants, sizes, loading state |
Input |
Text entry | Types, validation states |
Form |
Form validation | react-hook-form integration |
Dialog |
Modals | Portal, overlay, animations |
Sheet |
Side panels | Mobile-friendly drawers |
Table |
Data display | Semantic HTML, responsive |
Select |
Dropdowns | Searchable, keyboard nav |
Checkbox |
Boolean input | Indeterminate state |
Label |
Form labels | Auto-linked to inputs |
Textarea |
Multi-line input | Auto-resize support |
Tabs |
Navigation | Keyboard accessible |
Card |
Content containers | Header, content, footer |
Badge |
Status labels | Variants for states |
Skeleton |
Loading states | Placeholder UI |
Separator |
Visual dividers | Horizontal/vertical |
Dropdown Menu |
Actions menu | Nested menus, shortcuts |
Alert |
Notifications | Info, warning, error |
Progress |
Loading indicators | Determinate/indeterminate |
Tooltip |
Hover hints | Delay, positioning |
Button Variants & Sizes
<Button variant="default">Primary Action</Button>
<Button variant="secondary">Secondary Action</Button>
<Button variant="outline">Outlined</Button>
<Button variant="ghost">Subtle</Button>
<Button variant="destructive">Delete</Button>
<Button variant="link">Link Style</Button>
<Button size="sm">Small</Button> {/* 32px mobile, 40px desktop */}
<Button size="default">Default</Button> {/* 44px mobile, 36px desktop */}
<Button size="lg">Large</Button> {/* 48px mobile, 40px desktop */}
<Button size="icon" aria-label="Add"> {/* 44x44px - WCAG 2.1 AAA */}
<PlusIcon />
</Button>
Loading States
<Button loading={isPending} loadingText="Saving...">
Save Changes
</Button>
// Or manually:
<Button disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
Form Integration
Basic Form Pattern
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
type FormValues = z.infer<typeof schema>;
export function MyFormClient() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", name: "" },
});
const onSubmit = form.handleSubmit(async (data) => {
// Handle submission
});
return (
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormDescription>We'll never share your email.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={form.formState.isSubmitting}>
Submit
</Button>
</form>
</Form>
);
}
Form with tRPC Mutation
"use client";
import { api } from "@/lib/api/react";
import { toast } from "sonner";
export function CreateStackFormClient() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
/* ... */
},
});
const utils = api.useUtils();
const createMutation = api.stacks.create.useMutation({
onSuccess: () => {
toast.success("Stack created successfully");
utils.stacks.list.invalidate(); // Refetch list
form.reset();
},
onError: (error) => {
toast.error(error.message);
},
});
const onSubmit = form.handleSubmit((data) => {
createMutation.mutate(data);
});
return (
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
{/* Form fields */}
<Button
type="submit"
loading={createMutation.isPending}
disabled={createMutation.isPending}
>
Create Stack
</Button>
</form>
</Form>
);
}
Common Form Field Patterns
Number Input:
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
placeholder="100"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
Date Input:
<FormField
control={form.control}
name="scheduledFor"
render={({ field: { value, onChange, ...field } }) => (
<FormItem>
<FormLabel>Scheduled Date</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={value instanceof Date ? value.toISOString().split("T")[0] : ""}
onChange={(e) => {
const dateStr = e.target.value;
if (dateStr) onChange(new Date(dateStr));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
Select/Dropdown:
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>;
Textarea:
import { Textarea } from "@/components/ui/textarea";
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Optional notes..." maxLength={1000} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>;
Checkbox:
import { Checkbox } from "@/components/ui/checkbox";
<FormField
control={form.control}
name="acceptTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Accept terms and conditions</FormLabel>
<FormDescription>You agree to our Terms of Service.</FormDescription>
</div>
</FormItem>
)}
/>;
Dialog Patterns
Basic Dialog
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button variant="destructive">Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
Controlled Dialog with Form
"use client";
import { useState } from "react";
export function CreateDialogClient() {
const [open, setOpen] = useState(false);
const form = useForm({
/* ... */
});
const createMutation = api.stacks.create.useMutation({
onSuccess: () => {
toast.success("Created successfully");
form.reset();
setOpen(false); // Close dialog
},
});
const onSubmit = form.handleSubmit((data) => {
createMutation.mutate(data);
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create New</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Stack</DialogTitle>
<DialogDescription>Add a new stack to the system.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
{/* Form fields */}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button type="submit" loading={createMutation.isPending}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Responsive Dialog/Sheet
Atlas has a custom ResponsiveDialogSheet that shows Dialog on desktop, Sheet on mobile:
import { ResponsiveDialogSheet } from "@/components/features/common";
<ResponsiveDialogSheet
open={open}
onOpenChange={setOpen}
title="Create Stack"
description="Add a new stack to the system"
trigger={<Button>Open</Button>}
>
{/* Content - works on both desktop and mobile */}
<Form {...form}>
<form onSubmit={onSubmit}>{/* Fields */}</form>
</Form>
</ResponsiveDialogSheet>;
Data Table Patterns
Basic Table
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell>{invoice.invoice}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>;
Loading Skeleton
import { Skeleton } from "@/components/ui/skeleton";
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
Toast Notifications
Atlas uses Sonner for toast notifications:
import { toast } from "sonner";
// Success
toast.success("Stack created successfully");
// Error
toast.error("Failed to create stack");
// Info
toast.info("Processing your request...");
// Warning
toast.warning("This action is irreversible");
// With action
toast.success("Stack deleted", {
action: {
label: "Undo",
onClick: () => {
/* Undo logic */
},
},
});
// Promise toast (auto-updates based on promise state)
toast.promise(createMutation.mutateAsync(data), {
loading: "Creating stack...",
success: "Stack created successfully",
error: "Failed to create stack",
});
Styling Patterns
Responsive Design
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns */}
</div>
<Button className="w-full sm:w-auto">
{/* Full width on mobile, auto on desktop */}
</Button>
Conditional Styles with cn()
import { cn } from "@/lib/utils";
<Button
className={cn(
"base-classes",
isActive && "bg-blue-600",
isDisabled && "opacity-50 cursor-not-allowed",
variant === "large" && "text-lg px-6",
)}
>
Click me
</Button>;
Dark Mode Support
All shadcn components support dark mode automatically via CSS variables:
<div className="bg-background text-foreground">
{/* Automatically adapts to light/dark mode */}
</div>
Common Mistakes
❌ Missing aria-label on icon buttons
// Bad
<Button size="icon"><TrashIcon /></Button>
// Good
<Button size="icon" aria-label="Delete item"><TrashIcon /></Button>
❌ Not using FormControl
// Bad - missing ARIA attributes
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Input {...field} /> {/* Missing FormControl */}
<FormMessage />
</FormItem>
)}
/>
// Good - proper ARIA linkage
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
❌ Forgetting DialogTrigger asChild
// Bad - creates nested button
<DialogTrigger><Button>Open</Button></DialogTrigger>
// Good - merges props into Button
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
Installation
# Install shadcn CLI
pnpm dlx shadcn@latest init
# Add components
pnpm dlx shadcn@latest add button input form dialog table select checkbox textarea label
Resources
- Official docs: https://ui.shadcn.com
- Examples: See
references/examples.md - Recipes: See
references/recipes.md - Patterns: See
references/patterns.md - Atlas patterns:
@/framework/patterns/shadcn.md
Summary
Key practices:
- Copy, don't import - customize in
src/components/ui/ - Compose, don't prop - build from primitives
- className over props - extend with Tailwind
- Accessibility first - labels on icon buttons
- Forms with react-hook-form - use Form components
- Mobile-first - 44px minimum touch targets
- Type-safe - derive types from tRPC RouterOutputs