ModalPreview

Dialog overlay for focused user interactions and important content

Import

import { Modal, useModalState } from '@heroui/react';

Usage

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";

export function Default() {
  return (
    <Modal>
      <Button>Open Modal</Button>
      <Modal.Overlay>
        <Modal.Container>
          <Modal.Dialog className="sm:max-w-[360px]">
            <Modal.CloseTrigger />
            <Modal.Header>
              <div className="bg-default ring-muted/25 flex size-10 items-center justify-center rounded-full ring-1">
                <Icon className="size-5" icon="gravity-ui:rocket" />
              </div>
              <h2 className="text-foreground text-lg font-semibold leading-6">Welcome to HeroUI</h2>
            </Modal.Header>
            <Modal.Body>
              <p>
                Beautiful, fast and modern React UI library for building accessible and customizable
                web applications.
              </p>
            </Modal.Body>
            <Modal.Footer>
              <Button className="w-full">Continue</Button>
            </Modal.Footer>
          </Modal.Dialog>
        </Modal.Container>
      </Modal.Overlay>
    </Modal>
  );
}

Anatomy

<Modal>
  <Modal.Trigger />     {/* Or any trigger element */}
  <Modal.Overlay>       {/* Backdrop */}
    <Modal.Container>   {/* Positioning container */}
      <Modal.Dialog>    {/* Content wrapper */}
        <Modal.CloseTrigger />
        <Modal.Header />
        <Modal.Body />
        <Modal.Footer />
      </Modal.Dialog>
    </Modal.Container>
  </Modal.Overlay>
</Modal>

Placement

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";

export function Placements() {
  const placements = ["auto", "top", "center", "bottom"] as const;

  return (
    <div className="flex flex-wrap gap-4">
      {placements.map((placement) => (
        <Modal key={placement}>
          <Button>{placement.charAt(0).toUpperCase() + placement.slice(1)}</Button>
          <Modal.Overlay>
            <Modal.Container placement={placement}>
              <Modal.Dialog className="max-w-[360px]">
                <Modal.CloseTrigger />
                <Modal.Header>
                  <div className="bg-default flex size-10 items-center justify-center rounded-full">
                    <Icon icon="gravity-ui:rocket" />
                  </div>
                  <h2 className="text-foreground text-lg font-semibold leading-6">
                    Welcome to HeroUI
                  </h2>
                </Modal.Header>
                <Modal.Body>
                  <p>
                    Beautiful, fast and modern React UI library for building accessible and
                    customizable web applications.
                  </p>
                </Modal.Body>
                <Modal.Footer>
                  <Button className="w-full">Continue</Button>
                </Modal.Footer>
              </Modal.Dialog>
            </Modal.Container>
          </Modal.Overlay>
        </Modal>
      ))}
    </div>
  );
}

Overlay Variants

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";

export function OverlayVariants() {
  const variants = ["solid", "blur", "transparent"] as const;

  return (
    <div className="flex flex-wrap gap-4">
      {variants.map((variant) => (
        <Modal key={variant}>
          <Button>{variant.charAt(0).toUpperCase() + variant.slice(1)}</Button>
          <Modal.Overlay variant={variant}>
            <Modal.Container>
              <Modal.Dialog className="max-w-[360px]">
                <Modal.CloseTrigger />
                <Modal.Header>
                  <div className="bg-default flex size-10 items-center justify-center rounded-full">
                    <Icon icon="gravity-ui:rocket" />
                  </div>
                  <h2 className="text-foreground text-lg font-semibold leading-6">
                    Welcome to HeroUI
                  </h2>
                </Modal.Header>
                <Modal.Body>
                  <p>
                    Beautiful, fast and modern React UI library for building accessible and
                    customizable web applications.
                  </p>
                </Modal.Body>
                <Modal.Footer>
                  <Button className="w-full">Continue</Button>
                </Modal.Footer>
              </Modal.Dialog>
            </Modal.Container>
          </Modal.Overlay>
        </Modal>
      ))}
    </div>
  );
}

