Dialog

A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.

Installation

Install the following dependencies:

npm install @react-aria/dialog @react-aria/interactions @react-aria/overlays @react-stately/overlays

Copy and paste the following code into your project.

"use client";
 
import { useExitAnimation } from "~/hooks/use-animation";
import { mergeProps } from "~/utils/merge-props";
import { mergeRefs } from "~/utils/merge-refs";
import { type DialogAria, useDialog } from "@react-aria/dialog";
import { type PressProps, PressResponder } from "@react-aria/interactions";
import {
  type AriaModalOverlayProps,
  type ModalOverlayAria,
  Overlay,
  type OverlayProps,
  type OverlayTriggerAria,
  useModalOverlay,
  useOverlayTrigger,
} from "@react-aria/overlays";
import {
  type OverlayTriggerProps,
  type OverlayTriggerState,
  useOverlayTriggerState,
} from "@react-stately/overlays";
import { cloneElement, createContext, useContext, useRef } from "react";
import { type VariantProps, tv } from "tailwind-variants";
 
export const DialogStyles = {
  Overlay: tv({
    base: [
      "fixed inset-0 z-50 bg-black/80",
      "data-[state=open]:fade-in-0 data-[state=open]:animate-in",
      "data-[state=closed]:fade-out-0 data-[state=closed]:animate-out",
    ],
    variants: {
      sheet: {
        true: [
          "data-[state=open]:duration-500",
          "data-[state=closed]:duration-300",
        ],
      },
    },
  }),
  Content: tv({
    base: [
      "fixed z-50 w-full bg-background p-6 shadow-lg outline-none",
      "data-[state=open]:fade-in-0 data-[state=open]:animate-in",
      "data-[state=closed]:fade-out-0 data-[state=closed]:animate-out",
      "sm:rounded-lg",
    ],
    variants: {
      side: {
        top: [
          "inset-x-0 top-0 left-0 border-b data-[state=open]:duration-500",
          "data-[state=open]:slide-in-from-top",
          "data-[state=closed]:slide-out-to-top data-[state=closed]:duration-300",
        ],
        bottom: [
          "inset-x-0 bottom-0 border-t ease-in-out",
          "data-[state=open]:slide-in-from-bottom",
          "data-[state=closed]:slide-out-to-bottom",
        ],
        left: [
          "inset-y-0 left-0 h-full w-3/4 border-r ease-in-out sm:max-w-sm",
          "data-[state=open]:slide-in-from-left",
          "data-[state=closed]:slide-out-to-left",
        ],
        right: [
          "inset-y-0 right-0 h-full w-3/4 border-l ease-in-out sm:max-w-sm",
          "data-[state=open]:slide-in-from-right",
          "data-[state=closed]:slide-out-to-right",
        ],
        center: [
          "-translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 grid max-w-lg gap-4 border",
          "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=open]:zoom-in-95",
          "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=closed]:zoom-out-95",
        ],
      },
      sheet: {
        true: [
          "data-[state=open]:duration-500",
          "data-[state=closed]:duration-300",
        ],
      },
    },
    defaultVariants: {
      side: "center",
    },
  }),
  Close: tv({
    base: [
      "absolute top-4 right-4 size-4 rounded-sm opacity-70 ring-offset-background transition",
      "hover:opacity-100",
      "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
      "disabled:pointer-events-none",
    ],
  }),
  Header: tv({
    base: ["flex flex-col space-y-1.5 text-center", "sm:text-left"],
  }),
  Footer: tv({
    base: ["flex flex-col-reverse", "sm:flex-row sm:justify-end sm:space-x-2"],
  }),
  Title: tv({
    base: ["font-semibold text-lg leading-none tracking-tight"],
  }),
  Description: tv({
    base: ["text-muted-foreground text-sm"],
  }),
};
 
export interface DialogRootContext {
  triggerProps: OverlayTriggerAria["triggerProps"];
  overlayTriggerState: OverlayTriggerState;
  overlayProps: OverlayTriggerAria["overlayProps"];
  modalProps: ModalOverlayAria["modalProps"];
  underlayProps: ModalOverlayAria["underlayProps"];
  modalRef: React.RefObject<HTMLDivElement>;
  sheet: boolean;
}
 
