Advanced shadcn/ui Patterns Every Developer Should Know in 2026
- shadcn/ui is your code β modify it directly instead of creating wrapper components
- Compound components eliminate prop drilling β parent owns state, children consume via context
- react-hook-form + Zod is the standard for shadcn/ui forms β not useState
- shadcn/ui is a copy-paste component library built on Radix UI primitives and Tailwind CSS
- Advanced patterns include compound components, controlled form state, and slot-based composition
- The cn() utility merges Tailwind classes without specificity conflicts β use it everywhere
- Theming works through CSS variables, not Tailwind config β override --primary, --background, etc.
- Production apps need data table patterns, dialog state management, and toast coordination
- Biggest mistake: treating shadcn/ui as a black-box dependency β it is your code, modify it
Component styles missing after installation
ls components/ui/ | grep -i 'button\|dialog\|form'cat tailwind.config.ts | grep -A5 contentCSS variables not resolving
grep -n ':root' app/globals.cssgrep -c '--primary\|--background\|--foreground' app/globals.cssTypeScript errors on component props
npm ls @radix-ui/react-dialog @radix-ui/react-selectcat node_modules/@radix-ui/react-dialog/package.json | grep versionHydration mismatch with shadcn/ui components
grep -rn 'useState\|useEffect\|window\.' components/ui/ --include='*.tsx'grep -rn '"use client"' components/ui/ --include='*.tsx'Production Incident
Production Debug GuideCommon issues when building with shadcn/ui in production
shadcn/ui changed how teams build component systems. Instead of installing a dependency and accepting its API, you copy production-grade React components into your codebase. Every component is yours to modify, extend, and compose.
Most tutorials cover installation and basic usage. This article covers the patterns that separate a prototype from a production application: compound component architecture, form state management with react-hook-form integration, data table patterns with TanStack Table, dialog orchestration, toast coordination, and theming strategies.
Each pattern includes a real-world use case, production code, and the failure scenario you will encounter if you get it wrong.
Pattern 1: Compound Components with Shared Context
shadcn/ui components are built on Radix UI primitives, which use compound component patterns internally. Understanding this pattern lets you build custom composite components that share state without prop drilling.
The compound component pattern exposes a context from a parent component and consumes it in child components. The parent manages state, the children render UI. This is how shadcn/ui's Form, Select, and Dialog components work β and it is the pattern you should use for your own composite components.
The key insight: shadcn/ui components are not black boxes. You own the source code. You can wrap, extend, and compose them using the same compound pattern they use internally.
Biggest gotcha: the cn() utility (used everywhere in the examples below). It lives in lib/utils.ts and is required for safe Tailwind class merging.
// ============================================ // Pattern 1: Compound Components with Shared Context // ============================================ // This pattern creates a parent component that manages state // and child components that consume that state via context. // shadcn/ui uses this pattern internally β you should too. 'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' // ---- Step 1: Define the context ---- // The context holds shared state and actions interface PricingContextValue { selectedPlan: string | null setSelectedPlan: (plan: string) => void billingCycle: 'monthly' | 'annual' setBillingCycle: (cycle: 'monthly' | 'annual') => void isLoading: boolean } const PricingContext = React.createContext<PricingContextValue | null>(null) function usePricingContext() { const context = React.useContext(PricingContext) if (!context) { throw new Error('Pricing compound components must be used within <Pricing>') } return context } // ---- Step 2: Create the parent component ---- // The parent manages state and provides context interface PricingProps { defaultPlan?: string defaultCycle?: 'monthly' | 'annual' onPlanSelect?: (plan: string, cycle: 'monthly' | 'annual') => void children: React.ReactNode } function Pricing({ defaultPlan, defaultCycle = 'monthly', onPlanSelect, children, }: PricingProps) { const [selectedPlan, setSelectedPlan] = React.useState<string | null>( defaultPlan ?? null ) const [billingCycle, setBillingCycle] = React.useState<'monthly' | 'annual'>( defaultCycle ) const [isLoading, setIsLoading] = React.useState(false) const handlePlanSelect = React.useCallback( async (plan: string) => { setSelectedPlan(plan) setIsLoading(true) try { await onPlanSelect?.(plan, billingCycle) } finally { setIsLoading(false) } }, [billingCycle, onPlanSelect] ) const value = React.useMemo( () => ({ selectedPlan, setSelectedPlan: handlePlanSelect, billingCycle, setBillingCycle, isLoading, }), [selectedPlan, handlePlanSelect, billingCycle, isLoading] ) return ( <PricingContext.Provider value={value}> <div className="grid gap-6 md:grid-cols-3"> {children} </div> </PricingContext.Provider> ) } // ---- Step 3: Create child components ---- // Each child consumes the context to read/write shared state interface PricingCardProps { plan: string price: { monthly: number; annual: number } features: string[] popular?: boolean children?: React.ReactNode } function PricingCard({ plan, price, features, popular, children, }: PricingCardProps) { const { selectedPlan, setSelectedPlan, billingCycle, isLoading } = usePricingContext() const isSelected = selectedPlan === plan const currentPrice = billingCycle === 'monthly' ? price.monthly : price.annual return ( <Card className={cn( 'relative transition-all', isSelected && 'border-primary shadow-lg', popular && 'border-primary' )} > {popular && ( <Badge className="absolute -top-2 right-4"> Most Popular </Badge> )} <CardHeader> <CardTitle>{plan}</CardTitle> <CardDescription> <span className="text-3xl font-bold">\${currentPrice}</span> <span className="text-muted-foreground"> /{billingCycle === 'monthly' ? 'mo' : 'yr'} </span> </CardDescription> </CardHeader> <CardContent className="space-y-4"> <ul className="space-y-2"> {features.map((feature) => ( <li key={feature} className="flex items-center gap-2"> <span className="text-primary">✓</span> {feature} </li> ))} </ul> <Button className="w-full" variant={isSelected ? 'default' : 'outline'} onClick={() => setSelectedPlan(plan)} disabled={isLoading} > {isSelected ? 'Selected' : 'Select Plan'} </Button> {children} </CardContent> </Card> ) } // ---- Step 4: Create toggle sub-component ---- function BillingToggle() { const { billingCycle, setBillingCycle } = usePricingContext() return ( <div className="flex items-center justify-center gap-2"> <Button variant={billingCycle === 'monthly' ? 'default' : 'ghost'} size="sm" onClick={() => setBillingCycle('monthly')} > Monthly </Button> <Button variant={billingCycle === 'annual' ? 'default' : 'ghost'} size="sm" onClick={() => setBillingCycle('annual')} > Annual (Save 20%) </Button> </div> ) } // ---- Step 5: Compose the compound component ---- // Usage in a page β clean, declarative, no prop drilling // app/pricing/page.tsx export default function PricingPage() { return ( <div className="space-y-8"> <BillingToggle /> <Pricing onPlanSelect={async (plan, cycle) => { await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ plan, cycle }), }) }} > <PricingCard plan="Starter" price={{ monthly: 9, annual: 86 }} features={['5 projects', '10GB storage', 'Email support']} /> <PricingCard plan="Pro" price={{ monthly: 29, annual: 278 }} features={['Unlimited projects', '100GB storage', 'Priority support', 'API access']} popular /> <PricingCard plan="Enterprise" price={{ monthly: 99, annual: 950 }} features={['Unlimited everything', 'SSO', 'Dedicated support', 'SLA']} /> </Pricing> </div> ) }
- Parent component manages state and provides it via React Context
- Child components consume context β no prop drilling required
- Children can be reordered, conditionally rendered, or extended without changing the parent
- The pattern scales to any depth β nested compound components share context hierarchically
- shadcn/ui's Form, Select, and Dialog use this exact pattern internally
Pattern 2: Form State Management with react-hook-form
shadcn/ui's Form components wrap react-hook-form with Radix UI primitives. The integration is clean, but the pattern breaks down in complex forms with conditional fields, async validation, and multi-step wizards.
The key to production-grade forms is understanding the separation of concerns: FormField handles the field-level state, FormMessage handles error display, and react-hook-form handles validation and submission. Each layer has a specific responsibility β mixing them causes bugs.
The most common production mistake: using controlled state (useState) for form values instead of react-hook-form's register/setValue. This causes unnecessary re-renders on every keystroke and breaks the form's integration with shadcn/ui's FormMessage error display.
// ============================================ // Pattern 2: Advanced Form Patterns with shadcn/ui // ============================================ 'use client' import * as React from 'react' import { useForm, useFieldArray, useWatch } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import * as z from 'zod' import { cn } from '@/lib/utils' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' // ---- Schema: Define validation rules with Zod ---- // Zod schemas drive both runtime validation and TypeScript types const invoiceItemSchema = z.object({ description: z.string().min(1, 'Description is required'), quantity: z.coerce.number().min(1, 'Minimum quantity is 1'), unitPrice: z.coerce.number().min(0.01, 'Price must be positive'), taxable: z.boolean().default(false), }) const invoiceSchema = z.object({ clientName: z.string().min(2, 'Client name is required'), clientEmail: z.string().email('Invalid email address'), invoiceNumber: z.string().min(1, 'Invoice number is required'), dueDate: z.string().min(1, 'Due date is required'), items: z .array(invoiceItemSchema) .min(1, 'Add at least one line item'), notes: z.string().optional(), paymentTerms: z.enum(['net15', 'net30', 'net60', 'due_on_receipt']), }) type InvoiceFormValues = z.infer<typeof invoiceSchema> // ---- Pattern 2A: Dynamic Field Arrays ---- // Use useFieldArray for dynamic lists of form fields export function InvoiceForm() { const form = useForm<InvoiceFormValues>({ resolver: zodResolver(invoiceSchema), defaultValues: { clientName: '', clientEmail: '', invoiceNumber: `INV-${Date.now()}`, dueDate: '', items: [{ description: '', quantity: 1, unitPrice: 0, taxable: false }], notes: '', paymentTerms: 'net30', }, }) const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items', }) // Watch items for real-time total calculation const items = useWatch({ control: form.control, name: 'items' }) const subtotal = items.reduce((sum, item) => { return sum + (item.quantity || 0) * (item.unitPrice || 0) }, 0) const taxTotal = items.reduce((sum, item) => { if (!item.taxable) return sum return sum + (item.quantity || 0) * (item.unitPrice || 0) * 0.08 }, 0) const total = subtotal + taxTotal async function onSubmit(data: InvoiceFormValues) { const response = await fetch('/api/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!response.ok) { const error = await response.json() // Set server-side validation errors on specific fields if (error.fieldErrors) { Object.entries(error.fieldErrors).forEach(([field, message]) => { form.setError(field as keyof InvoiceFormValues, { type: 'server', message: message as string, }) }) } return } // Success β redirect or show toast } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> {/* ---- Client Info Section ---- */} <div className="grid gap-4 md:grid-cols-2"> <FormField control={form.control} name="clientName" render={({ field }) => ( <FormItem> <FormLabel>Client Name</FormLabel> <FormControl> <Input placeholder="Acme Corp" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="clientEmail" render={({ field }) => ( <FormItem> <FormLabel>Client Email</FormLabel> <FormControl> <Input placeholder="billing@acme.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> {/* ---- Dynamic Line Items ---- */} <div className="space-y-4"> <div className="flex items-center justify-between"> <FormLabel className="text-base">Line Items</FormLabel> <Button type="button" variant="outline" size="sm" onClick={() => append({ description: '', quantity: 1, unitPrice: 0, taxable: false, }) } > Add Item </Button> </div> {fields.map((field, index) => ( <div key={field.id} className="grid gap-4 rounded-lg border p-4 md:grid-cols-5" > <FormField control={form.control} name={`items.${index}.description`} render={({ field }) => ( <FormItem className="md:col-span-2"> <FormLabel className={cn(index !== 0 && 'sr-only')}> Description </FormLabel> <FormControl> <Input placeholder="Service description" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => ( <FormItem> <FormLabel className={cn(index !== 0 && 'sr-only')}> Qty </FormLabel> <FormControl> <Input type="number" min="1" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => ( <FormItem> <FormLabel className={cn(index !== 0 && 'sr-only')}> Price </FormLabel> <FormControl> <Input type="number" step="0.01" min="0" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="flex items-end gap-2"> <FormField control={form.control} name={`items.${index}.taxable`} render={({ field }) => ( <FormItem className="flex items-center gap-2"> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel className="text-xs">Tax</FormLabel> </FormItem> )} /> {fields.length > 1 && ( <Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} > Remove </Button> )} </div> </div> ))} {/* Totals β calculated from watched values */} <div className="flex flex-col items-end gap-1 text-sm"> <div className="flex w-48 justify-between"> <span>Subtotal</span> <span>\${subtotal.toFixed(2)}</span> </div> <div className="flex w-48 justify-between"> <span>Tax (8%)</span> <span>\${taxTotal.toFixed(2)}</span> </div> <div className="flex w-48 justify-between font-bold"> <span>Total</span> <span>\${total.toFixed(2)}</span> </div> </div> </div> <Button type="submit" disabled={form.formState.isSubmitting} className="w-full" > {form.formState.isSubmitting ? 'Creating...' : 'Create Invoice'} </Button> </form> </Form> ) }
Pattern 3: Data Tables with TanStack Table
shadcn/ui provides a DataTable component built on TanStack Table. The basic example handles static data, but production applications need server-side pagination, column sorting, row selection, bulk actions, and filtered views.
The key architectural decision: client-side vs server-side data management. Client-side works for datasets under 10,000 rows. Server-side is required for larger datasets or when data comes from an API with pagination cursors.
The shadcn/ui DataTable example uses client-side sorting and filtering. Production applications almost always need server-side operations β this pattern shows how to adapt the DataTable for API-driven data.
// ============================================ // Pattern 3: Server-Side Data Table // ============================================ 'use client' import * as React from 'react' import { ColumnDef, ColumnFiltersState, SortingState, VisibilityState, flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table' import { cn } from '@/lib/utils' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Checkbox } from '@/components/ui/checkbox' import { Badge } from '@/components/ui/badge' // ---- Type definitions ---- interface User { id: string name: string email: string role: 'admin' | 'member' | 'viewer' status: 'active' | 'inactive' | 'suspended' createdAt: string } interface DataTableProps { data: User[] pageCount: number currentPage: number totalCount: number onPaginationChange: (page: number, pageSize: number) => void onSortingChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void onFilterChange: (filters: Record<string, string>) => void onBulkAction?: (action: string, selectedIds: string[]) => void } // ---- Column definitions with shadcn/ui components ---- const columns: ColumnDef<User>[] = [ { id: 'select', header: ({ table }) => ( <Checkbox checked={ table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate') } onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }, { accessorKey: 'name', header: 'Name', cell: ({ row }) => ( <div className="font-medium">{row.getValue('name')}</div> ), }, { accessorKey: 'email', header: 'Email', cell: ({ row }) => ( <div className="text-muted-foreground">{row.getValue('email')}</div> ), }, { accessorKey: 'role', header: 'Role', cell: ({ row }) => { const role = row.getValue('role') as string return ( <Badge variant={ role === 'admin' ? 'default' : role === 'member' ? 'secondary' : 'outline' } > {role} </Badge> ) }, }, { accessorKey: 'status', header: 'Status', cell: ({ row }) => { const status = row.getValue('status') as string return ( <div className="flex items-center gap-2"> <span className={cn( 'h-2 w-2 rounded-full', status === 'active' && 'bg-green-500', status === 'inactive' && 'bg-gray-400', status === 'suspended' && 'bg-red-500' )} /> <span className="capitalize">{status}</span> </div> ) }, }, { accessorKey: 'createdAt', header: 'Created', cell: ({ row }) => { const date = new Date(row.getValue('createdAt')) return ( <time dateTime={date.toISOString()}> {date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} </time> ) }, }, ] // ---- Server-side Data Table Component ---- export function UserDataTable({ data, pageCount, currentPage, totalCount, onPaginationChange, onSortingChange, onFilterChange, onBulkAction, }: DataTableProps) { const [rowSelection, setRowSelection] = React.useState({}) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) const [sorting, setSorting] = React.useState<SortingState>([]) // Sync sorting state to server React.useEffect(() => { if (sorting.length > 0) { onSortingChange(sorting[0].id, sorting[0].desc ? 'desc' : 'asc') } }, [sorting, onSortingChange]) // Sync filter state to server (debounced) React.useEffect(() => { const timer = setTimeout(() => { const filters: Record<string, string> = {} columnFilters.forEach((filter) => { filters[filter.id] = filter.value as string }) onFilterChange(filters) }, 300) return () => clearTimeout(timer) }, [columnFilters, onFilterChange]) const table = useReactTable({ data, columns, pageCount, state: { sorting, columnVisibility, rowSelection, columnFilters, pagination: { pageIndex: currentPage - 1, pageSize: 20 }, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), manualPagination: true, manualSorting: true, manualFiltering: true, }) const selectedRows = table.getFilteredSelectedRowModel().rows return ( <div className="space-y-4"> {/* Toolbar */} <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <Input placeholder="Filter by name..." value={(table.getColumn('name')?.getFilterValue() as string) ?? ''} onChange={(e) => table.getColumn('name')?.setFilterValue(e.target.value) } className="max-w-sm" /> {selectedRows.length > 0 && onBulkAction && ( <div className="flex items-center gap-2"> <span className="text-sm text-muted-foreground"> {selectedRows.length} selected </span> <Button variant="outline" size="sm" onClick={() => onBulkAction( 'deactivate', selectedRows.map((r) => r.original.id) ) } > Deactivate Selected </Button> </div> )} </div> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> Columns </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {table .getAllColumns() .filter((col) => col.getCanHide()) .map((column) => ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value) } > {column.id} </DropdownMenuCheckboxItem> ))} </DropdownMenuContent> </DropdownMenu> </div> {/* Table */} <div className="rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> {/* Pagination */} <div className="flex items-center justify-between"> <div className="text-sm text-muted-foreground"> {selectedRows.length} of {totalCount} row(s) selected. </div> <div className="flex items-center gap-2"> <Button variant="outline" size="sm" onClick={() => onPaginationChange(currentPage - 1, 20)} disabled={currentPage <= 1} > Previous </Button> <span className="text-sm"> Page {currentPage} of {pageCount} </span> <Button variant="outline" size="sm" onClick={() => onPaginationChange(currentPage + 1, 20)} disabled={currentPage >= pageCount} > Next </Button> </div> </div> </div> ) }
- Client-side: sorting, filtering, pagination happen in the browser β fine for under 10,000 rows
- Server-side: set manualPagination, manualSorting, manualFiltering to true β the table does not process data
- Debounce filter inputs by 300ms before sending to the server β prevents excessive API calls
- Use getFacetedUniqueValues() for column filter dropdowns β it shows only values present in the current data
- Row selection works the same in both modes β use getFilteredSelectedRowModel() for selected rows
Pattern 4: Dialog Orchestration and State Management
shadcn/ui's Dialog component is a controlled component β you manage the open state. In production applications, dialogs are rarely isolated. They chain (confirm before delete), stack (settings (close dialog inside a modal), and coordinate A when dialog B opens).
The naive approach β independent useState for each dialog β breaks down quickly. Opening dialog A while dialog B is open creates z-index conflicts. Chaining dialogs requires callback coordination. The solution is a centralized dialog manager.
// ============================================ // Pattern 4: Centralized Dialog Manager // ============================================ 'use client' import * as React from 'react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' // ---- Dialog state types ---- type DialogType = 'confirm-delete' | 'edit-user' | 'create-item' | null interface DialogState { type: DialogType data: Record<string, unknown> | null resolve: ((value: boolean) => void) | null } interface DialogContextValue { open: (type: DialogType, data?: Record<string, unknown>) => Promise<boolean> close: () => void state: DialogState } const DialogContext = React.createContext<DialogContextValue | null>(null) export function useDialog() { const context = React.useContext(DialogContext) if (!context) { throw new Error('useDialog must be used within <DialogProvider>') } return context } // ---- Dialog Provider ---- // Manages a single dialog at a time β prevents stacking issues export function DialogProvider({ children }: { children: React.ReactNode }) { const [state, setState] = React.useState<DialogState>({ type: null, data: null, resolve: null, }) const open = React.useCallback( (type: DialogType, data?: Record<string, unknown>) => { return new Promise<boolean>((resolve) => { setState({ type, data: data ?? null, resolve }) }) }, [] ) const close = React.useCallback(() => { if (state.resolve) { state.resolve(false) } setState({ type: null, data: null, resolve: null }) }, [state.resolve]) const confirm = React.useCallback(() => { if (state.resolve) { state.resolve(true) } setState({ type: null, data: null, resolve: null }) }, [state.resolve]) const value = React.useMemo( () => ({ open, close, state }), [open, close, state] ) return ( <DialogContext.Provider value={value}> {children} {/* Confirm Delete Dialog */} <Dialog open={state.type === 'confirm-delete'} onOpenChange={(isOpen) => !isOpen && close()} > <DialogContent> <DialogHeader> <DialogTitle>Confirm Deletion</DialogTitle> <DialogDescription> Are you sure you want to delete{' '} <strong>{(state.data?.name as string) ?? 'this item'}</strong>? This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline" onClick={close}> Cancel </Button> <Button variant="destructive" onClick={confirm}> Delete </Button> </DialogFooter> </DialogContent> </Dialog> {/* Edit User Dialog */} <Dialog open={state.type === 'edit-user'} onOpenChange={(isOpen) => !isOpen && close()} > <DialogContent> <DialogHeader> <DialogTitle>Edit User</DialogTitle> <DialogDescription> Update details for {state.data?.name as string} </DialogDescription> </DialogHeader> {/* Edit form would go here */} <DialogFooter> <Button variant="outline" onClick={close}> Cancel </Button> <Button onClick={confirm}>Save Changes</Button> </DialogFooter> </DialogContent> </Dialog> </DialogContext.Provider> ) } // ---- Usage: Promise-based dialog interaction ---- // Components await the dialog result β clean async flow export function UserActions({ user }: { user: { id: string; name: string } }) { const dialog = useDialog() async function handleDelete() { // Open dialog and wait for user response const confirmed = await dialog.open('confirm-delete', { name: user.name, id: user.id, }) if (confirmed) { await fetch(`/api/users/${user.id}`, { method: 'DELETE' }) } } async function handleEdit() { const saved = await dialog.open('edit-user', { name: user.name, id: user.id, }) if (saved) { // Refresh data } } return ( <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={handleEdit}> Edit </Button> <Button variant="destructive" size="sm" onClick={handleDelete}> Delete </Button> </div> ) }
- dialog.open() returns a Promise that resolves to true (confirmed) or false (cancelled)
- The calling code awaits the result β no callbacks, no state management in the consumer
- Only one dialog can be open at a time β the provider enforces this constraint
- Dialog data is passed as a parameter β the dialog reads it from context, not from props
- This pattern works for any modal interaction: confirmations, forms, pickers, wizards
Pattern 5: Toast Coordination and Notification Queuing
shadcn/ui's Toast component uses the sonner library under the hood. The basic usage is straightforward β call toast() and a notification appears. Production applications need more: deduplication (prevent the same toast from appearing twice), prioritization (error toasts stay longer than success toasts), and coordination (dismiss loading toast when success toast appears).
The key pattern: wrap sonner's toast() in a service layer that handles deduplication, prioritization, and lifecycle management. This prevents toast spam and ensures users see the most important notifications.
// ============================================ // Pattern 5: Toast Service with Deduplication // ============================================ import { toast as sonnerToast } from 'sonner' // ---- Toast configuration ---- interface ToastConfig { id?: string duration?: number dismissible?: boolean } const TOAST_DURATIONS = { success: 4000, error: 8000, warning: 6000, info: 4000, loading: Infinity, } as const // ---- Active toast tracking ---- const activeToasts = new Map<string, string | number>() function deduplicationKey(message: string): string { return message.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 50) } // ---- Toast service ---- export const toast = { success(message: string, config?: ToastConfig) { const key = config?.id ?? deduplicationKey(message) // Dismiss existing toast with same key if (activeToasts.has(key)) { sonnerToast.dismiss(activeToasts.get(key)) } const id = sonnerToast.success(message, { duration: config?.duration ?? TOAST_DURATIONS.success, dismissible: config?.dismissible ?? true, id: key + '-' + Date.now(), // Unique per instance onDismiss: () => activeToasts.delete(key), }) activeToasts.set(key, id) return id }, error(message: string, config?: ToastConfig) { const key = config?.id ?? deduplicationKey(message) if (activeToasts.has(key)) { sonnerToast.dismiss(activeToasts.get(key)) } const id = sonnerToast.error(message, { duration: config?.duration ?? TOAST_DURATIONS.error, dismissible: config?.dismissible ?? true, id: key + '-' + Date.now(), // Unique per instance onDismiss: () => activeToasts.delete(key), }) activeToasts.set(key, id) return id }, loading(message: string, config?: ToastConfig) { const key = config?.id ?? deduplicationKey(message) if (activeToasts.has(key)) { sonnerToast.dismiss(activeToasts.get(key)) } const id = sonnerToast.loading(message, { duration: config?.duration ?? TOAST_DURATIONS.loading, id: key + '-' + Date.now(), // Unique per instance onDismiss: () => activeToasts.delete(key), }) activeToasts.set(key, id) return id }, // Dismiss loading toast and show result resolve( loadingId: string | number, type: 'success' | 'error', message: string ) { sonnerToast.dismiss(loadingId) // Find and remove from active tracking for (const [key, id] of activeToasts.entries()) { if (id === loadingId) { activeToasts.delete(key) break } } // Show result toast if (type === 'success') { return this.success(message) } else { return this.error(message) } }, dismissAll() { sonnerToast.dismiss() activeToasts.clear() }, } // ---- Usage with async operations ---- export async function withToast<T>( promise: Promise<T>, messages: { loading: string success: string error: string } ): Promise<T> { const loadingId = toast.loading(messages.loading) try { const result = await promise toast.resolve(loadingId, 'success', messages.success) return result } catch (error) { const errorMessage = error instanceof Error ? error.message : messages.error toast.resolve(loadingId, 'error', errorMessage) throw error } } // ---- Usage example ---- async function deleteUser(userId: string) { await withToast( fetch(`/api/users/${userId}`, { method: 'DELETE' }), { loading: 'Deleting user...', success: 'User deleted successfully', error: 'Failed to delete user', } ) }
- Deduplicate toasts by message content β the same error should not stack 5 times
- Loading toasts must be dismissed when the operation completes β use resolve() to swap loading for success/error
- Error toasts should stay longer (8s) than success toasts (4s) β users need time to read error messages
- Dismiss all toasts on route changes β stale toasts from previous pages confuse users
- Use toast IDs to track and dismiss specific toasts β not just dismissAll()
Pattern 6: Theming with CSS Variables
shadcn/ui uses CSS variables for theming β not Tailwind's color config. This is a deliberate design decision that enables runtime theme switching without rebuilding CSS. Understanding this mechanism is essential for building white-label applications, dark mode, and custom brand themes.
The CSS variables are defined in globals.css under :root (light) and .dark (dark) selectors. Each shadcn/ui component references these variables via Tailwind utilities like bg-background, text-foreground, border-border. Changing a CSS variable changes every component that uses it.
// ============================================ // Pattern 6: Runtime Theme Switching // ============================================ 'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' // ---- Theme types ---- type Theme = 'light' | 'dark' | 'system' type BrandTheme = 'default' | 'ocean' | 'forest' | 'sunset' interface ThemeContextValue { theme: Theme setTheme: (theme: Theme) => void brandTheme: BrandTheme setBrandTheme: (brand: BrandTheme) => void } const ThemeContext = React.createContext<ThemeContextValue | null>(null) export function useTheme() { const context = React.useContext(ThemeContext) if (!context) { throw new Error('useTheme must be used within <ThemeProvider>') } return context } // ---- Theme Provider ---- export function ThemeProvider({ children, defaultTheme = 'system', defaultBrand = 'default', }: { children: React.ReactNode defaultTheme?: Theme defaultBrand?: BrandTheme }) { const [theme, setThemeState] = React.useState<Theme>(defaultTheme) const [brandTheme, setBrandThemeState] = React.useState<BrandTheme>(defaultBrand) const [mounted, setMounted] = React.useState(false) const setTheme = React.useCallback((newTheme: Theme) => { setThemeState(newTheme) const root = document.documentElement root.classList.remove('light', 'dark') if (newTheme === 'system') { const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') .matches ? 'dark' : 'light' root.classList.add(systemTheme) } else { root.classList.add(newTheme) } localStorage.setItem('theme', newTheme) }, []) const setBrandTheme = React.useCallback((newBrand: BrandTheme) => { setBrandThemeState(newBrand) const root = document.documentElement root.classList.remove( 'theme-brand-ocean', 'theme-brand-forest', 'theme-brand-sunset' ) if (newBrand !== 'default') { root.classList.add(`theme-brand-${newBrand}`) } localStorage.setItem('brand-theme', newBrand) }, []) // Initialize from localStorage React.useEffect(() => { setMounted(true) const savedTheme = localStorage.getItem('theme') as Theme | null const savedBrand = localStorage.getItem('brand-theme') as BrandTheme | null if (savedTheme) setTheme(savedTheme) if (savedBrand) setBrandTheme(savedBrand) }, [setTheme, setBrandTheme]) // Listen for system theme changes React.useEffect(() => { if (theme !== 'system') return const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const handler = (e: MediaQueryListEvent) => { document.documentElement.classList.remove('light', 'dark') document.documentElement.classList.add(e.matches ? 'dark' : 'light') } mediaQuery.addEventListener('change', handler) return () => mediaQuery.removeEventListener('change', handler) }, [theme]) const value = React.useMemo( () => ({ theme, setTheme, brandTheme, setBrandTheme }), [theme, setTheme, brandTheme, setBrandTheme] ) return ( <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> ) } // ---- Theme Switcher Component ---- export function ThemeSwitcher() { const { theme, setTheme, brandTheme, setBrandTheme } = useTheme() const [mounted, setMounted] = React.useState(false) React.useEffect(() => { setMounted(true) }, []) if (!mounted) { return null // Skip SSR to prevent hydration mismatch } return ( <div className="flex items-center gap-2"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> {theme === 'light' ? 'Light' : theme === 'dark' ? 'Dark' : 'System'} </Button> </DropdownMenuTrigger> <DropdownMenuContent> <DropdownMenuItem onClick={() => setTheme('light')}> Light </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('dark')}> Dark </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('system')}> System </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> Brand: {brandTheme} </Button> </DropdownMenuTrigger> <DropdownMenuContent> <DropdownMenuItem onClick={() => setBrandTheme('default')}> Default </DropdownMenuItem> <DropdownMenuItem onClick={() => setBrandTheme('ocean')}> Ocean </DropdownMenuItem> <DropdownMenuItem onClick={() => setBrandTheme('forest')}> Forest </DropdownMenuItem> <DropdownMenuItem onClick={() => setBrandTheme('sunset')}> Sunset </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ) }
- shadcn/ui components reference CSS variables (bg-background, text-foreground) β not hardcoded colors
- Theme switching is a class toggle on the root element β no rebuild required
- Brand themes are additional CSS variable overrides applied via a parent class β scoped to a container
- The HSL format (hue saturation lightness) without hsl() wrapper is intentional β Tailwind wraps it automatically
- Runtime theme switching works because CSS variables are resolved at paint time, not at build time
Pattern 7: Slot-Based Component Composition
The slot pattern lets consumers inject custom content into predefined positions of a component. Unlike children (which is a single slot), named slots allow multiple injection points. This pattern is essential for building flexible layouts, card templates, and reusable page structures.
shadcn/ui's Card component uses a simple version of this pattern β CardHeader, CardContent, and CardFooter are implicit slots. The advanced version uses render props and explicit slot props for maximum flexibility.
// ============================================ // Pattern 7: Slot-Based Component Composition // ============================================ 'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Card, CardContent, CardFooter } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' // ---- Slot-based Entity Card ---- // A reusable card with named slots for maximum flexibility (extend as needed) interface EntityCardProps { className?: string // Named slots β each is optional (simplified to 3 core slots) header?: React.ReactNode content?: React.ReactNode footer?: React.ReactNode // Interaction onCardClick?: () => void } function EntityCard({ className, header, content, footer, onCardClick, }: EntityCardProps) { return ( <Card className={cn( 'overflow-hidden transition-shadow hover:shadow-md', onCardClick && 'cursor-pointer', className )} onClick={onCardClick} > <div className="p-4"> {/* Header slot β title, badges, avatar */} {header && <div className="mb-3">{header}</div>} {/* Content slot β description, body text */} {content && <div className="mb-3">{content}</div>} </div> {/* Footer slot β additional info, secondary actions */} {footer && <CardFooter className="border-t bg-muted/50 px-4 py-3"> {footer} </CardFooter>} </Card> ) } // ---- Usage: Same card, completely different content ---- // Product Card function ProductCard({ product }: { product: any }) { return ( <EntityCard header={ <div className="flex items-center justify-between"> <h3 className="font-semibold">{product.name}</h3> <Badge>{product.category}</Badge> </div> } content={ <> <p className="text-sm text-muted-foreground"> {product.description} </p> <span className="text-lg font-bold">\${product.price}</span> <span className="text-sm text-muted-foreground"> {product.stock} in stock </span> </> } footer={ <> <Button size="sm">Add to Cart</Button> <Button size="sm" variant="outline">Details</Button> </> } /> ) } // Team Member Card function TeamMemberCard({ member }: { member: any }) { return ( <EntityCard header={ <div className="flex items-center gap-3"> <Avatar> <AvatarImage src={member.avatar} /> <AvatarFallback>{member.name[0]}</AvatarFallback> </Avatar> <div> <h3 className="font-semibold">{member.name}</h3> <p className="text-sm text-muted-foreground">{member.role}</p> </div> </div> } content={ <p className="text-sm text-muted-foreground"> {member.bio} </p> } footer={ <div className="flex w-full justify-between text-sm text-muted-foreground"> <span>{member.projects} projects</span> <span>Joined {member.joinedDate}</span> </div> } /> ) }
- Use children when there is one injection point β the component wraps the content
- Use named slots when there are multiple injection points β header, content, footer, actions
- Render props (functions as children) when the parent needs to control rendering logic
- Slot components should have sensible defaults β the card works even with no slots provided
- Type each slot as React.ReactNode β this accepts strings, elements, fragments, and null
Pattern 8: Accessible Command Palette
shadcn/ui's Command component wraps cmdk β a fast, accessible command palette. The basic usage shows a search box with static options. Production applications need dynamic search, grouped results, keyboard navigation, and integration with application state.
The command palette is the power-user interface for navigation, actions, and search. Building it well requires understanding cmdk's API, React's useDeferredValue for search debouncing, and keyboard accessibility patterns.
// ============================================ // Pattern 8: Production Command Palette // ============================================ 'use client' import * as React from 'react' import { useRouter } from 'next/navigation' import { cn } from '@/lib/utils' import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command' import { Badge } from '@/components/ui/badge' // ---- Types ---- interface CommandAction { id: string label: string description?: string icon?: React.ReactNode shortcut?: string group: string action: () => void | Promise<void> } // ---- Global keyboard shortcut hook ---- function useCommandPalette() { const [open, setOpen] = React.useState(false) React.useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen((prev) => !prev) } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, []) return { open, setOpen } } // ---- Command Palette Component ---- export function CommandPalette() { const router = useRouter() const { open, setOpen } = useCommandPalette() const [search, setSearch] = React.useState('') const [debouncedSearch, setDebouncedSearch] = React.useState('') const [pages, setPages] = React.useState<string[]>([]) const [results, setResults] = React.useState<CommandAction[]>([]) const [isLoading, setIsLoading] = React.useState(false) // Proper debounce for search β prevents excessive API calls React.useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(search) }, 300) return () => clearTimeout(timer) }, [search]) // Search with debounced value React.useEffect(() => { if (!debouncedSearch || debouncedSearch.length < 2) { setResults([]) return } const controller = new AbortController() setIsLoading(true) fetch(`/api/search?q=${encodeURIComponent(debouncedSearch)}`, { signal: controller.signal, }) .then((res) => res.json()) .then((data) => { const actions: CommandAction[] = data.results.map((r: any) => ({ id: r.id, label: r.title, description: r.subtitle, group: r.type, action: () => { router.push(r.url) setOpen(false) }, })) setResults(actions) }) .catch((err) => { if (err.name !== 'AbortError') { console.error('Search failed:', err) } }) .finally(() => setIsLoading(false)) return () => controller.abort() }, [debouncedSearch, router, setOpen]) // Static navigation actions const navigationActions: CommandAction[] = React.useMemo( () => [ { id: 'nav-dashboard', label: 'Go to Dashboard', group: 'Navigation', action: () => { router.push('/dashboard'); setOpen(false) }, }, { id: 'nav-settings', label: 'Go to Settings', group: 'Navigation', action: () => { router.push('/settings'); setOpen(false) }, }, { id: 'nav-billing', label: 'Go to Billing', group: 'Navigation', action: () => { router.push('/billing'); setOpen(false) }, }, ], [router, setOpen] ) // Quick actions const quickActions: CommandAction[] = React.useMemo( () => [ { id: 'action-new-project', label: 'Create New Project', shortcut: 'βN', group: 'Actions', action: () => { router.push('/projects/new'); setOpen(false) }, }, { id: 'action-invite', label: 'Invite Team Member', shortcut: 'βI', group: 'Actions', action: () => { router.push('/team/invite'); setOpen(false) }, }, ], [router, setOpen] ) // Combine and filter actions const allActions = [...navigationActions, ...quickActions, ...results] const filteredActions = debouncedSearch ? allActions.filter( (action) => action.label.toLowerCase().includes(debouncedSearch.toLowerCase()) || action.description?.toLowerCase().includes(debouncedSearch.toLowerCase()) ) : allActions // Group actions const groupedActions = filteredActions.reduce((acc, action) => { if (!acc[action.group]) acc[action.group] = [] acc[action.group].push(action) return acc }, {} as Record<string, CommandAction[]>) return ( <CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Search or type a command..." value={search} onValueChange={setSearch} /> <CommandList> <CommandEmpty> {isLoading ? 'Searching...' : 'No results found.'} </CommandEmpty> {Object.entries(groupedActions).map(([group, actions]) => ( <React.Fragment key={group}> <CommandGroup heading={group}> {actions.map((action) => ( <CommandItem key={action.id} onSelect={() => action.action()} > {action.icon && ( <span className="mr-2">{action.icon}</span> )} <div className="flex flex-1 items-center justify-between"> <div> <div>{action.label}</div> {action.description && ( <div className="text-xs text-muted-foreground"> {action.description} </div> )} </div> {action.shortcut && ( <Badge variant="outline" className="text-xs"> {action.shortcut} </Badge> )} </div> </CommandItem> ))} </CommandGroup> <CommandSeparator /> </React.Fragment> ))} </CommandList> </CommandDialog> ) }
- useDeferredValue debounces search input β React batches updates and the API call uses the deferred value
- AbortController cancels in-flight requests when the user types more β prevents race conditions
- Static actions (navigation, quick actions) are always available β dynamic results supplement them
- Grouping by type (Navigation, Actions, Search Results) helps users find what they need faster
- The Cmd+K shortcut is a convention β users expect it in every modern web application
| Aspect | shadcn/ui | Material UI | Chakra UI | Ant Design |
|---|---|---|---|---|
| Ownership | You own the code β copy into your project | Dependency β installed via npm | Dependency β installed via npm | Dependency β installed via npm |
| Customization | Modify source directly β no overrides needed | Theme overrides and sx prop | Theme overrides and style props | ConfigProvider and theme tokens |
| Bundle size | Only components you add β no tree-shaking needed | Full library installed, tree-shaken at build | Full library installed, tree-shaken at build | Full library installed, tree-shaken at build |
| Upgrade path | Manual β you diff and merge changes | npm update β automatic | npm update β automatic | npm update β automatic |
| Radix UI primitives | Yes β built on Radix | No β custom implementation | No β custom implementation | No β custom implementation |
| Tailwind CSS | Required β all styles via Tailwind | Not used β Emotion/CSS-in-JS | Not used β Emotion/CSS-in-JS | Not used β Less/CSS-in-JS |
| TypeScript | Full support β generated types | Full support | Full support | Full support |
| Accessibility | Radix handles ARIA β strong foundation | Built-in β strong | Built-in β strong | Built-in β moderate |
| Learning curve | Low β standard React patterns | Medium β MUI-specific APIs | Medium β Chakra-specific APIs | Medium β Ant-specific APIs |
| Best for | Teams that want full control | Teams that want a complete system | Teams that want flexibility + opinions | Enterprise teams with design systems |
π― Key Takeaways
- shadcn/ui is your code β modify it directly instead of creating wrapper components
- Compound components eliminate prop drilling β parent owns state, children consume via context
- react-hook-form + Zod is the standard for shadcn/ui forms β not useState
- Server-side data tables require manualPagination, manualSorting, manualFiltering β the table does not process data
- Promise-based dialogs prevent stacking β one dialog at a time, awaitable results
- CSS variables enable runtime theme switching β never hardcode colors in shadcn/ui components
β Common Mistakes to Avoid
Interview Questions on This Topic
- QHow does shadcn/ui differ from traditional component libraries like Material UI? What are the trade-offs?Mid-levelReveal
- QA form built with shadcn/ui and react-hook-form is not displaying validation errors. The form submits successfully but errors from the server are not shown to the user. How would you debug this?SeniorReveal
- QWhat is the cn() utility in shadcn/ui and why is it necessary?JuniorReveal
- QHow would you build a white-label application using shadcn/ui's theming system?Mid-levelReveal
Frequently Asked Questions
Where is the cn() utility defined and why is it required everywhere?
It lives in lib/utils.ts and is the single source of truth for safe Tailwind class merging.
// lib/utils.ts import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
Without cn(), Tailwind class conflicts are resolved unpredictably by CSS specificity. Always use cn() when extending shadcn/ui components.
Is shadcn/ui suitable for production applications?
Yes. shadcn/ui is built on Radix UI primitives, which are production-grade and used by companies like Vercel, Linear, and GitHub. The components follow WAI-ARIA patterns for accessibility. The copy-paste model means you own the code and can fix issues immediately without waiting for a dependency update.
How do I update shadcn/ui components when new versions are released?
There is no automatic update mechanism β this is by design. You must manually compare your component files with the latest shadcn/ui source and merge changes. The npx shadcn-ui@latest diff command shows what changed. Some teams use git diff tools to track upstream changes.
Can I use shadcn/ui without Tailwind CSS?
No. shadcn/ui components are styled entirely with Tailwind CSS utilities. The cn() utility depends on tailwind-merge. If your project uses a different CSS solution, shadcn/ui is not compatible without significant modification.
How does shadcn/ui handle accessibility?
shadcn/ui builds on Radix UI primitives, which implement WAI-ARIA patterns for keyboard navigation, screen reader support, and focus management. The accessibility is handled at the primitive level β shadcn/ui adds visual styling on top. You get accessible components by default without additional configuration.
What is the difference between shadcn/ui and Radix UI?
Radix UI provides unstyled, accessible primitives β they handle behavior and accessibility but have no visual styling. shadcn/ui adds Tailwind CSS styling on top of Radix UI primitives and packages them as copy-paste components. You could use Radix UI directly and style it yourself, but shadcn/ui provides a well-designed starting point.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.