Dismiss Behavior

isDismissable

Controls whether the modal can be dismissed by clicking the overlay backdrop. When set to true, clicking outside the modal will close it.

isKeyboardDismissDisabled

Controls whether the ESC key can dismiss the modal. When set to true, the ESC key will be disabled and won't close the modal.

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";

export function DismissBehavior() {
  return (
    <div className="flex max-w-sm flex-col gap-6">
      <div className="flex flex-col gap-2">
        <h3 className="text-lg font-semibold">isDismissable</h3>
        <p className="text-muted text-sm">
          Controls whether the modal can be dismissed by clicking the overlay backdrop. When set to{" "}
          <code>true</code>, clicking outside the modal will close it.
        </p>
        <Modal>
          <Button>Open Modal</Button>
          <Modal.Overlay isDismissable>
            <Modal.Container>
              <Modal.Dialog className="max-w-[360px]">
                <Modal.CloseTrigger />
                <Modal.Header>
                  <div className="bg-default ring-muted/25 flex size-10 items-center justify-center rounded-full ring-1">
                    <Icon className="size-5" icon="gravity-ui:circle-info" />
                  </div>
                  <h2 className="text-foreground text-lg font-semibold leading-6">
                    isDismissable = true
                  </h2>
                  <p className="text-muted text-sm leading-5">
                    Click the overlay backdrop to close
                  </p>
                </Modal.Header>
                <Modal.Body>
                  <p>Click anywhere outside this modal (on the dark overlay) to dismiss it.</p>
                </Modal.Body>
                <Modal.Footer>
                  <Button className="w-full">Close</Button>
                </Modal.Footer>
              </Modal.Dialog>
            </Modal.Container>
          </Modal.Overlay>
        </Modal>
      </div>

      <div className="flex flex-col gap-2">
        <h3 className="text-lg font-semibold">isKeyboardDismissDisabled</h3>
        <p className="text-muted text-sm">
          Controls whether the ESC key can dismiss the modal. When set to <code>true</code>, the ESC
          key will be disabled and won't close the modal.
        </p>
        <Modal>
          <Button>Open Modal</Button>
          <Modal.Overlay isKeyboardDismissDisabled>
            <Modal.Container>
              <Modal.Dialog className="max-w-[360px]">
                <Modal.CloseTrigger />
                <Modal.Header>
                  <div className="bg-default ring-muted/25 flex size-10 items-center justify-center rounded-full ring-1">
                    <Icon className="size-5" icon="gravity-ui:circle-info" />
                  </div>
                  <h2 className="text-foreground text-lg font-semibold leading-6">
                    isKeyboardDismissDisabled = true
                  </h2>
                  <p className="text-muted text-sm leading-5">ESC key is disabled</p>
                </Modal.Header>
                <Modal.Body>
                  <p>
                    Press ESC - it won't close this modal. Use the close button or click the
                    overlay.
                  </p>
                </Modal.Body>
                <Modal.Footer>
                  <Button className="w-full">Close</Button>
                </Modal.Footer>
              </Modal.Dialog>
            </Modal.Container>
          </Modal.Overlay>
        </Modal>
      </div>
    </div>
  );
}

Scroll Behavior

"use client";

import {Button, Modal} from "@heroui/react";
import {useState} from "react";

