Modal

Modal provides an unstyled basis to display content in a layer above the main interface.

Dependencies

Source Code

"use client";
 
import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { Slot } from "@/components/slot";
import { useElementTransition } from "@/foundations/hooks/use-element-transition";
import { composeRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
// Hook to manage native <dialog> element behavior
const useDialogElement = (
  open: boolean,
  setOpen: (isOpen: boolean) => void
) => {
  const ref = useRef<HTMLDialogElement>(null);
 
  useLayoutEffect(() => {
    const element = ref.current;
    if (!element) return;
 
    if (!element.open) {
      // use native showModal() to ensure it receives focus when opened, and ESC closes it
      element.showModal();
    }
 
    const abortController = new AbortController();
    const { signal } = abortController;
 
    // Prevent the default cancel event and use internal state to close the drawer instead
    // This ensures drawer closing is synchronized with internal state, preventing layout shifts
    element.addEventListener(
      "cancel",
      (event: Event) => {
        event.preventDefault();
        setOpen(false);
      },
      { signal }
    );
 
    // Prevent ESC from closing the dialog when the cancel event is prevented.
    // Unsure if this is a browser bug or intended behavior — the ESC key can push through the cancel event for some reason
    element.addEventListener(
      "keydown",
      (event: KeyboardEvent) => {
        if (event.key === "Escape" && open) {
          event.preventDefault();
          setOpen(false);
        }
      },
      { signal }
    );
 
    // Prevent ESC from closing the dialog when it is inert.
    // If the dialog is opened while inert, the focus goes to the window, which allows ESC to close the dialog unexpectedly.
    window.addEventListener(
      "keydown",
      (event: KeyboardEvent) => {
        if (event.key === "Escape" && element.inert && open) {
          event.preventDefault();
          setOpen(false);
        }
      },
      { signal }
    );
 
    return () => {
      abortController.abort();
    };
  }, [open, ref, setOpen]);
 
  useEffect(() => {
    const element = ref.current;
    if (!element || !open) return;
 
    const handleDialogClick = (event: MouseEvent) => {
      // if the click is on the backdrop, close the drawer
      if ((event.target as HTMLElement).nodeName === "DIALOG") {
        const dialog = event.target as HTMLDialogElement;
        const { top, left, width, height } = dialog.getBoundingClientRect();
        const isOutsideModal =
          top > event.clientY ||
          event.clientY > top + height ||
          left > event.clientX ||
          event.clientX > left + width;
 
        if (isOutsideModal) {
          event.stopPropagation();
          setOpen(false);
        }
      }
    };
 
    element.addEventListener("click", handleDialogClick);
 
    return () => {
      element.removeEventListener("click", handleDialogClick);
    };
  }, [setOpen, open]);
 
  return ref;
};
 
const ModalContext = createContext<{
  open: boolean;
  labelId?: string;
  descriptionId?: string;
  setOpen: (open: boolean) => void;
  setLabelId: (id?: string) => void;
  setDescriptionId: (id?: string) => void;
} | null>(null);
 
const useModalContext = () => {
  const context = use(ModalContext);
 
  if (!context) {
    throw new Error("Modal component must be used within a Modal");
  }
 
  return context;
};
 
interface ModalProps {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode;
}
 
const Modal = ({ open: propsOpen, onOpenChange, children }: ModalProps) => {
  const [internalOpen, setInternalOpen] = useState(false);
  const [labelId, setLabelId] = useState<string | undefined>(undefined);
  const [descriptionId, setDescriptionId] = useState<string | undefined>(
    undefined
  );
 
  const open = propsOpen ?? internalOpen;
 
  const setOpen = useCallback(
    (isOpen: boolean) => {
      setInternalOpen(isOpen);
      onOpenChange?.(isOpen);
    },
    [onOpenChange]
  );
 
  const ctx = useMemo(
    () => ({
      open,
      setOpen,
      labelId,
      setLabelId,
      descriptionId,
      setDescriptionId,
    }),
    [descriptionId, labelId, open, setOpen]
  );
 
  return <ModalContext value={ctx}>{children}</ModalContext>;
};
 
interface ModalContentProps extends React.ComponentPropsWithRef<"dialog"> {
  catchFocus?: boolean;
}
 
const ModalContent = ({
  className,
  children,
  catchFocus = true,
  ...props
}: ModalContentProps) => {
  const { open, labelId, descriptionId, setOpen } = useModalContext();
  const dialogRef = useDialogElement(open, setOpen);
 
  const {
    ref: transitionRef,
    isMounted,
    status,
  } = useElementTransition<HTMLDialogElement>(open);
 
  if (!isMounted) return;
 
  return (
    <dialog
      ref={composeRefs(dialogRef, transitionRef)}
      data-status={status}
      aria-labelledby={labelId}
      aria-describedby={descriptionId}
      className={cn("m-auto", className)}
      {...props}
    >
      {catchFocus && (
        // By default, the HTML <dialog> element focuses the first focusable child when opened.
        // If that element is scrolled out of view, the dialog may jump to it, causing a jarring and confusing scroll.
        // Additionally, browsers like Safari may show focus-visible styles on that element, which can look odd.
        // The following element catches initial focus to prevent these issues.
        <div
          className="sr-only"
          autoFocus
          tabIndex={-1}
          data-modal-focus-catcher=""
        />
      )}
      {children}
    </dialog>
  );
};
 
interface ModalTriggerProps extends React.ComponentPropsWithRef<"button"> {
  asChild?: boolean;
}
 
const ModalTrigger = ({ asChild, children, ...props }: ModalTriggerProps) => {
  const { setOpen } = useModalContext();
 
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    props.onClick?.(event);
 
    if (!event.defaultPrevented) {
      setOpen(true);
    }
  };
 
  const Component = asChild ? Slot : "button";
 
  return (
    <Component {...props} onClick={handleClick}>
      {children}
    </Component>
  );
};
 
