Migrating to V4
Step-by-step guide for migrating from V3 to V4 of the exaBase Design System.
V4 is a major update that modernizes the foundation of the design system. The key changes are:
- Tailwind CSS v3 → v4: CSS-first configuration, modern color formats
- Radix UI → Base UI: More actively maintained primitives with a modern API
- React 18 → React 19: Latest React features
- Design refresh: More compact sizing, updated spacing and rounded corners
- New components: Autocomplete, Button Group, Combobox, Data Table, Date Picker, and more
This guide walks you through the migration process step by step.
Prerequisites
Before migrating, ensure your project meets the following requirements:
- Node.js 18.18 or later
- React 19
- Next.js 15 or later (if using Next.js)
- TypeScript 5 or later
Step 1: Update Dependencies
Update React and other core dependencies:
npm install react@^19 react-dom@^19Remove individual Radix UI packages and install Base UI:
npm uninstall @radix-ui/react-accordion @radix-ui/react-checkbox @radix-ui/react-collapsible \
@radix-ui/react-context-menu @radix-ui/react-dialog @radix-ui/react-dropdown-menu \
@radix-ui/react-hover-card @radix-ui/react-label @radix-ui/react-menubar \
@radix-ui/react-navigation-menu @radix-ui/react-popover @radix-ui/react-progress \
@radix-ui/react-radio-group @radix-ui/react-scroll-area @radix-ui/react-select \
@radix-ui/react-separator @radix-ui/react-slider @radix-ui/react-slot \
@radix-ui/react-switch @radix-ui/react-tabs @radix-ui/react-toast \
@radix-ui/react-toggle @radix-ui/react-toggle-group @radix-ui/react-tooltip
npm install @base-ui/reactUpdate components.json
Your components.json needs to be updated for V4. The full V3 and V4 configurations are shown below.
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"utils": "@/lib/utils"
}
}{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"registries": {
"@exabase": "https://exawizards.com/exabase/design/registry/{name}.json"
}
}Key differences:
style:"default"→"new-york"tailwind.config:"tailwind.config.js"→""(V4 has no JS config)tailwind.css:"src/styles/globals.css"→"app/globals.css"(adjust to your project)aliases: Addedui,libaliasesregistries: Added the exaBase registry URL
Reinstall Components
After updating components.json, reinstall all components to get V4 code and styles:
npx shadcn@latest add @exabase --all --overwriteStep 2: Migrate Tailwind CSS v3 to v4
Tailwind CSS v4 moves from JavaScript-based configuration to a CSS-first approach. For a complete guide, see the official Tailwind CSS v4 upgrade guide.
You can use the automated upgrade tool to handle most changes:
npx @tailwindcss/upgradeBelow are the design-system-specific changes to be aware of.
PostCSS Configuration
V4 handles imports and vendor prefixing internally, so you can simplify your PostCSS config. See the official guide for details.
export default {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
}export default {
plugins: {
"@tailwindcss/postcss": {},
},
}globals.css
In V3, theme values were defined in tailwind.config.js and colors were stored as raw RGB channels (e.g. 37, 99, 244). In V4, everything moves to globals.css — the theme is declared with the @theme directive, colors use complete rgb() values, and dark mode uses a CSS custom variant instead of JavaScript configuration.
The complete globals.css for V3 and V4 are shown below for reference.
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 255, 255, 255;
--foreground: 8, 10, 12;
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: 37, 99, 244;
--primary-hovered: 58, 114, 245;
--primary-pressed: 81, 130, 246;
--primary-foreground: 255, 255, 255;
--primary-text: var(--primary);
--secondary: 103, 120, 145, 0.1;
--secondary-hovered: 103, 120, 145, 0.2;
--secondary-pressed: 103, 120, 145, 0.3;
--secondary-foreground: 8, 10, 12;
--destructive: 245, 20, 10;
--destructive-hovered: 246, 55, 47;
--destructive-pressed: 247, 90, 83;
--destructive-foreground: 255, 255, 255;
--destructive-text: var(--destructive);
--destructive-muted: 254, 227, 226;
--info: 0, 177, 242;
--info-foreground: 255, 255, 255;
--info-text: 0, 152, 208;
--info-muted: 224, 246, 253;
--success: 17, 156, 17;
--success-foreground: 255, 255, 255;
--success-text: 11, 138, 11;
--success-muted: 226, 243, 226;
--warning: 249, 181, 0;
--warning-foreground: 8, 10, 12;
--warning-text: 179, 130, 0;
--warning-muted: 254, 246, 224;
--muted: 237, 239, 242;
--muted-foreground: 103, 120, 145;
--accent: var(--secondary);
--accent-pressed: var(--secondary-hovered);
--accent-foreground: var(--secondary-foreground);
--border: 212, 217, 224;
--input: 212, 217, 224;
--ring: var(--primary);
}
.dark {
--background: 16, 19, 23;
--foreground: 237, 239, 242;
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: 37, 99, 244;
--primary-hovered: 58, 114, 245;
--primary-pressed: 81, 130, 246;
--primary-foreground: 255, 255, 255;
--primary-text: 76, 127, 246;
--secondary: 237, 239, 242, 0.08;
--secondary-hovered: 237, 239, 242, 0.16;
--secondary-pressed: 237, 239, 242, 0.24;
--secondary-foreground: 237, 239, 242;
--destructive: 247, 62, 54;
--destructive-hovered: 248, 91, 84;
--destructive-pressed: 249, 120, 114;
--destructive-foreground: 255, 255, 255;
--destructive-text: 247, 62, 54;
--destructive-muted: 77, 3, 0;
--info: 0, 177, 242;
--info-foreground: 255, 255, 255;
--info-text: 0, 177, 242;
--info-muted: 0, 53, 73;
--success: 17, 156, 17;
--success-foreground: 255, 255, 255;
--success-text: 17, 156, 17;
--success-muted: 0, 52, 0;
--warning: 249, 181, 0;
--warning-foreground: 8, 10, 12;
--warning-text: 249, 181, 0;
--warning-muted: 75, 54, 0;
--muted: 31, 36, 44;
--muted-foreground: 130, 144, 165;
--accent: var(--secondary);
--accent-pressed: var(--secondary-hovered);
--accent-foreground: var(--secondary-foreground);
--border: 45, 53, 64;
--input: 45, 53, 64;
--ring: var(--primary);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: var(--font-inter), var(--font-noto-sans-jp), sans-serif;
--radius-xs: calc(var(--radius) - 6px);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-hovered: var(--primary-hovered);
--color-primary-pressed: var(--primary-pressed);
--color-primary-foreground: var(--primary-foreground);
--color-primary-text: var(--primary-text);
--color-secondary: var(--secondary);
--color-secondary-hovered: var(--secondary-hovered);
--color-secondary-pressed: var(--secondary-pressed);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-hovered: var(--destructive-hovered);
--color-destructive-pressed: var(--destructive-pressed);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive-text: var(--destructive-text);
--color-destructive-muted: var(--destructive-muted);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-info-text: var(--info-text);
--color-info-muted: var(--info-muted);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-text: var(--success-text);
--color-success-muted: var(--success-muted);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-text: var(--warning-text);
--color-warning-muted: var(--warning-muted);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-pressed: var(--accent-pressed);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: rgb(255, 255, 255);
--foreground: rgb(8, 10, 12);
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: rgb(37, 99, 244);
--primary-hovered: rgb(58, 114, 245);
--primary-pressed: rgb(81, 130, 246);
--primary-foreground: rgb(255, 255, 255);
--primary-text: var(--primary);
--secondary: rgba(103, 120, 145, 0.1);
--secondary-hovered: rgba(103, 120, 145, 0.2);
--secondary-pressed: rgba(103, 120, 145, 0.3);
--secondary-foreground: rgb(8, 10, 12);
--destructive: rgb(245, 20, 10);
--destructive-hovered: rgb(246, 55, 47);
--destructive-pressed: rgb(247, 90, 83);
--destructive-foreground: rgb(255, 255, 255);
--destructive-text: var(--destructive);
--destructive-muted: rgb(254, 227, 226);
--info: rgb(0, 177, 242);
--info-foreground: rgb(255, 255, 255);
--info-text: rgb(0, 152, 208);
--info-muted: rgb(224, 246, 253);
--success: rgb(17, 156, 17);
--success-foreground: rgb(255, 255, 255);
--success-text: rgb(11, 138, 11);
--success-muted: rgb(226, 243, 226);
--warning: rgb(249, 181, 0);
--warning-foreground: rgb(8, 10, 12);
--warning-text: rgb(179, 130, 0);
--warning-muted: rgb(254, 246, 224);
--muted: rgb(237, 239, 242);
--muted-foreground: rgb(103, 120, 145);
--accent: var(--secondary);
--accent-pressed: var(--secondary-hovered);
--accent-foreground: var(--secondary-foreground);
--border: rgb(212, 217, 224);
--input: rgb(212, 217, 224);
--ring: var(--primary);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: rgb(16, 19, 23);
--foreground: rgb(237, 239, 242);
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: rgb(37, 99, 244);
--primary-hovered: rgb(58, 114, 245);
--primary-pressed: rgb(81, 130, 246);
--primary-foreground: rgb(255, 255, 255);
--primary-text: rgb(76, 127, 246);
--secondary: rgba(237, 239, 242, 0.08);
--secondary-hovered: rgba(237, 239, 242, 0.16);
--secondary-pressed: rgba(237, 239, 242, 0.24);
--secondary-foreground: rgb(237, 239, 242);
--destructive: rgb(247, 62, 54);
--destructive-hovered: rgb(248, 91, 84);
--destructive-pressed: rgb(249, 120, 114);
--destructive-foreground: rgb(255, 255, 255);
--destructive-text: rgb(247, 62, 54);
--destructive-muted: rgb(77, 3, 0);
--info: rgb(0, 177, 242);
--info-foreground: rgb(255, 255, 255);
--info-text: rgb(0, 177, 242);
--info-muted: rgb(0, 53, 73);
--success: rgb(17, 156, 17);
--success-foreground: rgb(255, 255, 255);
--success-text: rgb(17, 156, 17);
--success-muted: rgb(0, 52, 0);
--warning: rgb(249, 181, 0);
--warning-foreground: rgb(8, 10, 12);
--warning-text: rgb(249, 181, 0);
--warning-muted: rgb(75, 54, 0);
--muted: rgb(31, 36, 44);
--muted-foreground: rgb(130, 144, 165);
--accent: var(--secondary);
--accent-pressed: var(--secondary-hovered);
--accent-foreground: var(--secondary-foreground);
--border: rgb(45, 53, 64);
--input: rgb(45, 53, 64);
--ring: var(--primary);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}Key differences to note:
- Imports:
@tailwind base/components/utilities→@import "tailwindcss" - Theme:
tailwind.config.js→@theme inline { ... }in CSS - Colors: Raw channels (
37, 99, 244) → Complete values (rgb(37, 99, 244)) - Dark mode:
darkMode: ["class"]in JS config →@custom-variant dark (&:is(.dark *))in CSS - New tokens: V4 adds
chart-*,sidebar-*, andradius-*tokens
Step 3: General API Pattern Changes
These patterns affect multiple components. Understanding them first will make the component-specific migration easier.
asChild → render
Radix UI used the asChild prop with a Slot component to override the rendered element. Base UI replaces this with a render prop.
Before (V3):
<CollapsibleTrigger asChild>
<Button>Toggle</Button>
</CollapsibleTrigger>After (V4):
<CollapsibleTrigger render={<Button />}>Toggle</CollapsibleTrigger>For more details, see the Base UI documentation on Composition and useRender.
Affected components: Button, Badge, Breadcrumb, Collapsible, Context Menu, Dropdown Menu, Tooltip, and any component that previously accepted asChild.
onSelect → onClick
Radix UI menu components used onSelect for item selection. Base UI uses standard onClick handlers.
Before (V3):
<DropdownMenuItem onSelect={() => handleAction()}>
Action
</DropdownMenuItem>After (V4):
<DropdownMenuItem onClick={() => handleAction()}>
Action
</DropdownMenuItem>Affected components: Dropdown Menu, Context Menu.
forceMount → keepMounted
Radix UI used forceMount to keep content in the DOM when hidden. Base UI uses keepMounted.
Before (V3):
<CollapsibleContent forceMount>
{/* content */}
</CollapsibleContent>After (V4):
<CollapsibleContent keepMounted>
{/* content */}
</CollapsibleContent>Data Attributes
Radix UI used data-[state=open] and data-[state=closed] for state-based styling. Base UI simplifies these to data-open and data-closed.
If you have custom CSS targeting these attributes, update them:
/* Before (v3) */
.my-element[data-state="open"] { ... }
/* After (v4) */
.my-element[data-open] { ... }In Tailwind classes:
// Before (v3)
className="data-[state=open]:animate-in data-[state=closed]:animate-out"
// After (v4)
className="data-open:animate-in data-closed:animate-out"type="single" | "multiple" → multiple prop
Radix UI used a type prop to switch between single and multiple selection. Base UI uses a boolean multiple prop.
Before (V3):
<Accordion type="multiple">After (V4):
<Accordion multiple>Affected components: Accordion, Toggle Group.
Step 4: Component-Specific Changes
Components are organized by migration impact:
- Breaking API Changes — Code changes required
- Style and Prop Changes — Review recommended
- Minimal Changes — Reinstall only
Breaking API Changes
These components have significant API changes that require code updates.
Accordion
type="single" | "multiple"replaced with booleanmultiplepropcollapsibleprop removed — single-mode accordion is always closablevalueanddefaultValuetype changed fromstring(single) orstring[](multiple) to alwaysstring[]AccordionContentinternally renamed toAccordionPrimitive.Panel(export name kept asAccordionContent)
| V3 | V4 |
|---|---|
<Accordion type="single" collapsible> | <Accordion> |
<Accordion type="multiple"> | <Accordion multiple> |
value: string | string[] | value: string[] |
Before (V3):
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
</AccordionItem>
</Accordion>After (V4):
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
</AccordionItem>
</Accordion>Select
The Select component has been migrated from Radix UI to Base UI with significant API changes.
Before (V3):
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>After (V4):
const items = [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
{ label: "System", value: "system" },
]
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>- Items should be defined as an array and mapped in
SelectContent(rather than inline children) SelectContentnow internally uses aPositioner+Popupstructure. The component name is kept asSelectContentfor convenience.- The popup now overlaps the trigger by default so the selected item aligns with the trigger text. Set
alignItemWithTrigger={false}onSelectContentto restore the previous behavior. - New
sizeprop onSelectTrigger:"default"|"sm"
Drawer
V3 used vaul. V4 uses the Base UI Drawer component.
Before (V3):
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Title</DrawerTitle>
<DrawerDescription>Description</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>After (V4):
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Title</DrawerTitle>
<DrawerDescription>Description</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>asChild→renderonDrawerTriggerandDrawerClose(see General API Pattern Changes)- The
DrawerOverlaycomponent has been renamed toDrawerBackdropinternally (but the export nameDrawerOverlayis kept for convenience) - The
directionprop is still supported
Dropdown Menu
asChild→render(see General API Pattern Changes)onSelect→onClick(see General API Pattern Changes)DropdownMenuLabelmust now be wrapped inDropdownMenuGroup- Removed
insetprop fromDropdownMenuLabel
Context Menu
asChild→render(see General API Pattern Changes)onSelect→onClick(see General API Pattern Changes)ContextMenuLabelmust now be wrapped inContextMenuGroup- Removed
insetprop fromContextMenuLabel ContextMenuSubrenamed toContextMenuSubmenuRootinternallyContextMenuSubTriggerrenamed toContextMenuSubmenuTriggerinternally
Toggle Group
type="single" | "multiple"replaced with booleanmultipleprop- New
sizeprop options:xs,icon,icon-xs,icon-sm,icon-lg - New
variantprop options:primary,primary-muted - New
spacingprop to add spacing between items (default:2, use0for no gap) - New
orientationprop:"horizontal"(default) |"vertical"
| V3 | V4 |
|---|---|
<ToggleGroup type="single"> | <ToggleGroup> |
<ToggleGroup type="multiple"> | <ToggleGroup multiple> |
Before (V3):
<ToggleGroup type="single">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<TextBoldIcon className="size-4" />
</ToggleGroupItem>
</ToggleGroup>After (V4):
<ToggleGroup size="icon">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<TextBoldIcon />
</ToggleGroupItem>
</ToggleGroup>Checkbox
The integration with Field has changed:
Before (V3):
<Field variant="check">
<Checkbox id="id" />
<Label htmlFor="id">Label Text</Label>
</Field>After (V4):
<Field orientation="horizontal">
<Checkbox id="id" />
<FieldLabel htmlFor="id">Label Text</FieldLabel>
</Field>variant="check"replaced withorientation="horizontal"Labelreplaced withFieldLabelinside Field- Unchecked border color changed from
border-primarytoborder-input
Field
The Field component has been significantly expanded. V3 had a single Field component with a variant prop. V4 introduces a full set of sub-components for building complex form layouts.
New sub-components in V4:
FieldSet— wraps a group of fields in a<fieldset>FieldLegend— legend for a fieldset (with"legend"or"label"variant)FieldGroup— groups multiple fields togetherFieldContent— content area within a fieldFieldLabel— label for a field (replaces usingLabeldirectly)FieldTitle— non-label title textFieldDescription— help text for a fieldFieldSeparator— visual separator between fieldsFieldError— error message display (with automatic deduplication)
API changes:
variant="default" | "check"→orientation="vertical" | "horizontal" | "responsive"- New
"responsive"orientation switches between vertical and horizontal based on container width
Before (V3):
import { Field } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
<Field variant="check">
<Checkbox id="terms" />
<Label htmlFor="terms">Accept terms</Label>
</Field>After (V4):
import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field"
<Field orientation="horizontal">
<Checkbox id="terms" />
<FieldLabel htmlFor="terms">Accept terms</FieldLabel>
</Field>
{/* V4 also supports richer layouts */}
<Field orientation="vertical">
<FieldLabel htmlFor="email">Email</FieldLabel>
<FieldContent>
<Input id="email" />
<FieldDescription>We'll never share your email.</FieldDescription>
<FieldError errors={errors} />
</FieldContent>
</Field>Collapsible
asChild→renderonCollapsibleTrigger(see General API Pattern Changes)forceMount→keepMountedonCollapsibleContent
Before (V3):
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon className="size-4" />
</Button>
</CollapsibleTrigger>After (V4):
<CollapsibleTrigger
render={
<Button variant="ghost" size="sm">
<ChevronDownIcon className="size-4" />
</Button>
}
/>Resizable
Updated to react-resizable-panels v4:
| V3 | V4 |
|---|---|
direction prop | orientation prop |
defaultSize={50} | defaultSize="50%" |
onLayout | onLayoutChange |
ref on Panel | panelRef prop |
onCollapse / onExpand on Handle | Use onResize instead |
Before (V3):
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={50}>One</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50}>Two</ResizablePanel>
</ResizablePanelGroup>After (V4):
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel defaultSize="50%">One</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="50%">Two</ResizablePanel>
</ResizablePanelGroup>Tabs
- New
variantprop onTabsList:"default"(new) and"line"(previously the default style) - The default variant changed — if you relied on the previous underline tab style, add
variant="line" - New
orientationprop:"horizontal"(default) |"vertical" - Animated indicator is now built in
Before (V3):
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
<TabsContent value="password">...</TabsContent>
</Tabs>After (V4 — to keep the previous underline style):
<Tabs defaultValue="account">
<TabsList variant="line">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
<TabsContent value="password">...</TabsContent>
</Tabs>Separator
decorativeprop removed — usearia-hidden="true"instead
| V3 | V4 |
|---|---|
<Separator decorative /> | <Separator aria-hidden="true" /> |
Tooltip
asChild→renderonTooltipTrigger(see General API Pattern Changes)TooltipProvider:delayDurationprop renamed todelayTooltipno longer wrapsTooltipProviderinternally — you must wrap your app (or a section) withTooltipProviderseparately
Before (V3):
{/* V3: Tooltip includes TooltipProvider internally */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Hover me</Button>
</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>After (V4):
{/* V4: TooltipProvider must wrap tooltips */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Button variant="outline" />}>
Hover me
</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>
</TooltipProvider>Dialog
The Dialog component has been migrated from Radix UI to Base UI with significant structural changes.
DialogOverlayinternally renamed toDialogPrimitive.Backdrop(export name kept asDialogOverlay)DialogContentnow usesViewport+Popupinternally (export name kept asDialogContent)DialogClose:asChild→render(see General API Pattern Changes)DialogFooternow accepts an optionalshowCloseButtonprop
Alert Dialog
Same structural changes as Dialog:
AlertDialogOverlayinternally renamed toAlertDialogPrimitive.BackdropAlertDialogContentnow usesViewport+PopupinternallyAlertDialogCancelnow usesAlertDialogPrimitive.Closewithrenderprop instead ofAlertDialogPrimitive.CancelAlertDialogActionnow wraps a Button component directlyAlertDialogFooternow accepts an optionalshowCloseButtonprop
Sheet
Same structural changes as Dialog:
SheetOverlayinternally renamed toSheetPrimitive.BackdropSheetContentnow usesPopupinternallySheetClose:asChild→render(see General API Pattern Changes)SheetContentnow accepts ashowCloseButtonprop- Animation CSS has been rewritten (uses
data-starting-style/data-ending-styleinstead ofdata-[state=open/closed])
Popover
- Content now uses
Positioner+Popupinternally (export name kept asPopoverContent) - New positioning props on
PopoverContent:align,alignOffset,side,sideOffset,collisionPadding - New optional components:
PopoverArrow,PopoverHeader,PopoverTitle,PopoverDescription
Toast
The Toast system has been completely rewritten from Radix UI Toast to Base UI Toast.
Before (V3):
import { Toaster } from "@/components/ui/toaster"
import { useToast } from "@/hooks/use-toast"
// In your layout:
<Toaster />
// To show a toast:
const { toast } = useToast()
toast({ title: "Event has been created" })After (V4):
import { ToastProvider } from "@/components/ui/toast"
// In your layout:
<ToastProvider />
// To show a toast:
import { toastManager } from "@/components/ui/toast"
toastManager.add({ title: "Event has been created" })Toaster(from@/components/ui/toaster) →ToastProvideruseToast()hook /toast()→toastManager.add()- New
AnchoredToastProviderfor anchored positioning - See the Toast documentation for full API details
Navigation Menu
NavigationMenuIndicatorhas been removed- New
NavigationMenuPositionercomponent required (wraps viewport content) NavigationMenunow accepts positioning props:align,collisionPadding- Trigger data attributes changed:
data-[state=open]→data-popup-open
Radio Group
- Migrated from Radix UI to Base UI
- Data attributes changed:
data-[state=checked]→data-checked,data-[disabled]→data-disabled
Button
asChild→render(see General API Pattern Changes)variant="default"renamed tovariant="primary"(the default variant, so no change needed unless you specified it explicitly)- New
variantoptions:primary-outline,primary-ghost,destructive-outline,destructive-ghost - New
sizeoptions:xs,icon-xs,icon-sm,icon-lg
Calendar
- react-day-picker updated to v9. See the react-day-picker upgrade guide for breaking changes in the underlying library.
- New
localeprop for locale-aware date formatting - Internal styling has been significantly rewritten (cell size, border radius, range selection styles)
Pagination
- Default
sizeofPaginationLinkchanged from"icon"to"default" PaginationPrevious/PaginationNextnow accept atextprop to customize the labelPaginationEllipsisnow accepts asizeprop- New
sizeoptions onPaginationLink:xs,sm,lg - If you replace
<a>with<Link>in the component code for Next.js, you need to reapply that change after updating.
Style and Prop Changes
These components have updated dimensions, new props, or style changes. Review your usage after reinstalling.
Alert
- Icon size was fixed at
size-4. In V4, you can override the default with any size.
Avatar
- New
sizeprop:"default"(size-8),"sm","lg" - New components:
AvatarBadge,AvatarGroup,AvatarGroupCount - Text or icons can now be placed as direct children of Avatar (without an image)
Badge
- Padding changed from
px-2topx-1.5 - Font weight changed from
font-semiboldtofont-medium - SVGs inside the badge are now automatically sized
- New
data-icon="inline-start"anddata-icon="inline-end"data attributes
Button
- Updated dimensions (sizes, spacing, rounded corners, font sizes)
- SVGs are now automatically sized
Card
- New
sizeprop:"default","sm" - Updated dimensions (spacing, padding)
Input
- Updated size and padding
- New
sizeprop:"default","sm"(also accepts a number for the nativesizeattribute) - Added invalid state styles
- Aligned autofill background in WebKit browsers
Input OTP
- Updated size and padding
- Updated focus ring styles
- Added invalid state styles
Progress
- New
ProgressLabelandProgressValuecomponents for displaying labels and values inside the progress bar
Slider
- Removed
my-2from root element - Updated thumb styles
Switch
- New
sizeprop
Textarea
- New
sizeprop
Toggle
- Updated button sizes and paddings
- New
sizeoptions:xs,icon,icon-xs,icon-sm,icon-lg - New
variantoptions:primary,primary-muted
Minimal Changes
These components have no breaking API changes. Reinstall them to get updated styles:
Aspect Ratio, Breadcrumb, Carousel, Command, Hover Card, Label, Menubar, Scroll Area, Skeleton, Table
New Components in V4
The following components are new in V4:
- Autocomplete — Text input with suggestions
- Button Group — Group related buttons
- Combobox — Searchable select
- Data Table — Sortable, filterable data table
- Date Picker — Calendar-based date selection
- Empty — Empty state placeholder
- Input Group — Input with addons
- Item — List item primitive
- Kbd — Keyboard key display
- Sidebar — Application sidebar layout
- Spinner — Loading indicator
Verification Checklist
After migration, verify the following:
- Application builds without errors
- All component imports resolve correctly (no leftover Radix UI imports)
-
asChildprops have been replaced withrenderprops everywhere -
onSelecthandlers on menu items have been replaced withonClick - Custom CSS using
data-[state=open]/data-[state=closed]has been updated todata-open/data-closed -
TooltipProviderwraps all Tooltip usage - Toast integration uses
ToastProviderandtoastManagerinstead ofToasteranduseToast() - Select components use the new items array pattern
- Accordion / Toggle Group no longer use the
typeprop (usemultipleinstead) - Interactive components (menus, dialogs, tooltips, popovers) open and close properly
- Form components (checkbox, select, input, radio group) submit values correctly
- Keyboard navigation works as expected
- Dark mode toggles correctly
- Custom styles applied via
classNamestill work - Animations (accordion, collapsible, dialog, sheet) play smoothly