export function ScrollBehavior() {
  const [scroll, setScroll] = useState<"inside" | "outside">("inside");

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-4">
        <label className="flex items-center gap-2">
          <input
            checked={scroll === "inside"}
            name="scroll"
            type="radio"
            value="inside"
            onChange={(e) => setScroll(e.target.value as "inside" | "outside")}
          />
          Inside
        </label>

        <label className="flex items-center gap-2">
          <input
            checked={scroll === "outside"}
            name="scroll"
            type="radio"
            value="outside"
            onChange={(e) => setScroll(e.target.value as "inside" | "outside")}
          />
          Outside
        </label>
      </div>

      <Modal>
        <Button>Open Modal ({scroll.charAt(0).toUpperCase() + scroll.slice(1)})</Button>
        <Modal.Overlay>
          <Modal.Container scroll={scroll}>
            <Modal.Dialog>
              <Modal.Header>
                <h2 className="text-foreground text-lg font-semibold leading-6">
                  Scroll: {scroll.charAt(0).toUpperCase() + scroll.slice(1)}
                </h2>
                <p className="text-muted text-sm leading-5">
                  Toggle the radio buttons above to see different scroll behaviors
                </p>
              </Modal.Header>
              <Modal.Body>
                {Array.from({length: 30}).map((_, i) => (
                  <p key={i} className="mb-3">
                    Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
                    Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet
                    hendrerit risus, sed porttitor quam.
                  </p>
                ))}
              </Modal.Body>
              <Modal.Footer>
                <Button variant="secondary">Cancel</Button>
                <Button>Confirm</Button>
              </Modal.Footer>
              <Modal.CloseTrigger />
            </Modal.Dialog>
          </Modal.Container>
        </Modal.Overlay>
      </Modal>
    </div>
  );
}

With Form

"use client";

import {Button, Input, Label, Modal, TextField} from "@heroui/react";
import {Icon} from "@iconify/react";

export function WithForm() {
  return (
    <Modal>
      <Button>Open Contact Form</Button>
      <Modal.Overlay>
        <Modal.Container placement="auto">
          <Modal.Dialog className="sm:max-w-md">
            <Modal.CloseTrigger />
            <Modal.Header>
              <div className="bg-accent-soft text-accent-soft-foreground flex size-10 items-center justify-center rounded-full">
                <Icon className="size-5" icon="gravity-ui:envelope" />
              </div>
              <h2 className="text-foreground text-lg font-semibold leading-6">Contact Us</h2>
              <p className="text-muted text-sm leading-5">
                Fill out the form below. On mobile, the modal will adapt when the keyboard appears.
              </p>
            </Modal.Header>
            <Modal.Body>
              <form className="flex flex-col gap-4">
                <TextField className="w-full" name="name" type="text">
                  <Label>Name</Label>
                  <Input placeholder="Enter your name" />
                </TextField>

                <TextField className="w-full" name="email" type="email">
                  <Label>Email</Label>
                  <Input placeholder="Enter your email" />
                </TextField>

                <TextField className="w-full" name="phone" type="tel">
                  <Label>Phone</Label>
                  <Input placeholder="Enter your phone number" />
                </TextField>

                <TextField className="w-full" name="company">
                  <Label>Company</Label>
                  <Input placeholder="Enter your company name" />
                </TextField>

                <TextField className="w-full" name="message">
                  <Label>Message</Label>
                  <Input placeholder="Enter your message" />
                </TextField>
              </form>
            </Modal.Body>
            <Modal.Footer>
              <Modal.CloseTrigger asChild>
                <Button variant="secondary">Cancel</Button>
              </Modal.CloseTrigger>
              <Button>Send Message</Button>
            </Modal.Footer>
          </Modal.Dialog>
        </Modal.Container>
      </Modal.Overlay>
    </Modal>
  );
}

Controlled State

Modal State

Status: closed

"use client";

import {Button, Modal, useModalState} from "@heroui/react";
import {Icon} from "@iconify/react";

