D
Dev SOPKnowledge Base
Search
← All topics

ShadCN/ui and Tailwind: Component Patterns and Named CSS Classes

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).

shadcntailwinduicomponentstypescriptnextjs
Agent trigger phrases: ShadCN · shadcn/ui · Tailwind components · ShadCN form · ShadCN table · ShadCN dialog · ShadCN installation · ui components

Overview

ShadCN/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.

Installation

npx shadcn@latest init

# Add components as needed
npx shadcn@latest add button
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add dialog
npx shadcn@latest add table
npx shadcn@latest add select
npx shadcn@latest add card
npx shadcn@latest add badge
npx shadcn@latest add toast

Named CSS Class Convention

Never use raw Tailwind in TSX — extract to named classes in globals.css:

/* globals.css */

/* Page layouts */
.page-container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8; }
.page-header { @apply mb-8 flex items-center justify-between; }
.page-title { @apply text-2xl font-bold text-gray-900; }

/* Cards */
.card-grid { @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6; }
.stat-card { @apply rounded-lg border bg-white p-6 shadow-sm; }
.stat-value { @apply text-3xl font-bold text-gray-900; }
.stat-label { @apply text-sm text-gray-500 mt-1; }

/* Forms */
.form-section { @apply space-y-6; }
.form-field { @apply space-y-2; }
.field-label { @apply text-sm font-medium text-gray-700; }
.field-error { @apply text-sm text-red-600 mt-1; }

/* Tables */
.data-table { @apply w-full border-collapse; }
.table-header-row { @apply border-b bg-gray-50; }
.table-header-cell { @apply px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase; }
.table-row { @apply border-b hover:bg-gray-50 transition-colors; }
.table-cell { @apply px-4 py-3 text-sm text-gray-900; }

/* Buttons */
.btn-primary { @apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }
.btn-danger { @apply bg-red-600 hover:bg-red-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }
.btn-ghost { @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md px-3 py-2 transition-colors; }

Form with react-hook-form + Zod

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";

const FormSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
});

type FormValues = z.infer<typeof FormSchema>;

export function ContactForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(FormSchema),
    defaultValues: { name: "", email: "" },
  });

  async function onSubmit(values: FormValues) {
    const response = await fetch("/api/contacts", {
      method: "POST",
      body: JSON.stringify(values),
    });
    if (!response.ok) {
      form.setError("root", { message: "Submission failed" });
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="form-section">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem className="form-field">
              <FormLabel className="field-label">Name</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage className="field-error" />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem className="form-field">
              <FormLabel className="field-label">Email</FormLabel>
              <FormControl>
                <Input type="email" {...field} />
              </FormControl>
              <FormMessage className="field-error" />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "Submitting..." : "Submit"}
        </Button>
      </form>
    </Form>
  );
}

Data Table with TanStack Table

"use client";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  flexRender,
  type ColumnDef,
} from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { useState } from "react";

interface Project { id: string; name: string; status: string; createdAt: string; }

const columns: ColumnDef<Project>[] = [
  { accessorKey: "name", header: "Project Name" },
  { accessorKey: "status", header: "Status",
    cell: ({ row }) => <span className="badge-status">{row.original.status}</span> },
  { accessorKey: "createdAt", header: "Created",
    cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString() },
];

export function ProjectTable({ data }: { data: Project[] }) {
  const [filter, setFilter] = useState("");

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    state: { globalFilter: filter },
    onGlobalFilterChange: setFilter,
  });

  return (
    <div className="space-y-4">
      <Input
        placeholder="Search projects..."
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        className="max-w-sm"
      />
      <Table className="data-table">
        <TableHeader>
          {table.getHeaderGroups().map((hg) => (
            <TableRow key={hg.id} className="table-header-row">
              {hg.headers.map((h) => (
                <TableHead key={h.id} className="table-header-cell">
                  {flexRender(h.column.columnDef.header, h.getContext())}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id} className="table-row">
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id} className="table-cell">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Dialog/Modal Pattern

import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

export function ConfirmDeleteDialog({ onConfirm }: { onConfirm: () => void }) {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive" size="sm">Delete</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Are you sure?</DialogTitle>
        </DialogHeader>
        <p className="text-sm text-gray-600">This action cannot be undone.</p>
        <div className="flex gap-3 mt-4">
          <Button variant="destructive" onClick={onConfirm}>Delete</Button>
          <Button variant="outline">Cancel</Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Toast Notifications

// In your root layout, add: <Toaster />
import { Toaster } from "@/components/ui/toaster";

// In components:
import { useToast } from "@/components/ui/use-toast";

function MyComponent() {
  const { toast } = useToast();

  function handleSuccess() {
    toast({
      title: "Success",
      description: "Project created successfully.",
    });
  }

  function handleError() {
    toast({
      title: "Error",
      description: "Something went wrong.",
      variant: "destructive",
    });
  }
}

Component Variant Pattern (cva)

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
  "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
  {
    variants: {
      variant: {
        active: "bg-green-100 text-green-800",
        inactive: "bg-gray-100 text-gray-800",
        pending: "bg-yellow-100 text-yellow-800",
        error: "bg-red-100 text-red-800",
      },
    },
    defaultVariants: { variant: "active" },
  }
);

interface BadgeProps extends VariantProps<typeof badgeVariants> {
  children: React.ReactNode;
  className?: string;
}

export function StatusBadge({ variant, children, className }: BadgeProps) {
  return (
    <span className={cn(badgeVariants({ variant }), className)}>
      {children}
    </span>
  );
}