Getting Started

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@^19

Remove 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/react

Update components.json

Your components.json needs to be updated for V4. The full V3 and V4 configurations are shown below.

components.json (V3)
{
  "$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"
  }
}
components.json (V4)
{
  "$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: Added ui, lib aliases
  • registries: 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 --overwrite

Step 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/upgrade

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

postcss.config.mjs (V3)
export default {
  plugins: {
    "postcss-import": {},
    "tailwindcss/nesting": {},
    tailwindcss: {},
    autoprefixer: {},
  },
}
postcss.config.mjs (V4)
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.

globals.css (V3)
@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;
  }
}
globals.css (V4)
@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-*, and radius-* tokens

Step 3: General API Pattern Changes

These patterns affect multiple components. Understanding them first will make the component-specific migration easier.

asChildrender

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.

onSelectonClick

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.

forceMountkeepMounted

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

These components have significant API changes that require code updates.

Accordion

  • type="single" | "multiple" replaced with boolean multiple prop
  • collapsible prop removed — single-mode accordion is always closable
  • value and defaultValue type changed from string (single) or string[] (multiple) to always string[]
  • AccordionContent internally renamed to AccordionPrimitive.Panel (export name kept as AccordionContent)
V3V4
<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)
  • SelectContent now internally uses a Positioner + Popup structure. The component name is kept as SelectContent for convenience.
  • The popup now overlaps the trigger by default so the selected item aligns with the trigger text. Set alignItemWithTrigger={false} on SelectContent to restore the previous behavior.
  • New size prop on SelectTrigger: "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>
  • asChildrender on DrawerTrigger and DrawerClose (see General API Pattern Changes)
  • The DrawerOverlay component has been renamed to DrawerBackdrop internally (but the export name DrawerOverlay is kept for convenience)
  • The direction prop is still supported

Context Menu

  • asChildrender (see General API Pattern Changes)
  • onSelectonClick (see General API Pattern Changes)
  • ContextMenuLabel must now be wrapped in ContextMenuGroup
  • Removed inset prop from ContextMenuLabel
  • ContextMenuSub renamed to ContextMenuSubmenuRoot internally
  • ContextMenuSubTrigger renamed to ContextMenuSubmenuTrigger internally

Toggle Group

  • type="single" | "multiple" replaced with boolean multiple prop
  • New size prop options: xs, icon, icon-xs, icon-sm, icon-lg
  • New variant prop options: primary, primary-muted
  • New spacing prop to add spacing between items (default: 2, use 0 for no gap)
  • New orientation prop: "horizontal" (default) | "vertical"
V3V4
<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 with orientation="horizontal"
  • Label replaced with FieldLabel inside Field
  • Unchecked border color changed from border-primary to border-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 together
  • FieldContent — content area within a field
  • FieldLabel — label for a field (replaces using Label directly)
  • FieldTitle — non-label title text
  • FieldDescription — help text for a field
  • FieldSeparator — visual separator between fields
  • FieldError — 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

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:

V3V4
direction proporientation prop
defaultSize={50}defaultSize="50%"
onLayoutonLayoutChange
ref on PanelpanelRef prop
onCollapse / onExpand on HandleUse 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 variant prop on TabsList: "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 orientation prop: "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

  • decorative prop removed — use aria-hidden="true" instead
V3V4
<Separator decorative /><Separator aria-hidden="true" />

Tooltip

  • asChildrender on TooltipTrigger (see General API Pattern Changes)
  • TooltipProvider: delayDuration prop renamed to delay
  • Tooltip no longer wraps TooltipProvider internally — you must wrap your app (or a section) with TooltipProvider separately

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.

  • DialogOverlay internally renamed to DialogPrimitive.Backdrop (export name kept as DialogOverlay)
  • DialogContent now uses Viewport + Popup internally (export name kept as DialogContent)
  • DialogClose: asChildrender (see General API Pattern Changes)
  • DialogFooter now accepts an optional showCloseButton prop

Alert Dialog

Same structural changes as Dialog:

  • AlertDialogOverlay internally renamed to AlertDialogPrimitive.Backdrop
  • AlertDialogContent now uses Viewport + Popup internally
  • AlertDialogCancel now uses AlertDialogPrimitive.Close with render prop instead of AlertDialogPrimitive.Cancel
  • AlertDialogAction now wraps a Button component directly
  • AlertDialogFooter now accepts an optional showCloseButton prop

Sheet

Same structural changes as Dialog:

  • SheetOverlay internally renamed to SheetPrimitive.Backdrop
  • SheetContent now uses Popup internally
  • SheetClose: asChildrender (see General API Pattern Changes)
  • SheetContent now accepts a showCloseButton prop
  • Animation CSS has been rewritten (uses data-starting-style / data-ending-style instead of data-[state=open/closed])

Popover

  • Content now uses Positioner + Popup internally (export name kept as PopoverContent)
  • 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) → ToastProvider
  • useToast() hook / toast()toastManager.add()
  • New AnchoredToastProvider for anchored positioning
  • See the Toast documentation for full API details
  • NavigationMenuIndicator has been removed
  • New NavigationMenuPositioner component required (wraps viewport content)
  • NavigationMenu now 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

  • asChildrender (see General API Pattern Changes)
  • variant="default" renamed to variant="primary" (the default variant, so no change needed unless you specified it explicitly)
  • New variant options: primary-outline, primary-ghost, destructive-outline, destructive-ghost
  • New size options: 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 locale prop for locale-aware date formatting
  • Internal styling has been significantly rewritten (cell size, border radius, range selection styles)

Pagination

  • Default size of PaginationLink changed from "icon" to "default"
  • PaginationPrevious / PaginationNext now accept a text prop to customize the label
  • PaginationEllipsis now accepts a size prop
  • New size options on PaginationLink: 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 size prop: "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-2 to px-1.5
  • Font weight changed from font-semibold to font-medium
  • SVGs inside the badge are now automatically sized
  • New data-icon="inline-start" and data-icon="inline-end" data attributes

Button

  • Updated dimensions (sizes, spacing, rounded corners, font sizes)
  • SVGs are now automatically sized

Card

  • New size prop: "default", "sm"
  • Updated dimensions (spacing, padding)

Input

  • Updated size and padding
  • New size prop: "default", "sm" (also accepts a number for the native size attribute)
  • 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 ProgressLabel and ProgressValue components for displaying labels and values inside the progress bar

Slider

  • Removed my-2 from root element
  • Updated thumb styles

Switch

  • New size prop

Textarea

  • New size prop

Toggle

  • Updated button sizes and paddings
  • New size options: xs, icon, icon-xs, icon-sm, icon-lg
  • New variant options: 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:

Verification Checklist

After migration, verify the following:

  • Application builds without errors
  • All component imports resolve correctly (no leftover Radix UI imports)
  • asChild props have been replaced with render props everywhere
  • onSelect handlers on menu items have been replaced with onClick
  • Custom CSS using data-[state=open] / data-[state=closed] has been updated to data-open / data-closed
  • TooltipProvider wraps all Tooltip usage
  • Toast integration uses ToastProvider and toastManager instead of Toaster and useToast()
  • Select components use the new items array pattern
  • Accordion / Toggle Group no longer use the type prop (use multiple instead)
  • 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 className still work
  • Animations (accordion, collapsible, dialog, sheet) play smoothly

On this page