export function Controlled() {
  const modalState = useModalState();

  return (
    <div className="flex flex-col gap-4">
      <div className="border-border bg-default rounded-lg border p-4">
        <p className="mb-2 text-sm font-medium">Modal State</p>
        <p className="text-muted text-sm">
          Status: <span className="font-mono">{modalState.isOpen ? "open" : "closed"}</span>
        </p>
      </div>

      <div className="flex flex-wrap gap-3">
        <Button onPress={modalState.open}>Open Modal</Button>
        <Button variant="secondary" onPress={modalState.toggle}>
          Toggle Modal
        </Button>
      </div>

      <Modal state={modalState}>
        <Modal.Overlay>
          <Modal.Container>
            <Modal.Dialog>
              <Modal.Header>
                <div className="bg-accent-soft text-accent-soft-foreground flex size-10 items-center justify-center rounded-full">
                  <Icon className="size-5" icon="gravity-ui:circle-check" />
                </div>
                <h2 className="text-foreground text-lg font-semibold leading-6">
                  Controlled with useModalState()
                </h2>
                <p className="text-muted text-sm leading-5">
                  This modal is controlled programmatically using the useModalState() hook
                </p>
              </Modal.Header>
              <Modal.Body>
                <p>
                  The hook provides methods like <code>open()</code>, <code>close()</code>,{" "}
                  <code>toggle()</code>, and access to <code>isOpen</code> state.
                </p>
                <p className="mt-3">
                  This enables powerful patterns like opening modals from anywhere in your
                  component, multiple triggers, and external state management.
                </p>
              </Modal.Body>
              <Modal.Footer>
                <Button variant="secondary" onPress={modalState.close}>
                  Close
                </Button>
                <Button onPress={modalState.close}>Confirm</Button>
              </Modal.Footer>
              <Modal.CloseTrigger />
            </Modal.Dialog>
          </Modal.Container>
        </Modal.Overlay>
      </Modal>
    </div>
  );
}

Custom Trigger

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";

export function CustomTrigger() {
  return (
    <Modal>
      <Modal.Trigger>
        <div className="border-border bg-default hover:bg-default-hover flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors">
          <div className="flex size-10 items-center justify-center">
            <Icon className="text-primary size-6" icon="gravity-ui:gear" />
          </div>
          <div>
            <p className="font-medium">Settings</p>
            <p className="text-muted text-sm">Click to open settings</p>
          </div>
        </div>
      </Modal.Trigger>
      <Modal.Overlay>
        <Modal.Container>
          <Modal.Dialog>
            {({close}) => (
              <>
                <Modal.Header>
                  <div className="bg-accent-soft text-accent-soft-foreground flex size-10 items-center justify-center rounded-full">
                    <Icon className="size-5" icon="gravity-ui:gear" />
                  </div>
                  <h2 className="text-foreground text-lg font-semibold leading-6">Settings</h2>
                </Modal.Header>
                <Modal.Body>
                  <p>Using Modal.Trigger allows you to create custom trigger elements.</p>
                </Modal.Body>
                <Modal.Footer>
                  <Button variant="secondary" onPress={close}>
                    Cancel
                  </Button>
                  <Button onPress={close}>Save</Button>
                </Modal.Footer>
                <Modal.CloseTrigger />
              </>
            )}
          </Modal.Dialog>
        </Modal.Container>
      </Modal.Overlay>
    </Modal>
  );
}

Custom Overlay

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";

export function CustomOverlay() {
  return (
    <Modal>
      <Button>Custom Backdrop</Button>
      <Modal.Overlay
        className="bg-gradient-to-t from-black/80 via-black/40 to-transparent dark:from-zinc-800/80 dark:via-zinc-800/40"
        variant="blur"
      >
        <Modal.Container>
          <Modal.Dialog>
            <Modal.Header className="items-center text-center">
              <div className="bg-accent-soft text-accent-soft-foreground flex size-10 items-center justify-center rounded-full">
                <Icon className="size-5" icon="gravity-ui:sparkles" />
              </div>
              <h2 className="text-foreground text-lg font-semibold leading-6">Premium Backdrop</h2>
              <p className="text-muted text-sm leading-5">
                Elegant gradient adapts to light and dark modes
              </p>
            </Modal.Header>
            <Modal.Body>
              <p>
                This backdrop features a sophisticated gradient that transitions from an elegant
                dark color at the bottom to complete transparency at the top, combined with a smooth
                blur effect. The gradient automatically adapts its intensity for optimal contrast in
                both light and dark modes.
              </p>
            </Modal.Body>
            <Modal.Footer className="flex-col-reverse">
              <Button className="w-full">Amazing!</Button>
              <Button className="w-full" variant="secondary">
                Close
              </Button>
            </Modal.Footer>
            <Modal.CloseTrigger />
          </Modal.Dialog>
        </Modal.Container>
      </Modal.Overlay>
    </Modal>
  );
}

