React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

modaldialogoverlaycomponenttailwind

Modal

An overlay dialog that appears on top of the page content to capture user attention for confirmations, forms, or important information.

Use Cases

  • Confirm destructive actions (delete account, remove item)
  • Display forms without navigating away (edit profile, new item)
  • Show detailed previews (image lightbox, document viewer)
  • Alert users to critical information (session expiry, errors)
  • Collect quick input (rename, add a tag, leave a comment)
  • Display terms of service or consent dialogs

Simplest Implementation

"use client";
 
import { useEffect, useRef } from "react";
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}
 
export function Modal({ open, onClose, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    if (open) {
      dialog.showModal();
    } else {
      dialog.close();
    }
  }, [open]);
 
  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      className="rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
    >
      {children}
    </dialog>
  );
}

Uses the native <dialog> element which provides built-in backdrop, focus trapping, and Escape key handling. The backdrop: Tailwind modifier styles the overlay behind the dialog.

Variations

With Title and Close Button

"use client";
 
import { useEffect, useRef } from "react";
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}
 
export function Modal({ open, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    open ? dialog.showModal() : dialog.close();
  }, [open]);
 
  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      className="w-full max-w-md rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
    >
      <div className="flex items-center justify-between border-b px-6 py-4">
        <h2 className="text-lg font-semibold">{title}</h2>
        <button
          onClick={onClose}
          aria-label="Close"
          className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
        >
          <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
          </svg>
        </button>
      </div>
      <div className="px-6 py-4">{children}</div>
    </dialog>
  );
}

Separates header and body with a border. The close button uses aria-label for accessibility since it only contains an icon.

Confirmation Modal

"use client";
 
import { useEffect, useRef } from "react";
 
interface ConfirmModalProps {
  open: boolean;
  onClose: () => void;
  onConfirm: () => void;
  title: string;
  message: string;
  confirmLabel?: string;
  loading?: boolean;
}
 
export function ConfirmModal({
  open, onClose, onConfirm, title, message, confirmLabel = "Confirm", loading,
}: ConfirmModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    open ? dialog.showModal() : dialog.close();
  }, [open]);
 
  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
    >
      <h2 className="text-lg font-semibold">{title}</h2>
      <p className="mt-2 text-sm text-gray-600">{message}</p>
      <div className="mt-6 flex justify-end gap-3">
        <button
          onClick={onClose}
          className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
        >
          Cancel
        </button>
        <button
          onClick={onConfirm}
          disabled={loading}
          className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
        >
          {loading ? "Deleting..." : confirmLabel}
        </button>
      </div>
    </dialog>
  );
}

A purpose-built confirmation dialog. The destructive action button is red, and the cancel button is visually lighter to guide the user toward the safe choice.

"use client";
 
import { useEffect, useRef } from "react";
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  footer: React.ReactNode;
}
 
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    open ? dialog.showModal() : dialog.close();
  }, [open]);
 
  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      className="w-full max-w-lg rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
    >
      <div className="border-b px-6 py-4">
        <h2 className="text-lg font-semibold">{title}</h2>
      </div>
      <div className="px-6 py-4">{children}</div>
      <div className="flex justify-end gap-3 border-t px-6 py-4">{footer}</div>
    </dialog>
  );
}

A slot-based layout with footer as a prop. This lets the parent provide any combination of buttons without the modal needing to know about specific actions.

Animated Modal

"use client";
 
import { useEffect, useRef, useState } from "react";
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}
 
export function Modal({ open, onClose, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const [visible, setVisible] = useState(false);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
 
    if (open) {
      dialog.showModal();
      requestAnimationFrame(() => setVisible(true));
    } else {
      setVisible(false);
      const timer = setTimeout(() => dialog.close(), 200);
      return () => clearTimeout(timer);
    }
  }, [open]);
 
  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      className={`w-full max-w-md rounded-xl bg-white p-6 shadow-xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
        visible ? "scale-100 opacity-100 backdrop:opacity-100" : "scale-95 opacity-0 backdrop:opacity-0"
      }`}
    >
      {children}
    </dialog>
  );
}

A two-phase approach: showModal() makes the dialog visible in the DOM, then requestAnimationFrame triggers the CSS transition. On close, the animation plays before dialog.close() removes it.

Prevent Close on Backdrop Click

"use client";
 
import { useEffect, useRef } from "react";
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  closeOnBackdrop?: boolean;
  children: React.ReactNode;
}
 
export function Modal({ open, onClose, closeOnBackdrop = true, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    open ? dialog.showModal() : dialog.close();
  }, [open]);
 
  function handleClick(e: React.MouseEvent<HTMLDialogElement>) {
    if (!closeOnBackdrop) return;
    const rect = dialogRef.current?.getBoundingClientRect();
    if (!rect) return;
    const clickedOutside =
      e.clientX < rect.left || e.clientX > rect.right ||
      e.clientY < rect.top || e.clientY > rect.bottom;
    if (clickedOutside) onClose();
  }
 
  return (
    <dialog
      ref={dialogRef}
      onClose={onClose}
      onClick={handleClick}
      className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
    >
      {children}
    </dialog>
  );
}

The native <dialog> doesn't close on backdrop click by default with showModal(). This checks if the click coordinates are outside the dialog's bounding rect to simulate backdrop click-to-close behavior.

Complex Implementation

"use client";
 
import { useEffect, useRef, useState, useCallback, createContext, useContext } from "react";
import { createPortal } from "react-dom";
 
