Dialog

Generic modal with header/body/footer, focus trap, Esc + backdrop close, and a portal.

import { Dialog }from "@/components/ui"

With form + actions

Full source

The complete component, read from src/components/ui/dialog.tsx.

dialog.tsx
"use client";

import { useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMounted, useFocusTrap } from "@/hooks";

type DialogSize = "sm" | "md" | "lg";

const SIZE: Record<DialogSize, string> = {
  sm: "max-w-sm",
  md: "max-w-md",
  lg: "max-w-lg",
};

interface DialogProps {
  open: boolean;
  onClose: () => void;
  title?: ReactNode;
  description?: ReactNode;

  footer?: ReactNode;
  size?: DialogSize;

  closeOnBackdrop?: boolean;
  children?: ReactNode;
}

export function Dialog({
  open,
  onClose,
  title,
  description,
  footer,
  size = "md",
  closeOnBackdrop = true,
  children,
}: DialogProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const mounted = useMounted();
  useFocusTrap(panelRef, open, onClose);

  if (!open || !mounted) return null;

  const labelledBy = title ? "dialog-title" : undefined;
  const describedBy = description ? "dialog-desc" : undefined;

  return createPortal(
    <div
      className="fixed inset-0 z-[1000] flex items-center justify-center bg-overlay p-4"
      onClick={closeOnBackdrop ? onClose : undefined}
    >
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={labelledBy}
        aria-describedby={describedBy}
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
        className={cn(
          "flex w-full flex-col overflow-hidden rounded-xl bg-surface shadow-overlay outline-none",
          SIZE[size]
        )}
      >
        {(title || description) && (
          <div className="flex items-start justify-between gap-4 px-5 pb-3 pt-5">
            <div className="min-w-0">
              {title && (
                <h2 id="dialog-title" className="text-base font-semibold text-text">
                  {title}
                </h2>
              )}
              {description && (
                <p id="dialog-desc" className="mt-1 text-sm leading-relaxed text-text-secondary">
                  {description}
                </p>
              )}
            </div>
            <button
              type="button"
              onClick={onClose}
              aria-label="Close dialog"
              className="-mr-1 -mt-1 shrink-0 rounded-md p-1 text-text-tertiary hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
            >
              <X size={18} aria-hidden />
            </button>
          </div>
        )}

        {children && <div className="px-5 py-2 text-sm text-text">{children}</div>}

        {footer && (
          <div className="mt-2 flex items-center justify-end gap-2 border-t border-border bg-bg px-5 py-3">
            {footer}
          </div>
        )}
      </div>
    </div>,
    document.body
  );
}

Dependencies

To use Dialog you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ button.tsx
  ├─ dialog.tsx
  ├─ field.tsx
  └─ input.tsx
src/hooks/
  ├─ use-focus-trap.ts
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts