modern-react-spa

Chapter 07

shadcn/ui Integration

shadcn/ui on React 19.2 + Vite — the copy-don't-install philosophy, CLI setup, Radix primitives, theming with CSS variables, and TanStack Table integration.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can explain why shadcn/ui is not a npm package and why that design matters; install it on a Vite + React 19.2 + TS SPA in under five minutes; compose Dialog, Combobox, Sheet, Sonner toasts, and DataTable into a real screen; and customise components without breaking your upgrade path.

🧭 Prerequisites — Ch 2 (refs-as-props), Ch 8 (tokens — deeper there) is useful but not required, Ch 17 (Vite). Familiarity with Tailwind helps; we’ll wire it up.


🔹 7.1 The “copy, don’t install” philosophy

shadcn/ui ships source code into your repo, not a versioned dependency. Run the CLI, the files land in src/components/ui/, you own them. Upgrades are deliberate copy-pastes, not npm update roulette.

Contrast with MUI / Mantine / Chakra — versioned libraries you import from. Their updates can break your styling on a Tuesday; theirs is the API contract; you adapt or pin.

shadcn flips it. Two layers:

  • Radix UI primitives — npm-installed, unstyled, handle accessibility (focus, keyboard, ARIA).
  • shadcn templates — copied source that wraps Radix in your Tailwind styling.

You own the second layer entirely. The first is npm-versioned but is unstyled and stable.

src/components/ui/
├── button.tsx          ◀── yours to edit
├── dialog.tsx          ◀── yours to edit
├── combobox.tsx        ◀── yours to edit
└── form.tsx            ◀── yours to edit

        └── these files live in YOUR repo — no @shadcn/ui import

What this changes about your CI, your design-system PRs, your bus factor:

  • No “shadcn published a breaking change” surprise.
  • A Button PR is a normal PR — diff, review, merge. Not a npm update bump.
  • New developers can read the actual component code, not jump through type definitions.
  • Customisations don’t compound (no monkey-patching a library you don’t own).

⚠️ The flip side: no automatic upgrades. When shadcn fixes a bug in their canonical Dialog, you re-run the CLI to pull it; you resolve any local diffs. The tax is real, paid only on the components you’ve actually edited.


🔹 7.2 CLI setup on Vite + React 19.2

# from your project root
npm install -D tailwindcss @tailwindcss/vite
npm create vite@latest acme-ui -- --template react-ts  # if starting fresh
cd acme-ui

npx shadcn@latest init                                  # interactive
# – Style? new-york (default)
# – Base color? slate
# – CSS variables? yes
# – tailwind.config.ts location? confirm

npx shadcn@latest add button dialog combobox sheet sonner

What lands:

📄 components.json — generated config

{
  "style": "new-york",
  "rsc": false,                                          // ← SPA, not RSC
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css":    "src/styles/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "ui":         "@/components/ui",
    "utils":      "@/lib/utils"
  }
}

📄 src/components/ui/button.tsx — copied source (annotated excerpt)

import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center …',          // base classes
  {
    variants: {
      variant: {
        default:     'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive …',
        outline:     'border border-input …',
        ghost:       'hover:bg-accent …',
        link:        'text-primary underline-offset-4 …',
      },
      size: { default: 'h-10 px-4 py-2', sm: 'h-9 px-3', lg: 'h-11 px-8', icon: 'h-10 w-10' },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  },
);

export const Button = ({
  className, variant, size, asChild = false, ref, ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants> & {
  asChild?: boolean;
  ref?: React.Ref<HTMLButtonElement>;
}) => {
  const Comp = asChild ? Slot : 'button';
  return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
};

The pieces worth recognising

  • cva (class-variance-authority) — typed variants → Tailwind class strings.
  • cn() — the clsx + tailwind-merge helper from @/lib/utils.
  • asChild + Slot — the polymorphic-component pattern. <Button asChild><a href="…">Link</a></Button> renders an <a> styled as a button.
  • ref? as a prop (Ch 2 §2.7) — no forwardRef. Some older shadcn versions still use forwardRef; you can leave them or migrate.

⚠️ Path aliases must match tsconfig.json. The CLI uses @/* by default; ensure your vite.config.ts + tsconfig.json resolve it (Ch 17 §17.5).


🔹 7.3 Radix UI primitives underneath

shadcn is Radix + a stylesheet. Understanding Radix means understanding shadcn.

What Radix gives you for free

  • Headless primitives — @radix-ui/react-dialog, react-popover, react-select, react-dropdown-menu.
  • Built-in keyboard handling, focus trapping, ARIA roles, portal rendering.
  • Composition pattern — every primitive is a tree: Dialog.RootDialog.TriggerDialog.PortalDialog.OverlayDialog.ContentDialog.Title/Dialog.Description/Dialog.Close.

shadcn’s dialog.tsx re-exports these with Tailwind classes applied. You can drop down to raw Radix any time — the import { Dialog as DialogPrimitive } from '@radix-ui/react-dialog' line in shadcn’s source is exactly that escape hatch.

┌─ Dialog anatomy (Radix → shadcn) ─────────────────────────┐
│                                                            │
│  <Dialog.Root>                  ← state container          │
│    <Dialog.Trigger>             ← opens the dialog          │
│    <Dialog.Portal>              ← renders into body         │
│      <Dialog.Overlay>           ← dim background            │
│      <Dialog.Content>           ← the modal box             │
│        <Dialog.Title>           ← required for a11y         │
│        <Dialog.Description>     ← required for a11y         │
│        <Dialog.Close>           ← optional close button     │
│                                                            │
└────────────────────────────────────────────────────────────┘

The required Title and Description are non-negotiable for screen readers. shadcn’s template includes them; resist the urge to remove them when they’re “just visual noise” — they’re not, they’re the a11y contract.

🔹 7.4 Theming via CSS variables

shadcn themes are CSS variables in your global stylesheet. Swap a few tokens, swap the look.

📄 src/styles/globals.css (excerpt)

@import "tailwindcss";

@layer base {
  :root {
    --background: 0 0% 100%;             /* hsl() values, space-separated */
    --foreground: 240 10% 3.9%;
    --primary:    240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
    --radius:     0.5rem;
  }
  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --primary:    0 0% 98%;
    --primary-foreground: 240 5.9% 10%;
  }
}

Tailwind v4 reads these via @theme. shadcn’s tailwind.config.ts maps --primarybg-primary, etc.

Light / dark / brand themes

  • Light/dark — toggle the .dark class on <html>. Use next-themes for the wiring (works in pure SPAs despite the name).
  • Brand theme — override the 6–8 most impactful tokens (--primary, --background, --accent, --radius). Don’t override all 30 tokens; you lose the curated harmony.
┌─ Token strip — three themes ─────────────────────────────┐
│ Light:   bg=white          primary=slate-900             │
│ Dark:    bg=slate-950      primary=white                 │
│ Brand:   bg=white          primary=#3b82f6  radius=12px  │
└──────────────────────────────────────────────────────────┘

🔹 7.5 The component anatomy — five examples

7.5.1 Button

Variants (default, outline, ghost, link, destructive). asChild for polymorphism. The example above covers it.

7.5.2 Dialog — controlled and uncontrolled

// uncontrolled (Radix handles open state)
<Dialog>
  <DialogTrigger asChild><Button>Edit</Button></DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit invoice</DialogTitle>
      <DialogDescription>Update amount and tenant.</DialogDescription>
    </DialogHeader>
    <EditInvoiceForm />
    <DialogFooter>
      <Button type="submit" form="invoice-form">Save</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

// controlled (you own open state)
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen}> … </Dialog>

Controlled is needed when an action elsewhere (a route loader, a toast) should open/close the dialog.

7.5.3 Combobox — async option loading

shadcn’s combobox is Command + Popover. The async pattern:

const [query, setQuery] = useState('');
const { data: tenants } = useQuery({                  // TanStack Query — Ch 14
  queryKey: ['tenants', query],
  queryFn:  () => searchTenants(query),
  enabled:  query.length >= 2,
});

<Popover open={open} onOpenChange={setOpen}>
  <PopoverTrigger asChild><Button variant="outline">{value ?? 'Pick tenant…'}</Button></PopoverTrigger>
  <PopoverContent className="p-0">
    <Command>
      <CommandInput placeholder="Search tenants…" value={query} onValueChange={setQuery} />
      <CommandList>
        {tenants?.map((t) => (
          <CommandItem key={t.id} onSelect={() => { setValue(t.name); setOpen(false); }}>
            {t.name}
          </CommandItem>
        ))}
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>

7.5.4 Sheet — side panels

<Sheet>
  <SheetTrigger asChild><Button variant="outline">View line items</Button></SheetTrigger>
  <SheetContent side="right" className="sm:max-w-md">
    <SheetHeader><SheetTitle>Line items</SheetTitle></SheetHeader>
    <LineItemsList items={invoice.lineItems} />
  </SheetContent>
</Sheet>

When to use Sheet vs Dialog: side-panel vs modal centerpiece. Sheets fit “drawer” workflows (filters, settings, navigation on mobile); Dialogs fit “one action, then decide” (confirmation, focused edit).

7.5.5 Sonner toasts

// in App.tsx — once
<Toaster richColors position="top-right" />

// at the call site
import { toast } from 'sonner';

toast.promise(saveInvoice(data), {
  loading: 'Saving…',
  success: 'Invoice saved',
  error:   (err) => `Could not save: ${err.message}`,
});

Pairs naturally with React 19.2 Actions (Ch 2 §2.2) — wrap the action’s promise; Sonner handles the UI states.


🔹 7.6 Data tables with TanStack Table

Canonical reference: https://tanstack.com/table/latest — headless, framework-agnostic table primitives.

Why this combo → shadcn provides the visual DataTable (built on <table> + Tailwind); TanStack Table provides the logic (column defs, sort/filter state, pagination, selection). They are explicitly designed to compose.

📄 typed column definitions

import { createColumnHelper, type ColumnDef } from '@tanstack/react-table';
import type { Invoice } from '@/types';

const ch = createColumnHelper<Invoice>();

