| name | shadcn-ui-patterns |
| description | Use when building UI components. Enforces ShadCN UI patterns, accessibility standards (Radix UI), and TailwindCSS best practices for November 2025. |
| allowed-tools | Read, Grep, Glob |
ShadCN UI Patterns - November 2025 Standards
When to Use
- Building new UI components
- Refactoring existing components to use ShadCN
- Implementing forms with validation
- Creating modals, dialogs, and overlays
- Ensuring accessibility compliance
Why ShadCN UI?
- Copy-paste, not npm - Full ownership of component code
- Radix UI primitives - Accessibility built-in (WCAG 2.1 AA compliant)
- TailwindCSS-first - Full customization, no CSS-in-JS
- TypeScript-native - Type-safe props and variants
- Server Component compatible - Works with Next.js 15 App Router
Core Principles
1. Component Installation Pattern
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add label
Components are copied to src/components/ui/ directory - you own the code.
2. Component Usage Patterns
Button Component
import { Button } from "@/components/ui/button"
<Button variant="default">Save</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost">Skip</Button>
<Button variant="link">Learn More</Button>
<Button size="default">Medium</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
<Button size="icon"><Icon /></Button>
<button className="px-4 py-2 bg-blue-500">Bad</button>
Dialog/Modal Component
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button>Open Settings</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Configure your application settings here.
</DialogDescription>
</DialogHeader>
{/* Dialog content */}
</DialogContent>
</Dialog>
<DialogContent>
<h2>Settings</h2> {/* Wrong - use DialogTitle */}
</DialogContent>
Form Component (with React Hook Form + Zod)
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as 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 formSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
function LoginForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: ,
: ,
},
})
() {
.(values)
}
(
)
}
<form>
{}
</form>
3. Server vs Client Components
import { Dialog, DialogContent } from "@/components/ui/dialog"
export default function ServerDialog() {
return <Dialog>...</Dialog>
}
'use client'
import { useState } from 'react'
import { Dialog, DialogContent } from "@/components/ui/dialog"
export function ClientDialog() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>...</DialogContent>
</Dialog>
)
}
4. Accessibility Requirements
Focus Management
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<Button onClick={() => setOpen(true)}>Open</Button>
Keyboard Navigation
Screen Reader Support
<DialogHeader>
<DialogTitle>Delete Project</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogTitle className="sr-only">Delete</DialogTitle>
5. Common Components to Use
| Component | Use Case | Key Props |
|---|
Button | All clickable actions | variant, size, asChild |
Dialog | Modals, confirmations | open, onOpenChange |
Sheet | Side panels, drawers | side, open, onOpenChange |
Popover | Tooltips, menus | open, onOpenChange |
Form | All forms | form (from useForm) |
Input | Text input | type, placeholder |
Select | Dropdowns | value, onValueChange |
Checkbox | Boolean input | checked, onCheckedChange |
RadioGroup | Single choice | value, onValueChange |
Table | Data tables | table (from TanStack Table) |
Card | Content containers | CardHeader, CardContent, CardFooter |
Toast | Notifications | title, description, variant |
Command | Command palette | onSelect |
Tabs | Tab navigation | value, onValueChange |
6. TailwindCSS Best Practices
<Button className="w-full mt-4">Submit</Button>
import { cn } from "@/lib/utils"
<Button className={cn(
"w-full",
isLoading && "opacity-50 cursor-not-allowed"
)}>
Submit
</Button>
<Button style={{ width: '100%', marginTop: '16px' }}>Submit</Button>
.my-button { width: 100%; }
7. Dark Mode Support
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
Content
</div>
<Button variant="default">
{/* Automatically styled for dark mode */}
</Button>
Common Mistakes to Catch
❌ Missing DialogTitle (Accessibility Violation)
<DialogContent>
<h2>Settings</h2>
<p>Content</p>
</DialogContent>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<p>Content</p>
</DialogContent>
❌ Not Using Form Component for Forms
<form>
<input name="email" />
<button type="submit">Submit</button>
</form>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField name="email" ... />
</form>
</Form>
❌ Hardcoding Colors Instead of Using Variants
<Button className="bg-red-500 hover:bg-red-600">Delete</Button>
<Button variant="destructive">Delete</Button>
❌ Not Using asChild for Triggers
<DialogTrigger>
<Button>Open</Button>
</DialogTrigger>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
Testing ShadCN Components
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog'
describe('Dialog', () => {
it('should open when trigger is clicked', async () => {
const user = userEvent.setup()
render(
<Dialog>
<DialogTrigger asChild>
<button>Open</button>
</DialogTrigger>
<DialogContent>
<div>Dialog content</div>
</DialogContent>
</Dialog>
)
expect(screen.queryByText('Dialog content')).not.toBeInTheDocument()
await user.click(screen.())
(screen.()).()
})
(, () => {
user = userEvent.()
(
)
(screen.()).()
user.()
(screen.())..()
})
})
Resources
November 2025 Note
ShadCN UI is the industry standard for React component libraries as of November 2025. All new Quetrex applications must use ShadCN UI for consistency, accessibility, and maintainability.