shadcn-ui-migration
Skill0 starsChanged 5 months agoMITView on GitHub
A step-by-step playbook for migrating a React project from a hand-rolled/custom design system to shadcn/ui. Covers Tailwind v3→v4, shadcn init, component-by-component replacement, theme reconciliation, lint recovery, and cleanup.
- Deletes or force-pushes
- Installs packages
In this repository5 other files it gives its agents.
- idea-expansionSkill
- phase-trackingSkill
- project-pulseSkill
- todo-workflowSkill
- ui-migrationSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
18 rules. About 0 are common, 7 are distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 12
Use this skill when:
- Very specific: line 27
If both are loaded, after migration each component move is recorded in COMPONENT_CATALOG.md, and tasks are tracked in TODOS.md.
- Distinctive: line 33
Before touching any component:
- Very specific: line 37
Run both
buildandlint: before starting. Record the baseline. - Very specific: line 56
Remove PostCSS: delete postcss.config.js, uninstall postcss and autoprefixer.
- Very specific: line 58
Update
vite.config.js: add tailwindcss() to plugins list. - Very specific: line 62
Install
tw-animate-css: if you need animation utilities (shadcn needs them). - Very specific: line 74
Test that text-primary-500 still resolves after migration.
- Distinctive: line 111
Run build + lint: before moving to the next component.
- Very specific: line 117
No built-in loading — compose with disabled + a spinner icon.
- Distinctive: line 140
No built-in sort/filter — keep existing state management.
- Very specific: line 155
Must wrap in TooltipProvider at the app root.
- Very specific: line 162
Import Toaster from @/components/ui/sonner, mount once in app root.
- Very specific: line 163
Use toast.success/error/info/loading from sonner directly.
- Distinctive: line 284
After a large migration, lint will be broken even if build passes.
- Distinctive: line 317
AGENTS.md: Remove old component references, update UI component table, remove outdated patterns. - Very specific: line 318
README.md: Remove @design-system references, update dependency lists. - Distinctive: line 356
After every phase, run:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
shadcn/ui Migration Skill
A step-by-step playbook for migrating a React project from a hand-rolled/custom design system to shadcn/ui. Covers Tailwind v3→v4, shadcn init, component-by-component replacement, theme reconciliation, lint recovery, and cleanup.
When to use
Use this skill when:
- Migrating from a custom
@design-systemdirectory to shadcn/ui - Upgrading Tailwind CSS from v3 to v4 alongside a shadcn migration
- Replacing hand-rolled UI components (Button, Card, Input, Dialog, Select, etc.) with shadcn equivalents
- Cleaning up after a migration (dependencies, docs, stale aliases)
Dependencies
This skill builds on two companion skills in this repository:
| Skill | Purpose |
ui-migration | Component provenance tracking — catalog what was shadcn, custom, or legacy |
todo-workflow | Multi-step task tracking via TODOS.md + ERRORS.md with commit-after-each-task discipline |
If both are loaded, after migration each component move is recorded in COMPONENT_CATALOG.md, and tasks are tracked in TODOS.md.
Phase 0: Preflight Checks
Before touching any component:
- List every component in the old design system directory. Note which are actually imported. Use
rgacrosssrc/for import strings. - Check
package.jsonfor every dependency imported by the old system — these may become removable. - Run both
buildandlintbefore starting. Record the baseline. A broken linter will hide regressions introduced during migration. - Read the project's
AGENTS.md— it likely documents phantom components that never existed. - Note the CSS framework version. Tailwind v3 and v4 require completely different approaches.
Phase 1: Tailwind v3 → v4 (if applicable)
Key differences
| v3 | v4 |
tailwind.config.js | @theme directives in CSS |
| PostCSS plugin | Native Vite plugin (@tailwindcss/vite) |
@apply still works | @apply still works |
darkMode: "class" | @custom-variant dark (&:is(.dark *)); |
Steps
- Remove PostCSS: delete
postcss.config.js, uninstallpostcssandautoprefixer. - Install Tailwind v4 + Vite plugin:
pnpm add tailwindcss @tailwindcss/vite - Update
vite.config.js: addtailwindcss()to plugins list. - Migrate config to CSS: copy all
theme.extend.colorsetc. into CSS@theme { ... }block. - Remove
tailwind.config.jsentirely. - Remove
@tailwind base/components/utilitiesdirectives — Tailwind v4 auto-injects these. - Install
tw-animate-cssif you need animation utilities (shadcn needs them).
Common pitfall
Tailwind v4's @theme uses --color-* variable naming. Old config colors: { primary: { 500: '#3b82f6' } } becomes:
@theme {
--color-primary-500: #3b82f6;
}Test that text-primary-500 still resolves after migration.
Phase 2: shadcn Init
pnpm dlx shadcn@latest init --defaults— createscomponents.json,src/lib/utils.ts(withcn()), andsrc/components/ui/.- Check
components.json— ensurepaths.uiresolves tosrc/components/ui, notsrc/*/components/ui. - If shadcn created
src/*/components/ui/with a literal*directory, move files up and remove the asterisk dir. - Update
components.jsonto the correct aliases. - Ensure
package.jsonhas"type": "module"— shadcn's generated files use ESM imports.
Known bug
shadcn may install via npx and not respect pnpm. If you get dependency errors, run the pnpm equivalent manually:
pnpm add class-variance-authority clsx tailwind-merge lucide-react @radix-ui/react-slotPhase 3: Component-by-Component Migration
Ordering
Replace in dependency order: Button → Card → Input → Select → Dialog → DropdownMenu → Sheet → ...
General pattern
For each old component:
- Install the shadcn equivalent:
pnpm dlx shadcn@latest add <name> - Read the generated file to understand the API.
- Find all callers:
rg "from '.*old-component'" src/ - Replace each caller with the new import path and API.
- Delete the old component file.
- Run build + lint before moving to the next component.
Component-specific notes
Button
- shadcn Button uses
class-variance-authorityfor variants. - No built-in
loading— compose withdisabled+ a spinner icon. - Old
icon/iconPositionprops: replace by manually placing<Icon>children inside Button.
Card
- shadcn Card is a pure Tailwind +
cn()composition:Card,CardHeader,CardTitle,CardDescription,CardAction,CardContent,CardFooter. - No
titleprop on Card — composeCardHeader + CardTitlemanually.
Input / Textarea
- shadcn Input is a primitive
<input>. No wrappers for "with unit" or "search" styling. - For search: compose
div.relative > SearchIcon + Input.pl-8. InputNumber,InputSearch,InputWithUnitwere custom wrappers — decide if they're needed or inline composition suffices.
Select / Dropdown
- shadcn Select replaces custom single-select use cases.
- shadcn DropdownMenu replaces for menu/action use cases (click-to-open menu with items).
- Old MultiSelect: shadcn has none — use Checkbox groups or a dedicated library.
Dialog
- Wraps
@radix-ui/react-dialog. Exports:Dialog,DialogTrigger,DialogContent,DialogHeader,DialogTitle,DialogDescription,DialogFooter. - Pass
open+onOpenChangefor controlled usage.
Table
- Pure Tailwind +
cn(). Composable:Table,TableHeader,TableBody,TableRow,TableHead,TableCell. - No built-in sort/filter — keep existing state management.
Badge
- Uses
class-variance-authority. Variants:default,secondary,destructive,outline,ghost,link. - Map old status/role classes to variants; use
classNamefor custom colors.
Avatar
- Composes:
Avatar,AvatarImage,AvatarFallback,AvatarBadge. - Replace hand-rolled initials div with
AvatarFallback.
Breadcrumb
- Composable:
Breadcrumb,BreadcrumbList,BreadcrumbItem,BreadcrumbLink,BreadcrumbPage,BreadcrumbSeparator. BreadcrumbLinkuses therenderprop pattern.
Tooltip
- Must wrap in
TooltipProviderat the app root. - Composition:
Tooltip > TooltipTrigger + TooltipContent.
Sheet
- Wraps
@radix-ui/react-dialogfor slide-out panels. Props:side(top/right/bottom/left).
Sonner (Toasts)
- Import
Toasterfrom@/components/ui/sonner, mount once in app root. - Use
toast.success/error/info/loadingfromsonnerdirectly. - ~16 KB savings vs
react-toastify.
Phase 4: The render Prop
Many shadcn components accept a render prop instead of asChild:
// ❌ Wrong — nested <button> inside <a>
<Button render={<Link to="/dashboard" />}>Dashboard</Button>
// ✅ Correct — render replaces the button element with the Link
<Button render={<Link to="/dashboard" />} />The render prop replaces the rendered element entirely. If the child component needs children, put them inside the render prop element:
<SidebarMenuButton render={<Link to="/dashboard" />}>
Dashboard
</SidebarMenuButton>render= element replacement (shadcn/base-ui approach)asChild= Radix UI approach (used by some Radix primitives shadcn wraps)
Phase 5: The shadcn Sidebar Block
The shadcn sidebar block is a full composable UI system installed via:
pnpm dlx shadcn@latest add sidebarGenerates src/components/ui/sidebar.tsx (~723 lines, 25 exports) and src/hooks/use-mobile.ts.
Architecture
SidebarProvider (context — open/close state)
Sidebar (desktop: persistent, mobile: Sheet overlay)
SidebarHeader
SidebarContent
SidebarGroup > SidebarGroupLabel > SidebarGroupContent
SidebarMenu > SidebarMenuItem
SidebarMenuButton (uses render prop, accepts tooltip)
SidebarMenuSub > SidebarMenuSubItem > SidebarMenuSubButton
SidebarFooter > SidebarMenu > ...
SidebarRail (desktop collapse handle)
SidebarInset (main content area)
SidebarTrigger (mobile toggle button)Critical: SidebarProvider Placement
SidebarProvider must wrap both the <Sidebar> and the content area. useSidebar() throws if called outside a provider. If multiple layouts each manage their own sidebar state, place a SidebarProvider in each layout.
Mobile behavior
- On mobile (<768px), the sidebar renders as a shadcn
Sheet— hidden by default, slides in viaSidebarTrigger. - On desktop, persistent panel with optional icon-only collapse via
SidebarRail. - Show
SidebarTriggerwithclassName="md:hidden"to only appear on mobile.
The Collapsible Submenu Pattern (Critical)
For sidebar submenus, shadcn uses Collapsible from @base-ui/react/collapsible. CRITICAL: Collapsible is a namespace object, not a component.
import { Collapsible } from '@base-ui/react/collapsible'
// ❌ BROWS — "Element type is invalid: expected a string or function but got: object"
<Collapsible defaultOpen={true}>...</Collapsible>
// ✅ CORRECT — use Collapsible.Root
<Collapsible.Root defaultOpen={true}>
<Collapsible.Trigger>...</Collapsible.Trigger>
<Collapsible.Panel>...</Collapsible.Panel>
</Collapsible.Root>Props for Collapsible.Root: defaultOpen, open, onOpenChange, disabled, className.
Phase 6: Theme Reconciliation
shadcn's default --primary color is near-black (oklch(0.145 0 0)). Most projects need to override it.
Where to override
In src/index.css, after the shadcn-generated :root block:
:root {
--primary: oklch(0.546 0.215 262.881); /* blue-600 */
--primary-foreground: oklch(1 0 0);
--ring: oklch(0.546 0.215 262.881);
--sidebar-primary: oklch(0.546 0.215 262.881);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-ring: oklch(0.546 0.215 262.881);
}
.dark {
--primary: oklch(0.623 0.214 259.815);
--ring: oklch(0.623 0.214 259.815);
--sidebar-primary: oklch(0.623 0.214 259.815);
}The @theme inline { --color-primary: var(--primary); } block wires CSS variables to Tailwind classes. Override the variables and bg-primary, text-primary-foreground, etc. follow automatically.
Don't forget --sidebar-primary — the sidebar block uses its own --sidebar-* variables.
Phase 7: The Lint Trap
After a large migration, lint will be broken even if build passes.
| Symptom | Root Cause | Fix |
eslint: command not found | ESLint not installed | pnpm add -D eslint |
.jsx files ignored | eslint.config.js ignores wrong dir | Fix ignores: ["build/**"] not "dist/**" |
'React' is defined but never used | React 19 JSX transform | Remove import React from all .jsx files |
'X' is defined but never used | Orphaned imports from migration | Search with rg and remove |
__dirname is not defined | ESM module | Replace with fileURLToPath(import.meta.url) |
Setup commands
pnpm add -D eslint @eslint/js globals eslint-plugin-react-hooks eslint-plugin-react-refresh
pnpm run lintPhase 8: Delete the Old Design System
Before deleting:
- Verify all imports are updated —
rg "from '@design-system'" src/should return nothing. - Check
vite.config.jsfor stale aliases (@design-system). - Check
tsconfig.jsonfor stale path aliases. - Remove the directory:
rm -rf src/design-system/ - Run build + lint to confirm nothing is broken.
Phase 9: Documentation Cleanup
AGENTS.md: Remove old component references, update UI component table, remove outdated patterns.README.md: Remove@design-systemreferences, update dependency lists.- Create
COMPONENT_CATALOG.md: Track provenance — which components are shadcn, which are custom, which were removed. (See theui-migrationskill for the catalog format.)
Phase 10: Dependency Cleanup
Likely removals
formik, react-helmet, react-portal, react-resizable, react-router-hash-link, @fortawesome/*, @tanstack/eslint-plugin-query, react-toastify, classnames/clsx, postcss/autoprefixer, and any component library wrappers no longer imported.
Move shadcn to devDependencies
pnpm add -D shadcnCheck peer dep warnings
pnpm ls --depth=0Common Runtime Errors
| Error | Cause | Fix |
Element type is invalid: expected a string or a class/function but got: object | Using Collapsible directly instead of Collapsible.Root | Use Collapsible.Root |
useSidebar must be used within a SidebarProvider | Component using useSidebar() outside provider | Wrap in SidebarProvider |
Module not found: Can't resolve '@/components/ui/X' | Component not installed or wrong path | pnpm dlx shadcn@latest add <name> |
| Tailwind classes not applying (v4) | CSS variables not wired via @theme inline | Ensure @theme inline block maps variables |
Verification Checklist
After every phase, run:
# 1. Build produces no errors
pnpm run build
# 2. Lint produces no errors
pnpm run lint
# 3. No remaining imports from old design system
rg "from '@design-system'" src/
# 4. No unused dependencies
pnpm ls --depth=0Hardest Lessons
- Lint matters more than build. A green build can hide months of code rot.
@base-ui/react'sCollapsiblenamespace is the biggest footgun. Every agent hits this.- Documentation debt is invisible but expensive. Phantom components in agent instructions cause wasted cycles.
- shadcn's Sidebar block (723 generated lines) acts like a framework. Read the entire file before using it.
- Theme variables must be overridden in both
:rootand.dark, and for both--primaryand--sidebar-primary. - Move fast but validate often. Build+lint after every single component swap.
- The real output is the process artifacts.
TODOS.md,ERRORS.md,COMPONENT_CATALOG.mdmake the migration reproducible. Without them, the next agent starts from zero.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