Custom Animations

"use client";

import {Button, Modal} from "@heroui/react";
import {Icon} from "@iconify/react";
import clsx from "clsx";

export function CustomAnimations() {
  const animations = {
    "Blur Slide": [
      "data-[entering]:blur-in-8",
      "data-[entering]:slide-in-from-bottom-8",
      "data-[entering]:fade-in-0",
      "data-[exiting]:blur-out-8",
      "data-[exiting]:slide-out-to-bottom-8",
      "data-[exiting]:fade-out",
    ].join(" "),
    Bounce: [
      "data-[entering]:zoom-in-50",
      "data-[entering]:fade-in-0",
      "data-[entering]:ease-[cubic-bezier(0.68,-0.55,0.265,1.55)]",
      "data-[exiting]:zoom-out-95",
      "data-[exiting]:fade-out",
      "data-[exiting]:duration-200",
    ].join(" "),
    "Rotate Zoom": [
      "data-[entering]:zoom-in-75",
      "data-[entering]:spin-in-45",
      "data-[entering]:fade-in-0",
      "data-[exiting]:zoom-out-95",
      "data-[exiting]:spin-out-12",
      "data-[exiting]:fade-out",
    ].join(" "),
    Spring: [
      "data-[entering]:zoom-in-90",
      "data-[entering]:ease-spring",
      "data-[exiting]:zoom-out-95",
      "data-[exiting]:ease-out-quart",
    ].join(" "),
  };

  return (
    <div className="flex flex-wrap gap-4">
      {Object.entries(animations).map(([name, classNames]) => (
        <Modal key={name}>
          <Button>{name}</Button>
          <Modal.Overlay className="data-[entering]:duration-500 data-[exiting]:duration-200">
            <Modal.Container
              className={clsx(
                classNames,
                "data-[entering]:animate-in",
                "data-[exiting]:animate-out",
                "data-[entering]:duration-300",
                "data-[exiting]:duration-150",
              )}
            >
              <Modal.Dialog className="sm:max-w-[360px]">
                <Modal.CloseTrigger />
                <Modal.Header>
                  <div className="bg-default ring-muted/25 flex size-10 items-center justify-center rounded-full ring-1">
                    <Icon className="size-5" icon="gravity-ui:rocket" />
                  </div>
                  <h2 className="text-foreground text-lg font-semibold leading-6">
                    Welcome to HeroUI
                  </h2>
                </Modal.Header>
                <Modal.Body>
                  <p>
                    Beautiful, fast and modern React UI library for building accessible and
                    customizable web applications.
                  </p>
                </Modal.Body>
                <Modal.Footer>
                  <Button className="w-full">Continue</Button>
                </Modal.Footer>
              </Modal.Dialog>
            </Modal.Container>
          </Modal.Overlay>
        </Modal>
      ))}
    </div>
  );
}

Styling

Passing Tailwind CSS classes

<Modal>
  <Button>Open</Button>
  <Modal.Overlay className="bg-black/80">
    <Modal.Container className="items-start pt-20">
      <Modal.Dialog className="bg-gradient-to-br from-purple-500 to-pink-500 text-white">
        <Modal.Header className="border-b border-white/20">
          <h2>Custom Styled Modal</h2>
        </Modal.Header>
        <Modal.Body>Content here</Modal.Body>
      </Modal.Dialog>
    </Modal.Container>
  </Modal.Overlay>
</Modal>

Customizing with CSS

@layer components {
  /* Custom overlay */
  .modal__overlay {
    @apply bg-gradient-to-br from-black/50 to-black/70;
  }

  /* Custom dialog */
  .modal__dialog {
    @apply border border-white/10 shadow-2xl;
  }

  /* Custom close button */
  .modal__close-trigger {
    @apply rounded-full bg-white/10;
  }
}

CSS Classes

Modal uses BEM naming for styling hooks.

Component Classes