// --- Context for nested access ---
 
interface ModalContextValue {
  close: () => void;
}
 
const ModalContext = createContext<ModalContextValue | null>(null);
 
export function useModal() {
  const ctx = useContext(ModalContext);
  if (!ctx) throw new Error("useModal must be used inside a Modal");
  return ctx;
}
 
// --- Modal Component ---
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  closeOnBackdrop?: boolean;
  closeOnEscape?: boolean;
  initialFocusRef?: React.RefObject<HTMLElement | null>;
  children: React.ReactNode;
}
 
export function Modal({
  open,
  onClose,
  closeOnBackdrop = true,
  closeOnEscape = true,
  initialFocusRef,
  children,
}: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const [visible, setVisible] = useState(false);
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => setMounted(true), []);
 
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
 
    if (open) {
      dialog.showModal();
      requestAnimationFrame(() => {
        setVisible(true);
        if (initialFocusRef?.current) {
          initialFocusRef.current.focus();
        }
      });
    } else {
      setVisible(false);
      const timer = setTimeout(() => dialog.close(), 200);
      return () => clearTimeout(timer);
    }
  }, [open, initialFocusRef]);
 
  const handleCancel = useCallback(
    (e: React.SyntheticEvent) => {
      if (!closeOnEscape) {
        e.preventDefault();
        return;
      }
      onClose();
    },
    [closeOnEscape, onClose]
  );
 
  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (!closeOnBackdrop) return;
      const rect = dialogRef.current?.getBoundingClientRect();
      if (!rect) return;
      const outside =
        e.clientX < rect.left || e.clientX > rect.right ||
        e.clientY < rect.top || e.clientY > rect.bottom;
      if (outside) onClose();
    },
    [closeOnBackdrop, onClose]
  );
 
  // Lock body scroll when open
  useEffect(() => {
    if (open) {
      const scrollY = window.scrollY;
      document.body.style.position = "fixed";
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = "100%";
      return () => {
        document.body.style.position = "";
        document.body.style.top = "";
        document.body.style.width = "";
        window.scrollTo(0, scrollY);
      };
    }
  }, [open]);
 
  if (!mounted) return null;
 
  return createPortal(
    <ModalContext.Provider value={{ close: onClose }}>
      <dialog
        ref={dialogRef}
        onCancel={handleCancel}
        onClose={onClose}
        onClick={handleClick}
        className={`w-full max-w-lg rounded-xl bg-white p-0 shadow-2xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
          visible
            ? "translate-y-0 scale-100 opacity-100 backdrop:opacity-100"
            : "translate-y-2 scale-95 opacity-0 backdrop:opacity-0"
        }`}
        aria-modal="true"
      >
        {children}
      </dialog>
    </ModalContext.Provider>,
    document.body
  );
}
 
// --- Compound parts ---
 
export function ModalHeader({ children }: { children: React.ReactNode }) {
  const { close } = useModal();
  return (
    <div className="flex items-center justify-between border-b px-6 py-4">
      <h2 className="text-lg font-semibold">{children}</h2>
      <button
        onClick={close}
        aria-label="Close"
        className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
      >
        <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
        </svg>
      </button>
    </div>
  );
}
 
export function ModalBody({ children }: { children: React.ReactNode }) {
  return <div className="px-6 py-4">{children}</div>;
}
 
export function ModalFooter({ children }: { children: React.ReactNode }) {
  return <div className="flex justify-end gap-3 border-t px-6 py-4">{children}</div>;
}

Key aspects:

  • Native <dialog> with showModal() — provides built-in focus trapping, Escape key handling, and aria-modal semantics. No need to implement a custom focus trap.
  • Compound component patternModalHeader, ModalBody, ModalFooter access the close function through context, keeping the API composable.
  • Body scroll lock — fixes the body position while preserving scroll position. On close, it restores the exact scroll offset so the user doesn't lose their place.
  • Animation with requestAnimationFrame — the dialog is shown first, then animated in on the next frame. On close, the animation plays for 200ms before dialog.close() is called.
  • onCancel intercept — the native dialog fires a cancel event on Escape. When closeOnEscape is false, preventDefault() blocks it.
  • Portal via createPortal — renders the dialog at document.body to avoid z-index stacking context issues from parent components.
  • initialFocusRef — allows the caller to specify which element should receive focus when the modal opens (e.g., a specific input field).

Gotchas

  • Forgetting showModal() vs show()show() opens the dialog without a backdrop and without focus trapping. Always use showModal() for modal dialogs.

  • Backdrop click doesn't close by default — Unlike many libraries, the native <dialog> with showModal() does not close when clicking the backdrop. You must implement this yourself by checking click coordinates.

  • Scroll bleed-through — The page behind the modal can still scroll on mobile Safari. Use the body scroll lock pattern (fixed position + saved scroll offset) to prevent this.

  • Nested modals — Opening a second <dialog> while one is already open can cause focus trap conflicts. Avoid stacking modals; use a sheet or inline expansion instead.

  • Missing onClose handler — If you don't set onClose, pressing Escape closes the dialog visually but your React state stays open: true, causing a desync. Always sync the onClose callback.

  • Animation on first mount — If the dialog is open on mount, the animation plays from the initial state. Use requestAnimationFrame or a mounted flag to skip the entrance animation when appropriate.

  • Button — Action triggers used inside modals
  • Forms — Form patterns used within modal dialogs
  • useRef — Managing dialog refs