export const DialogRootContext = createContext<DialogRootContext | null>(null);
 
export function useDialogRootContext() {
  const context = useContext(DialogRootContext);
  if (context === null) {
    throw new Error(
      "useDialogRootContext must be used within a DialogProvider"
    );
  }
  return context;
}
 
export interface DialogContext {
  dialogProps: DialogAria["dialogProps"];
  titleProps: DialogAria["titleProps"];
}
 
export const DialogContext = createContext<DialogContext | null>(null);
 
export function useDialogContext() {
  const context = useContext(DialogContext);
  if (context === null) {
    throw new Error("useDialogContext must be used within a DialogProvider");
  }
  return context;
}
 
export interface DialogRootProps
  extends OverlayTriggerProps,
    AriaModalOverlayProps {
  /**
   * Whether to close the modal when the user interacts outside it.
   * @default true
   */
  isDismissable?: boolean;
  children: React.ReactNode;
  sheet?: boolean;
}
 
export function DialogRoot({
  children,
  isDismissable = true,
  sheet = false,
  ...props
}: DialogRootProps) {
  const overlayTriggerState = useOverlayTriggerState(props);
  const { triggerProps, overlayProps } = useOverlayTrigger(
    { type: "dialog" },
    overlayTriggerState
  );
 
  const modalRef = useRef<HTMLDivElement>(null);
 
  const { modalProps, underlayProps } = useModalOverlay(
    {
      ...props,
      isDismissable,
    },
    overlayTriggerState,
    modalRef
  );
 
  return (
    <DialogRootContext.Provider
      value={{
        triggerProps,
        overlayTriggerState,
        overlayProps,
        modalProps,
        modalRef,
        underlayProps,
        sheet,
      }}
    >
      {children}
    </DialogRootContext.Provider>
  );
}
 
export type DialogTriggerProps = React.ComponentProps<typeof PressResponder>;
 
export function DialogTrigger(props: DialogTriggerProps) {
  const { overlayTriggerState, triggerProps } = useDialogRootContext();
  const triggerRef = useRef<HTMLButtonElement>(null);
 
  return (
    <PressResponder
      {...mergeProps(triggerProps, props)}
      ref={mergeRefs([triggerRef, props.ref])}
      isPressed={overlayTriggerState.isOpen}
    />
  );
}
 
export interface DialogPortalProps extends OverlayProps {}
 
export function DialogPortal(props: DialogPortalProps) {
  const { overlayTriggerState, modalRef } = useDialogRootContext();
  const isModalExiting = useExitAnimation(modalRef, overlayTriggerState.isOpen);
 
  if (!(overlayTriggerState.isOpen || isModalExiting)) {
    return null;
  }
 
  return <Overlay {...props} />;
}
 
export interface DialogOverlayProps extends React.ComponentProps<"div"> {}
 
export function DialogOverlay({ className, ...props }: DialogOverlayProps) {
  const { underlayProps, overlayTriggerState, sheet } = useDialogRootContext();
 
  return (
    <div
      {...mergeProps(underlayProps, props)}
      className={DialogStyles.Overlay({ className, sheet })}
      data-state={overlayTriggerState.isOpen ? "open" : "closed"}
    />
  );
}
 
interface DialogRenderProps {
  close: () => void;
}
 
export type DialogContentProps = VariantProps<
  (typeof DialogStyles)["Content"]
> &
  Omit<React.ComponentProps<"div">, "children"> & {
    /**
     * The role of the dialog. This can be a dialog or an alert dialog.
     * @default "dialog"
     * */
    role?: "dialog" | "alertdialog";
    children: React.ReactNode | ((opts: DialogRenderProps) => React.ReactNode);
  };
 
