packages/codemod/docs/MODAL_MIGRATION.md
This guide explains how to migrate from Chakra UI v2 Modal components to the v3 Dialog compound component API.
In Chakra UI v3, the Modal component has been redesigned and renamed to Dialog with:
onOpenChangenpx @chakra-ui/codemod@latest modal <path>
This will automatically transform all Modal components to Dialog in your codebase.
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react"
function App() {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Modal Title</ModalHeader>
<ModalCloseButton />
<ModalBody>Body content</ModalBody>
<ModalFooter>
<Button onClick={onClose}>Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
import { Button, Dialog, Portal } from "@chakra-ui/react"
function App() {
return (
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) {
onClose()
}
}}
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>Modal Title</Dialog.Header>
<Dialog.CloseTrigger />
<Dialog.Body>Body content</Dialog.Body>
<Dialog.Footer>
<Button onClick={onClose}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
)
}
Key Changes:
Modal → Dialog.RootDialog.*Portal (automatically added by codemod)ModalContent wrapped in Dialog.Positionerimport { Dialog, Portal } from '@chakra-ui/react'| v2 Component | v3 Component | Notes |
|---|---|---|
Modal | Dialog.Root | Root container |
ModalOverlay | Dialog.Backdrop | Backdrop overlay |
ModalContent | Dialog.Content | Wrap in Dialog.Positioner |
ModalHeader | Dialog.Header | Header section |
ModalBody | Dialog.Body | Body content |
ModalFooter | Dialog.Footer | Footer section |
ModalCloseButton | Dialog.CloseTrigger | Close button |
Before (v2):
<Modal isOpen={isOpen} onClose={onClose}>
</Modal>
After (v3):
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) {
onClose()
}
}}
>
</Dialog.Root>
Changes:
isOpen → openonClose → handled via onOpenChangeThe onClose callback is now handled through the onOpenChange event handler.
Before (v2):
<Modal isOpen={isOpen} onClose={handleClose}>
</Modal>
After (v3):
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) {
handleClose()
}
}}
>
</Dialog.Root>
Changes:
onClose callback is invoked when e.open is falseBefore (v2):
<Modal isOpen={isOpen} onClose={onClose} isCentered>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} placement="center">
</Dialog.Root>
Changes:
isCentered → placement="center"Before (v2):
<Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false}>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} closeOnInteractOutside={false}>
</Dialog.Root>
Changes:
closeOnOverlayClick → closeOnInteractOutsideBefore (v2):
<Modal isOpen={isOpen} onClose={onClose} closeOnEsc={false}>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} closeOnEscape={false}>
</Dialog.Root>
Changes:
closeOnEsc → closeOnEscapeBefore (v2):
<Modal isOpen={isOpen} onClose={onClose} onOverlayClick={handleOverlay}>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} onInteractOutside={handleOverlay}>
</Dialog.Root>
Changes:
onOverlayClick → onInteractOutsideBefore (v2):
<Modal isOpen={isOpen} onClose={onClose} blockScrollOnMount={false}>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} preventScroll={false}>
</Dialog.Root>
Changes:
blockScrollOnMount → preventScrollBefore (v2):
const initialRef = React.useRef()
<Modal isOpen={isOpen} onClose={onClose} initialFocusRef={initialRef}>
</Modal>
After (v3):
const initialRef = React.useRef()
<Dialog.Root open={isOpen} onOpenChange={...} initialFocusEl={() => initialRef.current}>
</Dialog.Root>
Changes:
initialFocusRef={ref} → initialFocusEl={() => ref.current}ref.currentBefore (v2):
const finalRef = React.useRef()
<Modal isOpen={isOpen} onClose={onClose} finalFocusRef={finalRef}>
</Modal>
After (v3):
const finalRef = React.useRef()
<Dialog.Root open={isOpen} onOpenChange={...} finalFocusEl={() => finalRef.current}>
</Dialog.Root>
Changes:
finalFocusRef={ref} → finalFocusEl={() => ref.current}ref.currentBefore (v2):
<Modal isOpen={isOpen} onClose={onClose} onEsc={handleEsc}>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} onEscapeKeyDown={handleEsc}>
</Dialog.Root>
Changes:
onEsc → onEscapeKeyDownBefore (v2):
<Modal isOpen={isOpen} onClose={onClose} onCloseComplete={handleComplete}>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...} onExitComplete={handleComplete}>
</Dialog.Root>
Changes:
onCloseComplete → onExitCompleteModal sizes have been remapped to match v3's size scale.
v2 Sizes: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" |
"4xl" | "5xl" | "6xl" | "full"
v3 Sizes: "xs" | "sm" | "md" | "lg" | "xl" | "cover" | "full"
Size Mapping:
| v2 Size | v3 Size |
|---|---|
xs | xs |
sm | sm |
md | md |
lg | lg |
xl | xl |
2xl | xl |
3xl | xl |
4xl | xl |
5xl | xl |
6xl | xl |
full | full |
Example:
// v2
<Modal size="3xl" isOpen={isOpen} onClose={onClose}>
</Modal>
// v3
<Dialog.Root size="xl" open={isOpen} onOpenChange={...}>
</Dialog.Root>
Note: Sizes 2xl through 6xl are all mapped to xl in v3. If you need
finer control, use custom styling.
The following props pass through unchanged:
| Prop | Description |
|---|---|
motionPreset | Animation preset for enter/exit |
scrollBehavior | How content scrolls ("inside" or "outside") |
trapFocus | Whether to trap focus inside dialog |
Example:
// v2 and v3 (unchanged)
<Modal
isOpen={isOpen}
onClose={onClose}
motionPreset="slideInBottom"
scrollBehavior="inside"
trapFocus={false}
>
</Modal>
// v3
<Dialog.Root
open={isOpen}
onOpenChange={...}
motionPreset="slideInBottom"
scrollBehavior="inside"
trapFocus={false}
>
</Dialog.Root>
The following props have been removed in v3:
| Removed Prop | Reason / Alternative |
|---|---|
allowPinchZoom | No longer supported |
autoFocus | Handled automatically |
lockFocusAcrossFrames | No longer needed |
preserveScrollBarGap | Handled automatically |
returnFocusOnClose | Use finalFocusEl instead |
useInert | Handled automatically |
portalProps | Use <Portal> component directly |
In v2, Modal automatically rendered the backdrop and content in a portal. In v3,
you must explicitly use the <Portal> component, but the codemod adds this
automatically.
Before (v2):
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent></ModalContent>
</Modal>
After (v3):
<Dialog.Root open={isOpen} onOpenChange={...}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
The codemod automatically:
<Portal>Portal to importsBefore (v2):
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react"
export default function App() {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Button onClick={onOpen}>Open Modal</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Modal Title</ModalHeader>
<ModalBody>
<p>Modal body text goes here.</p>
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
After (v3):
import { Button, Dialog, Portal } from "@chakra-ui/react"
export default function App() {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Button onClick={onOpen}>Open Modal</Button>
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) {
onClose()
}
}}
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>Modal Title</Dialog.Header>
<Dialog.Body>
<p>Modal body text goes here.</p>
</Dialog.Body>
<Dialog.Footer>
<Button onClick={onClose}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</>
)
}
Before (v2):
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react"
export default function App() {
const { isOpen, onOpen, onClose } = useDisclosure()
const finalRef = React.useRef(null)
return (
<>
<Button ref={finalRef} onClick={onOpen}>
Open Modal
</Button>
<Modal
isOpen={isOpen}
onClose={onClose}
finalFocusRef={finalRef}
isCentered
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Centered Modal</ModalHeader>
<ModalCloseButton />
<ModalBody>Focus will return to the button when closed.</ModalBody>
</ModalContent>
</Modal>
</>
)
}
After (v3):
import { Button, Dialog, Portal } from "@chakra-ui/react"
export default function App() {
const { isOpen, onOpen, onClose } = useDisclosure()
const finalRef = React.useRef(null)
return (
<>
<Button ref={finalRef} onClick={onOpen}>
Open Modal
</Button>
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) {
onClose()
}
}}
finalFocusEl={() => finalRef.current}
placement="center"
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>Centered Modal</Dialog.Header>
<Dialog.CloseTrigger />
<Dialog.Body>
Focus will return to the button when closed.
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</>
)
}
Before (v2):
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react"
export default function App() {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Button onClick={onOpen}>Open Large Modal</Button>
<Modal
isOpen={isOpen}
onClose={onClose}
size="4xl"
motionPreset="slideInBottom"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Large Modal</ModalHeader>
<ModalBody>
<p>Content with custom size and animation.</p>
</ModalBody>
</ModalContent>
</Modal>
</>
)
}
After (v3):
import { Button, Dialog, Portal } from "@chakra-ui/react"
export default function App() {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Button onClick={onOpen}>Open Large Modal</Button>
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) {
onClose()
}
}}
size="xl"
motionPreset="slideInBottom"
scrollBehavior="inside"
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>Large Modal</Dialog.Header>
<Dialog.Body>
<p>Content with custom size and animation.</p>
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</>
)
}
Note: size="4xl" is automatically mapped to size="xl" in v3.
After running the codemod, review your code for:
onOpenChange logic works correctlymotionPreset animations work as expectedAfter migration, test:
If you encounter issues during migration: