Skip to content
Homeβ€Ί JavaScriptβ€Ί Advanced shadcn/ui Patterns Every Developer Should Know in 2026

Advanced shadcn/ui Patterns Every Developer Should Know in 2026

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 22 of 23
Level up your UI game with 8 advanced shadcn/ui patterns and techniques used in production applications.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Level up your UI game with 8 advanced shadcn/ui patterns and techniques used in production applications.
  • 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
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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
🚨 START HERE
shadcn/ui Quick Debug Reference
Fast commands for diagnosing shadcn/ui issues
🟑Component styles missing after installation
Immediate ActionVerify component was added to correct directory
Commands
ls components/ui/ | grep -i 'button\|dialog\|form'
cat tailwind.config.ts | grep -A5 content
Fix NowRun npx shadcn-ui@latest add <component> and check components/ui/ directory
🟑CSS variables not resolving
Immediate ActionCheck globals.css for variable definitions
Commands
grep -n ':root' app/globals.css
grep -c '--primary\|--background\|--foreground' app/globals.css
Fix NowRun npx shadcn-ui@latest init to regenerate CSS variable definitions
🟑TypeScript errors on component props
Immediate ActionCheck Radix UI primitive types
Commands
npm ls @radix-ui/react-dialog @radix-ui/react-select
cat node_modules/@radix-ui/react-dialog/package.json | grep version
Fix NowUpdate Radix UI dependencies: npm update @radix-ui/*
🟑Hydration mismatch with shadcn/ui components
Immediate ActionCheck for client-only code in server components
Commands
grep -rn 'useState\|useEffect\|window\.' components/ui/ --include='*.tsx'
grep -rn '"use client"' components/ui/ --include='*.tsx'
Fix NowAdd 'use client' directive to components using browser APIs or React hooks
Production Incidentshadcn/ui Dialog State Race Condition Crashed the Checkout FlowA SaaS checkout page using shadcn/ui Dialog and Form components had a race condition where rapid button clicks opened multiple dialogs simultaneously, causing state corruption and duplicate payment submissions.
SymptomUsers reported double charges on their credit cards. Server logs showed two identical payment intents created within 50ms of each other. Frontend monitoring showed multiple Dialog components mounting simultaneously on the same page.
AssumptionThe Dialog component's open/onOpenChange state management would prevent multiple dialogs from opening simultaneously.
Root causeThe checkout page had three independent Dialog components β€” one for payment, one for confirmation, and one for error handling. Each managed its own open state via useState. When users clicked the pay button rapidly, the payment Dialog opened, but the click event also propagated to the confirmation Dialog's trigger. Because each Dialog had independent state, both opened simultaneously. The payment Dialog's form submitted, and the confirmation Dialog's confirm button also triggered a second submission β€” both hit the Stripe API before the first response returned.
FixReplaced independent Dialog state with a single useReducer that manages a dialog stack. Only one dialog can be open at a time β€” opening a new dialog automatically closes the previous one. Added a submission lock via useRef that prevents form submission while a request is in flight. Added idempotency keys to the Stripe API calls so duplicate submissions are rejected server-side.
Key Lesson
Independent Dialog state allows multiple dialogs to open simultaneously β€” use a centralized dialog managerForm submission must be locked while a request is in flight β€” use useRef for the lock, not useState (state updates are async)Server-side idempotency keys are the last line of defense against duplicate submissionsTest rapid-click scenarios β€” automated tests that click once miss race conditions
Production Debug GuideCommon issues when building with shadcn/ui in production
Tailwind classes not applying to shadcn/ui components→Check tailwind.config content paths include the component directory — shadcn/ui components must be in the content scan paths
Dialog or Popover renders behind other elements→Check z-index stacking context — shadcn/ui uses z-50 for portals, but parent containers with position:relative and z-index create new stacking contexts
Form validation errors not displaying→Verify FormMessage component is rendered inside FormField — it reads errors from FormField context, not from useForm directly
Select or Combobox dropdown scrolls with the page instead of staying fixed→Ensure the SelectContent or PopoverContent uses a portal — check that the component is not inside an overflow:hidden container
Theme colors not applying in dark mode→Verify CSS variables are defined under both :root and .dark selectors — shadcn/ui theming relies on CSS variables, not Tailwind color utilities

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.

io.thecodeforge.shadcn.compound-components.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
// ============================================
// 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">&#10003;</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>
  )
}
Mental Model
Compound Components as a State Distribution Pattern
Compound components decouple state management from UI rendering β€” the parent owns state, children consume it via context.
  • 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
πŸ“Š Production Insight
Compound components eliminate prop drilling but create implicit dependencies through context.
If a child renders outside the parent, it throws a runtime error β€” not a compile-time error.
Rule: always throw a descriptive error in useXxxContext() when context is null.
🎯 Key Takeaway
Compound components decouple state from rendering β€” parent owns state, children consume via context.
The pattern eliminates prop drilling and enables flexible component composition.
Always throw descriptive errors when context is missing β€” catch misuse at runtime.

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.

io.thecodeforge.shadcn.form-patterns.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
// ============================================
// 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>
  )
}
⚠ Form State Anti-Patterns
πŸ“Š Production Insight
react-hook-form reduces re-renders by 90% compared to useState-based forms.
But useWatch triggers re-renders on every change for watched fields.
Rule: watch only fields that drive computed values β€” let react-hook-form handle the rest.
🎯 Key Takeaway
shadcn/ui Form wraps react-hook-form β€” Field handles state, FormMessage handles errors.
Zod schemas drive both validation and TypeScript types β€” single source of truth.
Server-side errors must be explicitly set via form.setError() β€” they do not appear automatically.

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.

io.thecodeforge.shadcn.data-table.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
// ============================================
// 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>
  )
}
πŸ’‘Server-Side vs Client-Side Data Table
  • 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
πŸ“Š Production Insight
Client-side tables break at 10,000+ rows β€” the browser freezes during sorting and filtering.
Server-side tables require manual state synchronization β€” sorting, filtering, and pagination state must be sent to the API.
Rule: use server-side mode for any dataset that could exceed 10,000 rows.
🎯 Key Takeaway
TanStack Table handles table logic β€” shadcn/ui handles table UI. The separation is clean.
Server-side mode requires manualPagination, manualSorting, manualFiltering β€” the table does not process data.
Debounce filter inputs by 300ms β€” prevents excessive API calls during typing.

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.

io.thecodeforge.shadcn.dialog-manager.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
// ============================================
// 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>
  )
}
Mental Model
Dialogs as Async Promises
Promise-based dialogs turn modal interactions into awaitable async flows β€” the calling code reads like synchronous logic.
  • 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
πŸ“Š Production Insight
Independent dialog state allows multiple dialogs to stack β€” causes z-index conflicts and user confusion.
Promise-based dialogs turn modal flows into async functions β€” the calling code awaits the result.
Rule: centralize dialog state in a provider β€” never manage dialog open/close in individual components.
🎯 Key Takeaway
Promise-based dialogs turn modal interactions into awaitable async flows.
One dialog at a time β€” the provider enforces this constraint and prevents stacking.
The calling code reads like synchronous logic β€” await dialog.open() returns true or false.

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.

io.thecodeforge.shadcn.toast-service.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
// ============================================
// 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',
    }
  )
}
πŸ”₯Toast Lifecycle Management
  • 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()
πŸ“Š Production Insight
Toast spam is a real UX problem β€” rapid API calls can stack dozens of identical toasts.
Deduplication by message content prevents the same toast from appearing multiple times.
Rule: wrap sonner's toast() in a service layer that handles deduplication and lifecycle.
🎯 Key Takeaway
Wrap sonner's toast() in a service layer β€” handle deduplication, prioritization, and lifecycle.
Loading toasts must resolve to success or error β€” never leave a loading toast hanging.
Error toasts stay longer than success toasts β€” users need time to read error messages.

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.

io.thecodeforge.shadcn.theme-switcher.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
// ============================================
// 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>
  )
}
Mental Model
CSS Variables as a Theming API
CSS variables separate the design system from the component library β€” change the variables, change every component.
  • 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
πŸ“Š Production Insight
CSS variables enable runtime theme switching β€” no rebuild, no Tailwind config changes.
Brand themes as parent class overrides scope the theme to a container β€” useful for white-label apps.
Rule: never hardcode colors in shadcn/ui components β€” always use CSS variable references.
🎯 Key Takeaway
shadcn/ui theming uses CSS variables, not Tailwind config β€” runtime switching without rebuilds.
Brand themes are CSS variable overrides scoped by parent class β€” perfect for white-label applications.
Never hardcode colors β€” always reference CSS variables via Tailwind utilities like bg-background.

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.

io.thecodeforge.shadcn.slot-composition.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
// ============================================
// 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>
      }
    />
  )
}
πŸ’‘When to Use Slots vs Children
  • 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
πŸ“Š Production Insight
Named slots eliminate the need for multiple card variants β€” one component handles all layouts.
Without slots, teams create ProductCard, TeamCard, ArticleCard β€” all duplicating the same shell.
Rule: build one slot-based card component, compose content per use case.
🎯 Key Takeaway
Named slots let consumers inject content into predefined positions β€” header, content, footer, actions.
One slot-based component replaces multiple variant components β€” less code, more flexibility.
Each slot should be optional with sensible defaults β€” the component works even with no slots.

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.

io.thecodeforge.shadcn.command-palette.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
// ============================================
// 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>
  )
}
Mental Model
Command Palette as a Universal Interface
The command palette is the keyboard-first interface for navigation, actions, and search β€” one input, infinite possibilities.
  • 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
πŸ“Š Production Insight
Command palette search without AbortController causes race conditions β€” slow responses overwrite fast ones.
useDeferredValue prevents excessive API calls β€” React batches rapid keystrokes into a single search.
Rule: always use AbortController with search APIs β€” cancel previous requests when new ones start.
🎯 Key Takeaway
The command palette is the keyboard-first interface β€” Cmd+K opens it, typing searches it.
useDeferredValue + AbortController prevents race conditions and excessive API calls.
Group static actions and dynamic results separately β€” users need to distinguish navigation from search.
πŸ—‚ shadcn/ui vs Traditional Component Libraries
Ownership, flexibility, and maintenance comparison
Aspectshadcn/uiMaterial UIChakra UIAnt Design
OwnershipYou own the code β€” copy into your projectDependency β€” installed via npmDependency β€” installed via npmDependency β€” installed via npm
CustomizationModify source directly β€” no overrides neededTheme overrides and sx propTheme overrides and style propsConfigProvider and theme tokens
Bundle sizeOnly components you add β€” no tree-shaking neededFull library installed, tree-shaken at buildFull library installed, tree-shaken at buildFull library installed, tree-shaken at build
Upgrade pathManual β€” you diff and merge changesnpm update β€” automaticnpm update β€” automaticnpm update β€” automatic
Radix UI primitivesYes β€” built on RadixNo β€” custom implementationNo β€” custom implementationNo β€” custom implementation
Tailwind CSSRequired β€” all styles via TailwindNot used β€” Emotion/CSS-in-JSNot used β€” Emotion/CSS-in-JSNot used β€” Less/CSS-in-JS
TypeScriptFull support β€” generated typesFull supportFull supportFull support
AccessibilityRadix handles ARIA β€” strong foundationBuilt-in β€” strongBuilt-in β€” strongBuilt-in β€” moderate
Learning curveLow β€” standard React patternsMedium β€” MUI-specific APIsMedium β€” Chakra-specific APIsMedium β€” Ant-specific APIs
Best forTeams that want full controlTeams that want a complete systemTeams that want flexibility + opinionsEnterprise 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

    βœ•Treating shadcn/ui as a black-box dependency
    Symptom

    Developers avoid modifying shadcn/ui components, creating wrapper components around them instead of editing the source directly. This adds unnecessary abstraction layers and makes debugging harder.

    Fix

    Edit the component source directly β€” it is your code, not a dependency. Modify the component in components/ui/ to match your requirements. The copy-paste model means you own every line.

    βœ•Not using the cn() utility for class merging
    Symptom

    Tailwind class conflicts when extending shadcn/ui components β€” custom classes are overridden by component defaults or vice versa, causing inconsistent styling.

    Fix

    Always use cn() from @/lib/utils to merge classes. It uses clsx and tailwind-merge to handle conflicts. // WRONG: <Button className={bg-red-500 ${className}}> // RIGHT: <Button className={cn('bg-red-500', className)}>

    βœ•Using controlled state (useState) instead of react-hook-form for forms
    Symptom

    Every keystroke triggers a re-render β€” forms with 10+ fields become noticeably slow. Validation errors do not display because FormMessage reads from react-hook-form context, not useState.

    Fix

    Use react-hook-form with zodResolver for all forms. FormField, FormControl, and FormMessage integrate with react-hook-form β€” not with useState.

    βœ•Independent Dialog state for multiple dialogs
    Symptom

    Multiple dialogs can open simultaneously β€” z-index conflicts, overlapping modals, and confusing user experience. Rapid button clicks trigger race conditions.

    Fix

    Centralize dialog state in a provider β€” only one dialog can be open at a time. Use a Promise-based API so calling code can await the dialog result.

    βœ•Hardcoding colors instead of using CSS variables
    Symptom

    Theme switching does not work β€” components keep their hardcoded colors when the user switches between light and dark mode. Brand theme overrides have no effect.

    Fix

    Always use CSS variable references via Tailwind utilities: bg-background, text-foreground, border-border. Never hardcode hex or RGB values in component styles.

    βœ•Client-side data table for large datasets
    Symptom

    Browser freezes when sorting or filtering 50,000 rows β€” the entire dataset is loaded into memory and processed in the main thread.

    Fix

    Use server-side mode (manualPagination, manualSorting, manualFiltering) for any dataset that could exceed 10,000 rows. The table should only hold the current page of data.

Interview Questions on This Topic

  • QHow does shadcn/ui differ from traditional component libraries like Material UI? What are the trade-offs?Mid-levelReveal
    shadcn/ui uses a copy-paste model β€” you copy source code into your project and own every line. Material UI is a dependency β€” you install it via npm and consume its API. Advantages of shadcn/ui: - Full ownership: modify any component without fighting override APIs - Zero bundle bloat: only the components you add are in your project - Built on Radix UI primitives: strong accessibility foundation - Tailwind CSS integration: utility-first styling, no CSS-in-JS runtime Trade-offs: - Manual upgrades: you must diff and merge when shadcn/ui updates β€” no npm update - No centralized bug fixes: bugs in your copy must be fixed by you - Requires Tailwind CSS: teams using other CSS solutions cannot use it - More initial setup: each component must be individually added and configured The right choice depends on team size and control requirements. Small teams that want full control benefit most from shadcn/ui. Large teams that want automatic upgrades and centralized support may prefer Material UI.
  • 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
    This is a common integration issue with three likely causes: 1. FormMessage is outside FormField: FormMessage reads errors from FormField context, not from useForm() directly. Verify that FormMessage is rendered inside the same FormField as the input it should display errors for. 2. Server errors are not set via form.setError(): When the server returns validation errors, they must be explicitly set on the form using form.setError(fieldName, { type: 'server', message: errorMessage }). The form does not automatically detect server-side errors. 3. Zod schema does not match the server error format: If the server returns errors in a different shape than the Zod schema expects, the error paths do not match field names. Map server error paths to form field names before calling form.setError(). Debugging steps: - Log form.formState.errors to verify errors are registered - Check that FormMessage is rendered inside FormField - Verify the server error response format matches what form.setError() expects - Add a catch block to handleSubmit that calls form.setError() for each server error Check formState.isSubmitting β€” if true, the form is waiting for server response.
  • QWhat is the cn() utility in shadcn/ui and why is it necessary?JuniorReveal
    cn() is a utility function that combines clsx and tailwind-merge. It solves two problems: 1. Conditional class names: clsx allows conditional classes like cn('base-class', isActive && 'active-class', className). Without clsx, you would need manual string concatenation. 2. Tailwind class conflicts: tailwind-merge resolves conflicting Tailwind utilities. For example, cn('bg-red-500', 'bg-blue-500') returns 'bg-blue-500' β€” the last class wins. Without tailwind-merge, both classes would be applied and the result depends on CSS specificity, which is unpredictable. The cn() utility is essential for shadcn/ui because components accept a className prop that merges with default classes. Without cn(), custom classes would conflict with component defaults. With cn(), the merge is predictable and the consumer's classes take precedence.
  • QHow would you build a white-label application using shadcn/ui's theming system?Mid-levelReveal
    shadcn/ui uses CSS variables for theming, which enables runtime theme switching. For a white-label application: 1. Define brand themes as CSS variable overrides: Create theme classes (e.g., .theme-brand-ocean, .theme-brand-forest) that override the default CSS variables. Each brand gets its own primary, secondary, accent, and ring colors. 2. Scope themes to containers: Apply the theme class to a parent container, not the root element. This means different parts of the application can have different brands β€” useful for multi-tenant SaaS where each customer has their own theme. 3. Store brand preference in the database: Load the customer's brand theme on login and apply it to the root container. Use React Context to manage the active theme and provide a theme switcher for admins. 4. Use CSS variables everywhere: Ensure all custom components use CSS variable references (bg-background, text-foreground) instead of hardcoded colors. This ensures brand themes affect all components, not just shadcn/ui primitives. 5. Test with multiple brands: Verify that each brand theme has sufficient contrast ratios for accessibility. Automated tools like axe can check contrast, but manual review is necessary for edge cases.

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.

πŸ”₯
Naren Founder & Author

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.

← PreviousPartial Prerendering in Next.js 16 β€” The Complete GuideNext β†’I Built a SaaS in 48 Hours Using Only v0 + Cursor AI
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged