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
ButtonPR is a normal PR — diff, review, merge. Not anpm updatebump. - 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()— theclsx + tailwind-mergehelper 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) — noforwardRef. Some older shadcn versions still useforwardRef; 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.Root→Dialog.Trigger→Dialog.Portal→Dialog.Overlay→Dialog.Content→Dialog.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 --primary → bg-primary, etc.
Light / dark / brand themes
- Light/dark — toggle the
.darkclass on<html>. Usenext-themesfor 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
addcommand 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
- Treating shadcn as a versioned package. No
updatebutton. Re-run the CLI on the components you want to refresh. forwardRefin new components on React 19.2. Use refs-as-props instead (Ch 2 §2.7).- Editing both vendor and feature concerns in
components/ui/*. Keep them separated. - Ignoring
cn()andcva(). They enable variant composition; you’ll reinvent them poorly without. - Controlled and uncontrolled Dialogs mixed without policy. Pick one per use case; document.
- Forgetting
Portalboundaries when nesting Dialogs inside Sheets — z-index fights. - Removing
DialogTitle/DialogDescriptionfor visual reasons. They’re a11y-required; visually hide withsr-onlyinstead.
✅ 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
- https://ui.shadcn.com/ — official site.
- https://www.radix-ui.com/primitives — the headless primitives underneath.
- https://tanstack.com/table/latest — TanStack Table docs.
- https://cva.style/ — class-variance-authority.
- Ch 2 §2.7 (refs as props); Ch 8 (tokens deep dive); Ch 14 (TanStack Query — pairs with TanStack Table).
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.