export const columns: ColumnDef<Invoice>[] = [
  ch.accessor('id',     { header: 'ID' }),
  ch.accessor('tenant', { header: 'Tenant' }),
  ch.accessor('amount', {
    header: () => <div className="text-right">Amount</div>,
    cell: (i) => <div className="text-right">${i.getValue().toLocaleString()}</div>,
  }),
  ch.accessor('status', {
    header: 'Status',
    cell: (i) => <Badge variant={i.getValue() === 'paid' ? 'default' : 'outline'}>{i.getValue()}</Badge>,
  }),
];

📄 the shadcn shell, parameterised on TanStack’s Table<T>

import {
  flexRender, getCoreRowModel, getSortedRowModel, useReactTable,
  type ColumnDef, type SortingState,
} from '@tanstack/react-table';

export const DataTable = <T,>({ columns, data }: { columns: ColumnDef<T>[]; data: T[] }) => {
  const [sorting, setSorting] = useState<SortingState>([]);
  const table = useReactTable({
    data, columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((g) => (
          <TableRow key={g.id}>{g.headers.map((h) => (
            <TableHead key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</TableHead>
          ))}</TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {table.getRowModel().rows.map((r) => (
          <TableRow key={r.id}>{r.getVisibleCells().map((c) => (
            <TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
          ))}</TableRow>
        ))}
      </TableBody>
    </Table>
  );
};

Server- vs client-side modes

The same table component can drive both. The flag is manualSorting: true (and manualPagination, manualFiltering). When manual, TanStack passes the state to your callback; you fetch with the new query params; the table renders what you give it. Pairs with TanStack Query (Ch 14) — the table state is your query key.

Virtualisation for big tables — cross-link Ch 32.3. @tanstack/react-virtual plugs into the same table.getRowModel().rows shape.


🔹 7.7 Customising without breaking updates

The rule of thumb:

  • ✅ Edit colour tokens, spacing, typography freely.
  • ✅ Add new variants (variant: 'brand') freely.
  • ⚠️ Change the underlying Radix primitive structure → harder upgrades.
  • ⚠️ Rename exported component names → the CLI’s add command stops matching.

Pattern → two folders:

src/components/
├── ui/         ← vendor-ish (shadcn-managed)
│   ├── button.tsx
│   └── dialog.tsx
└── app/        ← yours (feature-specific)
    ├── invoice-edit-dialog.tsx
    └── tenant-combobox.tsx

app/ components compose ui/ components. Don’t mix concerns.


🔹 7.8 Composing your own shadcn-style components

Write a DateRangePicker that feels like shadcn — same theming, same accessibility, same cn() + cva pattern.

import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import * as Popover from '@radix-ui/react-popover';
import { DayPicker } from 'react-day-picker';

const triggerVariants = cva('flex items-center …', {
  variants: { size: { sm: 'h-9 text-sm', md: 'h-10', lg: 'h-11' } },
  defaultVariants: { size: 'md' },
});

export const DateRangePicker = ({
  value, onChange, size, className,
}: { value: DateRange; onChange: (r: DateRange) => void; className?: string }
  & VariantProps<typeof triggerVariants>) => (
  <Popover.Root>
    <Popover.Trigger className={cn(triggerVariants({ size }), className)}>
      {format(value.from, 'MMM d')}{format(value.to, 'MMM d')}
    </Popover.Trigger>
    <Popover.Portal>
      <Popover.Content className="rounded-md border bg-popover p-3">
        <DayPicker mode="range" selected={value} onSelect={onChange} />
      </Popover.Content>
    </Popover.Portal>
  </Popover.Root>
);

Same conventions = same review checklist = same a11y guarantees.


🪤 Common Pitfalls

  1. Treating shadcn as a versioned package. No update button. Re-run the CLI on the components you want to refresh.
  2. forwardRef in new components on React 19.2. Use refs-as-props instead (Ch 2 §2.7).
  3. Editing both vendor and feature concerns in components/ui/*. Keep them separated.
  4. Ignoring cn() and cva(). They enable variant composition; you’ll reinvent them poorly without.
  5. Controlled and uncontrolled Dialogs mixed without policy. Pick one per use case; document.
  6. Forgetting Portal boundaries when nesting Dialogs inside Sheets — z-index fights.
  7. Removing DialogTitle/DialogDescription for visual reasons. They’re a11y-required; visually hide with sr-only instead.

✅ Recap

  • shadcn/ui = Radix primitives + Tailwind tokens + source you own.
  • Theming is CSS variables; swap tokens, swap themes.
  • TanStack Table provides the logic; shadcn provides the visual; they compose.
  • Keep vendor ui/ files clean to make upgrades a copy-paste, not a merge conflict.
  • Build your own components in shadcn style for a consistent system.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (8)
  1. 7.1 The “copy, don’t install” philosophy
  2. 7.2 CLI setup on Vite + React 19.2
  3. 7.3 Radix UI primitives underneath
  4. 7.4 Theming via CSS variables
  5. 7.5 The component anatomy — five examples
  6. 7.6 Data tables with TanStack Table
  7. 7.7 Customising without breaking updates
  8. 7.8 Composing your own shadcn-style components