{"slug":"shadcn-and-tailwind-patterns","title":"ShadCN/ui and Tailwind: Component Patterns and Named CSS Classes","tags":["shadcn","tailwind","ui","components","typescript","nextjs"],"agent_summary":"ShadCN/ui component patterns for Next.js — installation, custom component variants, form integration with react-hook-form and Zod, data tables, dark mode, and the named CSS class convention (never raw Tailwind in TSX).","trigger_phrases":["ShadCN","shadcn/ui","Tailwind components","ShadCN form","ShadCN table","ShadCN dialog","ShadCN installation","ui components"],"runnable":false,"markdown":"\n## Overview\n\nShadCN/ui provides accessible, unstyled-by-default components built on Radix UI primitives. The styling convention: named CSS classes in `globals.css`, not raw Tailwind utilities scattered in TSX files.\n\n## Installation\n\n```bash\nnpx shadcn@latest init\n\n# Add components as needed\nnpx shadcn@latest add button\nnpx shadcn@latest add form\nnpx shadcn@latest add input\nnpx shadcn@latest add dialog\nnpx shadcn@latest add table\nnpx shadcn@latest add select\nnpx shadcn@latest add card\nnpx shadcn@latest add badge\nnpx shadcn@latest add toast\n```\n\n## Named CSS Class Convention\n\n**Never** use raw Tailwind in TSX — extract to named classes in `globals.css`:\n\n```css\n/* globals.css */\n\n/* Page layouts */\n.page-container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8; }\n.page-header { @apply mb-8 flex items-center justify-between; }\n.page-title { @apply text-2xl font-bold text-gray-900; }\n\n/* Cards */\n.card-grid { @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6; }\n.stat-card { @apply rounded-lg border bg-white p-6 shadow-sm; }\n.stat-value { @apply text-3xl font-bold text-gray-900; }\n.stat-label { @apply text-sm text-gray-500 mt-1; }\n\n/* Forms */\n.form-section { @apply space-y-6; }\n.form-field { @apply space-y-2; }\n.field-label { @apply text-sm font-medium text-gray-700; }\n.field-error { @apply text-sm text-red-600 mt-1; }\n\n/* Tables */\n.data-table { @apply w-full border-collapse; }\n.table-header-row { @apply border-b bg-gray-50; }\n.table-header-cell { @apply px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase; }\n.table-row { @apply border-b hover:bg-gray-50 transition-colors; }\n.table-cell { @apply px-4 py-3 text-sm text-gray-900; }\n\n/* Buttons */\n.btn-primary { @apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }\n.btn-danger { @apply bg-red-600 hover:bg-red-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }\n.btn-ghost { @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md px-3 py-2 transition-colors; }\n```\n\n## Form with react-hook-form + Zod\n\n```typescript\n\"use client\";\nimport { useForm } from \"react-hook-form\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { z } from \"zod\";\nimport { Button } from \"@/components/ui/button\";\nimport { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nconst FormSchema = z.object({\n  name: z.string().min(2, \"Name must be at least 2 characters\"),\n  email: z.string().email(\"Invalid email address\"),\n});\n\ntype FormValues = z.infer<typeof FormSchema>;\n\nexport function ContactForm() {\n  const form = useForm<FormValues>({\n    resolver: zodResolver(FormSchema),\n    defaultValues: { name: \"\", email: \"\" },\n  });\n\n  async function onSubmit(values: FormValues) {\n    const response = await fetch(\"/api/contacts\", {\n      method: \"POST\",\n      body: JSON.stringify(values),\n    });\n    if (!response.ok) {\n      form.setError(\"root\", { message: \"Submission failed\" });\n    }\n  }\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"form-section\">\n        <FormField\n          control={form.control}\n          name=\"name\"\n          render={({ field }) => (\n            <FormItem className=\"form-field\">\n              <FormLabel className=\"field-label\">Name</FormLabel>\n              <FormControl>\n                <Input {...field} />\n              </FormControl>\n              <FormMessage className=\"field-error\" />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"email\"\n          render={({ field }) => (\n            <FormItem className=\"form-field\">\n              <FormLabel className=\"field-label\">Email</FormLabel>\n              <FormControl>\n                <Input type=\"email\" {...field} />\n              </FormControl>\n              <FormMessage className=\"field-error\" />\n            </FormItem>\n          )}\n        />\n        <Button type=\"submit\" disabled={form.formState.isSubmitting}>\n          {form.formState.isSubmitting ? \"Submitting...\" : \"Submit\"}\n        </Button>\n      </form>\n    </Form>\n  );\n}\n```\n\n## Data Table with TanStack Table\n\n```typescript\n\"use client\";\nimport {\n  useReactTable,\n  getCoreRowModel,\n  getSortedRowModel,\n  getFilteredRowModel,\n  flexRender,\n  type ColumnDef,\n} from \"@tanstack/react-table\";\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from \"@/components/ui/table\";\nimport { Input } from \"@/components/ui/input\";\nimport { useState } from \"react\";\n\ninterface Project { id: string; name: string; status: string; createdAt: string; }\n\nconst columns: ColumnDef<Project>[] = [\n  { accessorKey: \"name\", header: \"Project Name\" },\n  { accessorKey: \"status\", header: \"Status\",\n    cell: ({ row }) => <span className=\"badge-status\">{row.original.status}</span> },\n  { accessorKey: \"createdAt\", header: \"Created\",\n    cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString() },\n];\n\nexport function ProjectTable({ data }: { data: Project[] }) {\n  const [filter, setFilter] = useState(\"\");\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    state: { globalFilter: filter },\n    onGlobalFilterChange: setFilter,\n  });\n\n  return (\n    <div className=\"space-y-4\">\n      <Input\n        placeholder=\"Search projects...\"\n        value={filter}\n        onChange={(e) => setFilter(e.target.value)}\n        className=\"max-w-sm\"\n      />\n      <Table className=\"data-table\">\n        <TableHeader>\n          {table.getHeaderGroups().map((hg) => (\n            <TableRow key={hg.id} className=\"table-header-row\">\n              {hg.headers.map((h) => (\n                <TableHead key={h.id} className=\"table-header-cell\">\n                  {flexRender(h.column.columnDef.header, h.getContext())}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {table.getRowModel().rows.map((row) => (\n            <TableRow key={row.id} className=\"table-row\">\n              {row.getVisibleCells().map((cell) => (\n                <TableCell key={cell.id} className=\"table-cell\">\n                  {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                </TableCell>\n              ))}\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n```\n\n## Dialog/Modal Pattern\n\n```typescript\nimport { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function ConfirmDeleteDialog({ onConfirm }: { onConfirm: () => void }) {\n  return (\n    <Dialog>\n      <DialogTrigger asChild>\n        <Button variant=\"destructive\" size=\"sm\">Delete</Button>\n      </DialogTrigger>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Are you sure?</DialogTitle>\n        </DialogHeader>\n        <p className=\"text-sm text-gray-600\">This action cannot be undone.</p>\n        <div className=\"flex gap-3 mt-4\">\n          <Button variant=\"destructive\" onClick={onConfirm}>Delete</Button>\n          <Button variant=\"outline\">Cancel</Button>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n```\n\n## Toast Notifications\n\n```typescript\n// In your root layout, add: <Toaster />\nimport { Toaster } from \"@/components/ui/toaster\";\n\n// In components:\nimport { useToast } from \"@/components/ui/use-toast\";\n\nfunction MyComponent() {\n  const { toast } = useToast();\n\n  function handleSuccess() {\n    toast({\n      title: \"Success\",\n      description: \"Project created successfully.\",\n    });\n  }\n\n  function handleError() {\n    toast({\n      title: \"Error\",\n      description: \"Something went wrong.\",\n      variant: \"destructive\",\n    });\n  }\n}\n```\n\n## Component Variant Pattern (cva)\n\n```typescript\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst badgeVariants = cva(\n  \"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium\",\n  {\n    variants: {\n      variant: {\n        active: \"bg-green-100 text-green-800\",\n        inactive: \"bg-gray-100 text-gray-800\",\n        pending: \"bg-yellow-100 text-yellow-800\",\n        error: \"bg-red-100 text-red-800\",\n      },\n    },\n    defaultVariants: { variant: \"active\" },\n  }\n);\n\ninterface BadgeProps extends VariantProps<typeof badgeVariants> {\n  children: React.ReactNode;\n  className?: string;\n}\n\nexport function StatusBadge({ variant, children, className }: BadgeProps) {\n  return (\n    <span className={cn(badgeVariants({ variant }), className)}>\n      {children}\n    </span>\n  );\n}\n```\n","html":"<h2>Overview</h2>\n<p>ShadCN/ui provides accessible, unstyled-by-default components built on Radix UI primitives. The styling convention: named CSS classes in <code>globals.css</code>, not raw Tailwind utilities scattered in TSX files.</p>\n<h2>Installation</h2>\n<pre><code class=\"language-bash\">npx shadcn@latest init\n\n# Add components as needed\nnpx shadcn@latest add button\nnpx shadcn@latest add form\nnpx shadcn@latest add input\nnpx shadcn@latest add dialog\nnpx shadcn@latest add table\nnpx shadcn@latest add select\nnpx shadcn@latest add card\nnpx shadcn@latest add badge\nnpx shadcn@latest add toast\n</code></pre>\n<h2>Named CSS Class Convention</h2>\n<p><strong>Never</strong> use raw Tailwind in TSX — extract to named classes in <code>globals.css</code>:</p>\n<pre><code class=\"language-css\">/* globals.css */\n\n/* Page layouts */\n.page-container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8; }\n.page-header { @apply mb-8 flex items-center justify-between; }\n.page-title { @apply text-2xl font-bold text-gray-900; }\n\n/* Cards */\n.card-grid { @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6; }\n.stat-card { @apply rounded-lg border bg-white p-6 shadow-sm; }\n.stat-value { @apply text-3xl font-bold text-gray-900; }\n.stat-label { @apply text-sm text-gray-500 mt-1; }\n\n/* Forms */\n.form-section { @apply space-y-6; }\n.form-field { @apply space-y-2; }\n.field-label { @apply text-sm font-medium text-gray-700; }\n.field-error { @apply text-sm text-red-600 mt-1; }\n\n/* Tables */\n.data-table { @apply w-full border-collapse; }\n.table-header-row { @apply border-b bg-gray-50; }\n.table-header-cell { @apply px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase; }\n.table-row { @apply border-b hover:bg-gray-50 transition-colors; }\n.table-cell { @apply px-4 py-3 text-sm text-gray-900; }\n\n/* Buttons */\n.btn-primary { @apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }\n.btn-danger { @apply bg-red-600 hover:bg-red-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }\n.btn-ghost { @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md px-3 py-2 transition-colors; }\n</code></pre>\n<h2>Form with react-hook-form + Zod</h2>\n<pre><code class=\"language-typescript\">\"use client\";\nimport { useForm } from \"react-hook-form\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { z } from \"zod\";\nimport { Button } from \"@/components/ui/button\";\nimport { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nconst FormSchema = z.object({\n  name: z.string().min(2, \"Name must be at least 2 characters\"),\n  email: z.string().email(\"Invalid email address\"),\n});\n\ntype FormValues = z.infer&#x3C;typeof FormSchema>;\n\nexport function ContactForm() {\n  const form = useForm&#x3C;FormValues>({\n    resolver: zodResolver(FormSchema),\n    defaultValues: { name: \"\", email: \"\" },\n  });\n\n  async function onSubmit(values: FormValues) {\n    const response = await fetch(\"/api/contacts\", {\n      method: \"POST\",\n      body: JSON.stringify(values),\n    });\n    if (!response.ok) {\n      form.setError(\"root\", { message: \"Submission failed\" });\n    }\n  }\n\n  return (\n    &#x3C;Form {...form}>\n      &#x3C;form onSubmit={form.handleSubmit(onSubmit)} className=\"form-section\">\n        &#x3C;FormField\n          control={form.control}\n          name=\"name\"\n          render={({ field }) => (\n            &#x3C;FormItem className=\"form-field\">\n              &#x3C;FormLabel className=\"field-label\">Name&#x3C;/FormLabel>\n              &#x3C;FormControl>\n                &#x3C;Input {...field} />\n              &#x3C;/FormControl>\n              &#x3C;FormMessage className=\"field-error\" />\n            &#x3C;/FormItem>\n          )}\n        />\n        &#x3C;FormField\n          control={form.control}\n          name=\"email\"\n          render={({ field }) => (\n            &#x3C;FormItem className=\"form-field\">\n              &#x3C;FormLabel className=\"field-label\">Email&#x3C;/FormLabel>\n              &#x3C;FormControl>\n                &#x3C;Input type=\"email\" {...field} />\n              &#x3C;/FormControl>\n              &#x3C;FormMessage className=\"field-error\" />\n            &#x3C;/FormItem>\n          )}\n        />\n        &#x3C;Button type=\"submit\" disabled={form.formState.isSubmitting}>\n          {form.formState.isSubmitting ? \"Submitting...\" : \"Submit\"}\n        &#x3C;/Button>\n      &#x3C;/form>\n    &#x3C;/Form>\n  );\n}\n</code></pre>\n<h2>Data Table with TanStack Table</h2>\n<pre><code class=\"language-typescript\">\"use client\";\nimport {\n  useReactTable,\n  getCoreRowModel,\n  getSortedRowModel,\n  getFilteredRowModel,\n  flexRender,\n  type ColumnDef,\n} from \"@tanstack/react-table\";\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from \"@/components/ui/table\";\nimport { Input } from \"@/components/ui/input\";\nimport { useState } from \"react\";\n\ninterface Project { id: string; name: string; status: string; createdAt: string; }\n\nconst columns: ColumnDef&#x3C;Project>[] = [\n  { accessorKey: \"name\", header: \"Project Name\" },\n  { accessorKey: \"status\", header: \"Status\",\n    cell: ({ row }) => &#x3C;span className=\"badge-status\">{row.original.status}&#x3C;/span> },\n  { accessorKey: \"createdAt\", header: \"Created\",\n    cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString() },\n];\n\nexport function ProjectTable({ data }: { data: Project[] }) {\n  const [filter, setFilter] = useState(\"\");\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    state: { globalFilter: filter },\n    onGlobalFilterChange: setFilter,\n  });\n\n  return (\n    &#x3C;div className=\"space-y-4\">\n      &#x3C;Input\n        placeholder=\"Search projects...\"\n        value={filter}\n        onChange={(e) => setFilter(e.target.value)}\n        className=\"max-w-sm\"\n      />\n      &#x3C;Table className=\"data-table\">\n        &#x3C;TableHeader>\n          {table.getHeaderGroups().map((hg) => (\n            &#x3C;TableRow key={hg.id} className=\"table-header-row\">\n              {hg.headers.map((h) => (\n                &#x3C;TableHead key={h.id} className=\"table-header-cell\">\n                  {flexRender(h.column.columnDef.header, h.getContext())}\n                &#x3C;/TableHead>\n              ))}\n            &#x3C;/TableRow>\n          ))}\n        &#x3C;/TableHeader>\n        &#x3C;TableBody>\n          {table.getRowModel().rows.map((row) => (\n            &#x3C;TableRow key={row.id} className=\"table-row\">\n              {row.getVisibleCells().map((cell) => (\n                &#x3C;TableCell key={cell.id} className=\"table-cell\">\n                  {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                &#x3C;/TableCell>\n              ))}\n            &#x3C;/TableRow>\n          ))}\n        &#x3C;/TableBody>\n      &#x3C;/Table>\n    &#x3C;/div>\n  );\n}\n</code></pre>\n<h2>Dialog/Modal Pattern</h2>\n<pre><code class=\"language-typescript\">import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function ConfirmDeleteDialog({ onConfirm }: { onConfirm: () => void }) {\n  return (\n    &#x3C;Dialog>\n      &#x3C;DialogTrigger asChild>\n        &#x3C;Button variant=\"destructive\" size=\"sm\">Delete&#x3C;/Button>\n      &#x3C;/DialogTrigger>\n      &#x3C;DialogContent>\n        &#x3C;DialogHeader>\n          &#x3C;DialogTitle>Are you sure?&#x3C;/DialogTitle>\n        &#x3C;/DialogHeader>\n        &#x3C;p className=\"text-sm text-gray-600\">This action cannot be undone.&#x3C;/p>\n        &#x3C;div className=\"flex gap-3 mt-4\">\n          &#x3C;Button variant=\"destructive\" onClick={onConfirm}>Delete&#x3C;/Button>\n          &#x3C;Button variant=\"outline\">Cancel&#x3C;/Button>\n        &#x3C;/div>\n      &#x3C;/DialogContent>\n    &#x3C;/Dialog>\n  );\n}\n</code></pre>\n<h2>Toast Notifications</h2>\n<pre><code class=\"language-typescript\">// In your root layout, add: &#x3C;Toaster />\nimport { Toaster } from \"@/components/ui/toaster\";\n\n// In components:\nimport { useToast } from \"@/components/ui/use-toast\";\n\nfunction MyComponent() {\n  const { toast } = useToast();\n\n  function handleSuccess() {\n    toast({\n      title: \"Success\",\n      description: \"Project created successfully.\",\n    });\n  }\n\n  function handleError() {\n    toast({\n      title: \"Error\",\n      description: \"Something went wrong.\",\n      variant: \"destructive\",\n    });\n  }\n}\n</code></pre>\n<h2>Component Variant Pattern (cva)</h2>\n<pre><code class=\"language-typescript\">import { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst badgeVariants = cva(\n  \"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium\",\n  {\n    variants: {\n      variant: {\n        active: \"bg-green-100 text-green-800\",\n        inactive: \"bg-gray-100 text-gray-800\",\n        pending: \"bg-yellow-100 text-yellow-800\",\n        error: \"bg-red-100 text-red-800\",\n      },\n    },\n    defaultVariants: { variant: \"active\" },\n  }\n);\n\ninterface BadgeProps extends VariantProps&#x3C;typeof badgeVariants> {\n  children: React.ReactNode;\n  className?: string;\n}\n\nexport function StatusBadge({ variant, children, className }: BadgeProps) {\n  return (\n    &#x3C;span className={cn(badgeVariants({ variant }), className)}>\n      {children}\n    &#x3C;/span>\n  );\n}\n</code></pre>\n"}