# Modal Dialog

> Modal built on native <dialog> with enter / exit animation, focus handling and backdrop dismiss.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/modal-dialog
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/modal-dialog.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/modal-dialog-demo.tsx"
"use client"

import * as React from "react"
import { TriangleAlert } from "lucide-react"

import { ModalDialog } from "@/components/modal-dialog"

const button = "inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium transition-colors"

export default function ModalDialogDemo() {
  const [edit, setEdit] = React.useState(false)
  const [remove, setRemove] = React.useState(false)

  return (
    <div className="flex flex-col items-center gap-6">
      <div className="flex w-80 items-center gap-3 rounded-2xl border bg-card p-4 shadow-sm">
        <span className="flex size-10 items-center justify-center rounded-full bg-orange-500/15 font-semibold text-orange-600 dark:text-orange-400">AL</span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-medium">Ada Lovelace</p>
          <p className="truncate text-xs text-muted-foreground">ada@analytical.engine</p>
        </div>
      </div>
      <div className="flex gap-3">
        <button type="button" onClick={() => setEdit(true)} className={`${button} border bg-background hover:bg-accent`}>
          Edit profile
        </button>
        <button type="button" onClick={() => setRemove(true)} className={`${button} bg-destructive text-white hover:bg-destructive/90 dark:bg-red-600 dark:hover:bg-red-700`}>
          Delete account
        </button>
      </div>

      <ModalDialog
        open={edit}
        onOpenChange={setEdit}
        title="Edit profile"
        description="Changes are visible to everyone in your workspace."
        footer={
          <>
            <button type="button" onClick={() => setEdit(false)} className={`${button} border bg-background hover:bg-accent`}>
              Cancel
            </button>
            <button type="button" onClick={() => setEdit(false)} className={`${button} bg-primary text-primary-foreground hover:opacity-90`}>
              Save changes
            </button>
          </>
        }
      >
        <div className="grid gap-3">
          <label className="grid gap-1.5 text-sm font-medium">
            Name
            <input defaultValue="Ada Lovelace" className="h-9 rounded-lg border bg-background px-3 font-normal outline-none focus:border-ring focus:ring-3 focus:ring-ring/20" />
          </label>
          <label className="grid gap-1.5 text-sm font-medium">
            Email
            <input defaultValue="ada@analytical.engine" className="h-9 rounded-lg border bg-background px-3 font-normal outline-none focus:border-ring focus:ring-3 focus:ring-ring/20" />
          </label>
        </div>
      </ModalDialog>

      <ModalDialog
        open={remove}
        onOpenChange={setRemove}
        size="sm"
        dismissible={false}
        showClose={false}
        title={
          <span className="flex items-center gap-2">
            <TriangleAlert className="size-5 text-destructive" /> Delete account?
          </span>
        }
        description="This permanently deletes your account and all of its projects. This can't be undone."
        footer={
          <>
            <button type="button" onClick={() => setRemove(false)} className={`${button} border bg-background hover:bg-accent`}>
              Cancel
            </button>
            <button type="button" onClick={() => setRemove(false)} className={`${button} bg-destructive text-white hover:bg-destructive/90 dark:bg-red-600 dark:hover:bg-red-700`}>
              Delete
            </button>
          </>
        }
      />
    </div>
  )
}
```

## Source

```tsx title="components/modal-dialog.tsx"
"use client"

import * as React from "react"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ModalDialogProps extends Omit<React.ComponentProps<"dialog">, "open" | "title"> {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: React.ReactNode
  description?: React.ReactNode
  /** Buttons along the bottom edge */
  footer?: React.ReactNode
  size?: "sm" | "md" | "lg"
  /** Clicking the dimmed backdrop closes the dialog */
  dismissible?: boolean
  showClose?: boolean
}

const sizes = { sm: "max-w-sm", md: "max-w-lg", lg: "max-w-2xl" }

// Built on <dialog>: the browser handles the top layer, focus trapping,
// inert background and Escape. Entry and exit animate with @starting-style.
export function ModalDialog({
  open,
  onOpenChange,
  title,
  description,
  footer,
  size = "md",
  dismissible = true,
  showClose = true,
  className,
  children,
  ...props
}: ModalDialogProps) {
  const ref = React.useRef<HTMLDialogElement>(null)
  const titleId = React.useId()
  const descriptionId = React.useId()

  React.useEffect(() => {
    const dialog = ref.current
    if (!dialog) return
    if (open && !dialog.open) dialog.showModal()
    else if (!open && dialog.open) dialog.close()
  }, [open])

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
      onCancel={(event) => {
        // Escape: keep the open prop the source of truth
        event.preventDefault()
        onOpenChange(false)
      }}
      onClick={(event) => {
        if (dismissible && event.target === event.currentTarget) onOpenChange(false)
      }}
      className={cn(
        "m-auto w-[calc(100%-2rem)] rounded-2xl border bg-background p-0 text-foreground shadow-2xl",
        "scale-95 opacity-0 transition-[opacity,scale,display,overlay] transition-discrete duration-200 ease-out open:scale-100 open:opacity-100 starting:open:scale-95 starting:open:opacity-0",
        "backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-[background-color,backdrop-filter,display,overlay] backdrop:transition-discrete backdrop:duration-200 open:backdrop:bg-black/50 open:backdrop:backdrop-blur-[2px] starting:open:backdrop:bg-black/0",
        sizes[size],
        className
      )}
      {...props}
    >
      <div className="flex flex-col gap-5 p-6">
        <div className="flex flex-col gap-1.5 pr-8">
          <h2 id={titleId} className="text-lg leading-tight font-semibold">
            {title}
          </h2>
          {description && (
            <p id={descriptionId} className="text-sm text-muted-foreground">
              {description}
            </p>
          )}
        </div>
        {children}
        {footer && <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">{footer}</div>}
      </div>
      {showClose && (
        <button
          type="button"
          onClick={() => onOpenChange(false)}
          aria-label="Close"
          className="absolute top-4 right-4 flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
        >
          <X className="size-4" />
        </button>
      )}
    </dialog>
  )
}
```