ClassDescription
modal__triggerTrigger element wrapper
modal__overlayBackdrop overlay
modal__containerPositioning container
modal__dialogContent wrapper
modal__headerHeader section
modal__bodyBody content area
modal__footerFooter section
modal__close-triggerClose button

Variant Classes

ClassDescription
modal__overlay--solidSolid black backdrop
modal__overlay--blurBlurred backdrop
modal__overlay--transparentNo backdrop
modal__container--scroll-outsideScroll entire modal
modal__dialog--scroll-insideScroll body only
modal__dialog--scroll-outsideDialog for outside scroll
modal__body--scroll-insideScrollable body
modal__body--scroll-outsideNon-scrollable body

Interactive States

Modal components support standard interaction states:

/* Trigger focus */
.modal__trigger:focus-visible { }
.modal__trigger[data-focus-visible="true"] { }

/* Close button hover */
.modal__close-trigger:hover { }
.modal__close-trigger[data-hover="true"] { }

/* Close button pressed */
.modal__close-trigger:active { }
.modal__close-trigger[data-pressed="true"] { }

/* Dialog focus */
.modal__dialog:focus-visible { }
.modal__dialog[data-focus-visible="true"] { }

/* Animation states */
.modal__overlay[data-entering] { }
.modal__overlay[data-exiting] { }
.modal__container[data-entering] { }
.modal__container[data-exiting] { }

API Reference

PropTypeDefaultDescription
stateUseModalStateReturn-External state from useModalState hook
defaultOpenbooleanfalseInitially open state
isOpenboolean-Controlled open state
onOpenChange(isOpen: boolean) => void-Called when open state changes

Modal.Trigger Props

PropTypeDefaultDescription
childrenReactNode-Trigger content
classNamestring-Additional CSS classes

Modal.Overlay Props

PropTypeDefaultDescription
variant"solid" | "blur" | "transparent""solid"Backdrop style
classNamestring | (values) => string-Additional CSS classes
isDismissablebooleanfalseClose on backdrop click
isKeyboardDismissDisabledbooleantrueDisable ESC key

Modal.Container Props

PropTypeDefaultDescription
placement"auto" | "center" | "top" | "bottom""auto"Viewport position
scroll"inside" | "outside""inside"Scroll behavior
classNamestring | (values) => string-Additional CSS classes

Modal.Dialog Props

PropTypeDefaultDescription
childrenReactNode | ({close}) => ReactNode-Content or render function
classNamestring | (values) => string-Additional CSS classes
rolestring"dialog"ARIA role
aria-labelstring-Accessibility label
aria-labelledbystring-ID of labelling element
aria-describedbystring-ID of describing element

Modal.Body Props

PropTypeDefaultDescription
childrenReactNode-Body content
classNamestring-Additional CSS classes

Modal.Header Props

PropTypeDefaultDescription
childrenReactNode-Header content
classNamestring-Additional CSS classes

Modal.Footer Props

PropTypeDefaultDescription
childrenReactNode-Footer content
classNamestring-Additional CSS classes

Modal.CloseTrigger Props

PropTypeDefaultDescription
asChildbooleanfalseRender as child element
childrenReactNode-Custom close button
classNamestring | (values) => string-Additional CSS classes

Hooks

const modalState = useModalState({
  defaultOpen: false,
  onOpenChange: (isOpen) => console.log('Modal:', isOpen)
});

// Available methods
modalState.isOpen    // Current state
modalState.open()     // Open modal
modalState.close()    // Close modal
modalState.toggle()   // Toggle state
modalState.setOpen()  // Set state directly

Accessibility

Modal implements WAI-ARIA Dialog pattern with:

  • Focus Management: Focus trapped within modal when open
  • Keyboard Navigation:
    • Escape closes modal (when dismissable)
    • Tab cycles through interactive elements
    • Focus returns to trigger on close
  • Screen Readers: Proper ARIA attributes and live regions
  • Scroll Lock: Body scroll disabled when modal open
  • Click Outside: Closes modal when clicking backdrop (configurable)