export function DialogContent({
  className,
  side,
  ...props
}: DialogContentProps) {
  const { overlayProps, modalProps, modalRef, overlayTriggerState, sheet } =
    useDialogRootContext();
 
  const { dialogProps, titleProps } = useDialog(props, modalRef);
 
  let children = props.children;
  if (typeof children === "function") {
    children = children({
      close: overlayTriggerState.close || (() => null),
    });
  }
 
  return (
    <div
      {...mergeProps(overlayProps, modalProps, dialogProps, props)}
      ref={modalRef}
      className={DialogStyles.Content({ className, sheet, side })}
      data-state={overlayTriggerState.isOpen ? "open" : "closed"}
    >
      <DialogContext.Provider
        value={{
          dialogProps,
          titleProps,
        }}
      >
        {children}
      </DialogContext.Provider>
    </div>
  );
}
 
export interface DialogHeaderProps extends React.ComponentProps<"header"> {}
 
export function DialogHeader({ className, ...props }: DialogHeaderProps) {
  return <header {...props} className={DialogStyles.Header({ className })} />;
}
 
export interface DialogFooterProps extends React.ComponentProps<"footer"> {}
 
export function DialogFooter({ className, ...props }: DialogFooterProps) {
  return <footer {...props} className={DialogStyles.Footer({ className })} />;
}
 
export interface DialogTitleProps extends React.ComponentProps<"h2"> {}
 
export function DialogTitle({ className, ...props }: DialogTitleProps) {
  const { titleProps } = useDialogContext();
 
  return (
    <h2
      {...mergeProps(titleProps, props)}
      className={DialogStyles.Title({ className })}
    />
  );
}
 
export interface DialogDescriptionProps extends React.ComponentProps<"h3"> {}
 
export function DialogDescription({
  className,
  ...props
}: DialogDescriptionProps) {
  return <h3 {...props} className={DialogStyles.Description({ className })} />;
}
 
export interface DialogCloseProps extends PressProps {
  children: React.ReactElement;
  asChild?: boolean;
}
 
export function DialogClose({ children, asChild, ...props }: DialogCloseProps) {
  const { overlayTriggerState } = useDialogRootContext();
 
  if (asChild) {
    return cloneElement(children, {
      ...props,
      onPress: overlayTriggerState.close,
    });
  }
 
  return (
    <button
      {...props}
      className={DialogStyles.Close()}
      onClick={overlayTriggerState.close}
    >
      {children}
    </button>
  );
}
 
export const Dialog = Object.assign(
  {},
  {
    Root: DialogRoot,
    Trigger: DialogTrigger,
    Portal: DialogPortal,
    Overlay: DialogOverlay,
    Content: DialogContent,
    Header: DialogHeader,
    Footer: DialogFooter,
    Title: DialogTitle,
    Description: DialogDescription,
    Close: DialogClose,
  }
);

Update the import paths to match your project setup.

Usage

Single import

import { Dialog } from "~/components/ui/dialog";
<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Header>
        <Dialog.Title>Are you absolutely sure?</Dialog.Title>
        <Dialog.Description>
          This action cannot be undone. This will permanently delete your
          account and remove your data from our servers.
        </Dialog.Description>
      </Dialog.Header>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Multiple imports

import {
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogRoot,
  DialogTitle,
  DialogTrigger,
} from "~/components/ui/dialog";
<DialogRoot>
  <DialogTrigger>Open</DialogTrigger>
  <DialogPortal>
    <DialogOverlay />
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Are you absolutely sure?</DialogTitle>
        <DialogDescription>
          This action cannot be undone. This will permanently delete your
          account and remove your data from our servers.
        </DialogDescription>
      </DialogHeader>
    </DialogContent>
  </DialogPortal>
</DialogRoot>

Examples

Default

Alert Dialog

Use the role="alertdialog" prop on the <Dialog.Content> element to make an alert dialog. If the isDismissable prop is not explicitly set, the dialog will be not dismissable.

Sheet

Use the sheet prop on the <Dialog.Root> and the side prop on the <Dialog.Content> element to make a sheet dialog. The side prop can be set to top, right, bottom, or left.

Top

Left

Bottom