interface ModalCloseProps extends React.ComponentPropsWithRef<"button"> {
  asChild?: boolean;
}
 
const ModalClose = ({
  asChild = false,
  children,
  ...props
}: ModalCloseProps) => {
  const { setOpen } = useModalContext();
 
  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    props.onClick?.(event as React.MouseEvent<HTMLButtonElement>);
 
    if (!event.defaultPrevented) {
      setOpen(false);
    }
  };
 
  const Component = asChild ? Slot : "button";
  return (
    <Component {...props} onClick={handleClick}>
      {children}
    </Component>
  );
};
 
interface ModalTitleProps extends React.ComponentPropsWithRef<"h2"> {
  asChild?: boolean;
}
 
const ModalTitle = ({ children, asChild, ...props }: ModalTitleProps) => {
  const generatedId = useId();
  const id = props.id ?? generatedId;
 
  const { setLabelId } = useModalContext();
 
  useLayoutEffect(() => {
    setLabelId(id);
 
    return () => setLabelId(undefined);
  }, [id, setLabelId]);
 
  const Component = asChild ? Slot : "h2";
  return (
    <Component id={id} {...props}>
      {children}
    </Component>
  );
};
 
interface ModalDescriptionProps extends React.ComponentPropsWithRef<"p"> {
  asChild?: boolean;
}
 
const ModalDescription = ({
  children,
  asChild,
  ...props
}: ModalDescriptionProps) => {
  const generatedId = useId();
  const id = props.id ?? generatedId;
 
  const { setDescriptionId } = useModalContext();
 
  useLayoutEffect(() => {
    setDescriptionId(id);
 
    return () => setDescriptionId(undefined);
  }, [id, setDescriptionId]);
 
  const Component = asChild ? Slot : "p";
  return (
    <Component id={id} {...props}>
      {children}
    </Component>
  );
};
 
export {
  Modal,
  ModalContent,
  ModalTrigger,
  ModalClose,
  ModalTitle,
  ModalDescription,
};

CSS

To prevent background scrolling when the modal is open, add the following CSS to your global styles:

body:has(dialog[open]) {
  overflow: hidden;
  scrollbar-gutter: stable;
}

Features

  • Native Dialog: Built on top of the HTML <dialog> element for optimal accessibility and base functionality
  • Backdrop Interaction: Click outside to close, ESC key support
  • Focus Management: Automatic focus trapping and restoration
  • Smooth Transitions: Integrated with useElementTransition for animations
  • Controlled & Uncontrolled: Supports both controlled and uncontrolled modes

Anatomy

<Modal>
  <ModalTrigger />
  <ModalContent>
    <ModalTitle />
    <ModalDescription />
    <ModalClose />
  </ModalContent>
</Modal>

API Reference

PropDefaultTypeDescription

open

-

boolean

Controls the open state of the modal (controlled mode)

onOpenChange

-

(open: boolean) => void

Callback fired when the open state changes

children

*
-

ReactNode

The modal components

ModalContent

Extends the native <dialog> element.

PropDefaultTypeDescription

catchFocus

true

boolean

Whether to catch initial focus to prevent scroll jumps

ModalTrigger

Extends the button element.

PropDefaultTypeDescription

asChild

false

boolean

Render as a child component instead of an h2

ModalClose

Extends the button element.

PropDefaultTypeDescription

asChild

false

boolean

Render as a child component instead of an h2

ModalTitle

Extends the h2 element.

PropDefaultTypeDescription

asChild

false

boolean

Render as a child component instead of an h2

ModalDescription

Extends the p element.

PropDefaultTypeDescription

asChild

false

boolean

Render as a child component instead of a p

Examples

Basic Modal

Controlled Modal

Best Practices

  1. Accessibility:

    • Always provide ModalTitle for screen readers
    • Use ModalDescription for additional context
    • Ensure keyboard navigation works properly
  2. User Experience:

    • Keep modal content focused and concise
    • Provide clear actions (confirm/cancel)