apps/docs/content/docs/en/react/migration/(components)/modal.mdx
In v2, Modal used separate components:
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, useDisclosure } from "@heroui/react";
export default function App() {
const {isOpen, onOpen, onOpenChange} = useDisclosure();
return (
<>
<Button onPress={onOpen}>Open Modal</Button>
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Title</ModalHeader>
<ModalBody>Content</ModalBody>
<ModalFooter>Footer</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
In v3, Modal uses compound components:
import { Modal, Button } from "@heroui/react";
export default function App() {
return (
<Modal>
<Button>Open Modal</Button>
<Modal.Backdrop>
<Modal.Container>
<Modal.Dialog>
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>Title</Modal.Heading>
</Modal.Header>
<Modal.Body>Content</Modal.Body>
<Modal.Footer>Footer</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}
v2: Separate components (Modal, ModalContent, ModalHeader, ModalBody, ModalFooter)
v3: Compound components (Modal.Backdrop, Modal.Container, Modal.Dialog, Modal.Header, Modal.Body, Modal.Footer)
Modal.Triggerv2: Required useDisclosure hook and manual onPress wiring to open the modal
v3: Provides Modal.Trigger as a built-in trigger component that automatically opens the modal when pressed — no state management needed
<Tabs items={["v2", "v3 (Button trigger)", "v3 (Modal.Trigger)"]}> <Tab value="v2"> ```tsx import { Modal, ModalContent, Button, useDisclosure } from "@heroui/react";
const {isOpen, onOpen, onOpenChange} = useDisclosure();
<Button onPress={onOpen}>Open Modal</Button>
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent></ModalContent>
</Modal>
```
Modal.Trigger wraps any content in a pressable element that opens the modal. Use it when you need a custom trigger beyond a standard Button.
v2: Uses useDisclosure hook
v3: Built-in trigger (no state needed), controlled with isOpen/onOpenChange on Modal.Backdrop, or use useOverlayState and pass state to the root
useOverlayState replaces useDisclosureThe useOverlayState hook is a direct replacement for v2's useDisclosure. It supports both controlled and uncontrolled modes:
import { useOverlayState } from "@heroui/react";
// Uncontrolled (manages its own state)
const state = useOverlayState();
// Uncontrolled with default open
const state = useOverlayState({ defaultOpen: true });
// With callback
const state = useOverlayState({
onOpenChange: (isOpen) => console.log("Modal is now:", isOpen),
});
// Controlled (you manage the state)
const [isOpen, setIsOpen] = useState(false);
const state = useOverlayState({ isOpen, onOpenChange: setIsOpen });
Hook API:
| Property | Type | Description |
|---|---|---|
state.isOpen | boolean | Whether the overlay is currently open |
state.open() | () => void | Opens the overlay |
state.close() | () => void | Closes the overlay |
state.toggle() | () => void | Toggles the overlay open/closed |
state.setOpen(isOpen) | (isOpen: boolean) => void | Sets the open state directly |
Pass state to the Modal root to connect it:
const state = useOverlayState();
<Modal state={state}>
<Button onPress={state.open}>Open</Button>
<Modal.Backdrop>
<Modal.Container>
<Modal.Dialog></Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
| v2 Prop | v3 Location | Notes |
|---|---|---|
size | size (on Container) | Simplified (xs, sm, md, lg, cover, full) |
radius | — | Removed (use Tailwind CSS) |
shadow | — | Removed (use Tailwind CSS) |
backdrop | variant (on Backdrop) | Renamed; values unchanged (opaque, blur, transparent) |
scrollBehavior | scroll (on Container) | Renamed (normal → inside) |
placement | placement (on Container) | Moved to Container |
isDismissable | isDismissable (on Backdrop) | Moved to Modal.Backdrop |
isKeyboardDismissDisabled | isKeyboardDismissDisabled (on Backdrop) | Moved to Modal.Backdrop |
isOpen | isOpen (on Backdrop) | Controlled state on Modal.Backdrop |
onOpenChange | onOpenChange (on Backdrop) | Same as above |
onClose | — | Use close from render prop |
hideCloseButton | — | Omit Modal.CloseTrigger instead |
closeButton | — | Use Modal.CloseTrigger with custom content |
motionProps | — | Removed (animations handled differently) |
classNames | — | Use className props on individual components |
shouldBlockScroll | — | Removed (handled automatically) |
portalContainer | — | Removed |
<Tabs items={["v2", "v3 (useState)", "v3 (useOverlayState)", "v3 (state prop)"]}> <Tab value="v2"> ```tsx import { useDisclosure } from "@heroui/react";
const {isOpen, onOpen, onOpenChange} = useDisclosure();
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Title</ModalHeader>
<ModalBody>Content</ModalBody>
</>
)}
</ModalContent>
</Modal>
```
const [isOpen, setIsOpen] = useState(false);
<Modal>
<Button onPress={() => setIsOpen(true)}>Open</Button>
<Modal.Backdrop isOpen={isOpen} onOpenChange={setIsOpen}>
<Modal.Container>
<Modal.Dialog>
{({close}) => (
<>
<Modal.Header>
<Modal.Heading>Title</Modal.Heading>
</Modal.Header>
<Modal.Body>Content</Modal.Body>
</>
)}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
```
const state = useOverlayState();
<Modal>
<Button onPress={state.open}>Open</Button>
<Modal.Backdrop isOpen={state.isOpen} onOpenChange={state.setOpen}>
<Modal.Container>
<Modal.Dialog>
{({close}) => (
<>
<Modal.Header>
<Modal.Heading>Title</Modal.Heading>
</Modal.Header>
<Modal.Body>Content</Modal.Body>
</>
)}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
```
// Pass state directly to Modal root — no need to wire isOpen/onOpenChange manually
const state = useOverlayState();
<Modal state={state}>
<Button onPress={state.open}>Open</Button>
<Modal.Backdrop>
<Modal.Container>
<Modal.Dialog>
{({close}) => (
<>
<Modal.Header>
<Modal.Heading>Title</Modal.Heading>
</Modal.Header>
<Modal.Body>Content</Modal.Body>
</>
)}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
```
<Tabs items={["v2", "v3"]}>
<Tab value="v2">
tsx <Modal backdrop="blur"> <ModalContent></ModalContent> </Modal> <Modal placement="top"> <ModalContent></ModalContent> </Modal> <Modal scrollBehavior="outside"> <ModalContent></ModalContent> </Modal>
</Tab>
<Tab value="v3">
tsx <Modal.Backdrop variant="blur"> <Modal.Container> <Modal.Dialog></Modal.Dialog> </Modal.Container> </Modal.Backdrop> <Modal.Backdrop> <Modal.Container placement="top"> <Modal.Dialog></Modal.Dialog> </Modal.Container> </Modal.Backdrop> <Modal.Backdrop> <Modal.Container scroll="outside"> <Modal.Dialog></Modal.Dialog> </Modal.Container> </Modal.Backdrop>
</Tab>
</Tabs>
<Tabs items={["v2", "v3"]}>
<Tab value="v2">
tsx <Modal hideCloseButton> <ModalContent></ModalContent> </Modal> <Modal closeButton={<CustomCloseIcon />}> <ModalContent></ModalContent> </Modal>
</Tab>
<Tab value="v3">
tsx <Modal.Container> <Modal.Dialog> <Modal.Header> <Modal.Heading>Title</Modal.Heading> </Modal.Header> </Modal.Dialog> </Modal.Container> <Modal.Container> <Modal.Dialog> <Modal.CloseTrigger> <CustomCloseIcon /> </Modal.CloseTrigger> </Modal.Dialog> </Modal.Container>
</Tab>
</Tabs>
<Tabs items={["v2", "v3"]}> <Tab value="v2"> ```tsx import { Modal, ModalContent, useDisclosure } from "@heroui/react";
const {isOpen, onOpen, onOpenChange} = useDisclosure();
<div onClick={onOpen} className="cursor-pointer rounded-xl bg-surface p-4">
<p className="font-semibold">Settings</p>
<p className="text-xs text-muted">Manage your preferences</p>
</div>
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent></ModalContent>
</Modal>
```
<Tabs items={["v2", "v3"]}>
<Tab value="v2">
tsx <ModalHeader className="flex flex-col gap-1"> <Icon /> Modal Title </ModalHeader>
</Tab>
<Tab value="v3">
tsx <Modal.Header> <Modal.Icon> <Icon /> </Modal.Icon> <Modal.Heading>Modal Title</Modal.Heading> </Modal.Header>
</Tab>
</Tabs>
The v3 Modal follows this structure:
Modal (Root)
├── Trigger (e.g. Button or Modal.Trigger)
└── Modal.Backdrop (variant, isDismissable, isKeyboardDismissDisabled)
└── Modal.Container (placement, scroll, size)
└── Modal.Dialog
├── Modal.CloseTrigger (optional)
├── Modal.Header
│ ├── Modal.Icon (optional)
│ └── Modal.Heading
├── Modal.Body
└── Modal.Footer
Modal.Container, Modal.Dialog, etc.)Modal.Trigger for custom trigger elements or place a Button as a direct child of Modal -- no manual state wiring neededuseDisclosure replaced by useOverlayState hook; supports controlled/uncontrolled modes and a state prop on Modal rootModal to Modal.Backdrop and Modal.ContaineronClose callback replaced with close render prop on Modal.DialoghideCloseButton/closeButton replaced with Modal.CloseTriggersize on Modal.Container (xs, sm, md, lg, cover, full); radius and shadow removed -- use Tailwindbackdrop → variant on Modal.Backdrop (values: opaque, blur, transparent)scrollBehavior → scroll (normal → inside)motionProps removed, animations handled differentlyModal.Trigger, Modal.Icon, Modal.Heading, Modal.CloseTrigger