dotUI
dotUI
beta
  1. Components
  2. Overlay
  3. Dialog

Dialog

A dialog is an overlay shown above other content in an application.

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog, DialogBody, DialogFooter } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit Profile</Button>
      <Dialog title="Edit profile" description="Make changes to your profile.">
        {({ close }) => (
          <>
            <DialogBody>
              <TextField autoFocus label="Name" defaultValue="Mehdi" />
              <TextField label="Username" defaultValue="@mehdibha_" />
            </DialogBody>
            <DialogFooter>
              <Button variant="outline" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Cancel
              </Button>
              <Button variant="primary" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Save changes
              </Button>
            </DialogFooter>
          </>
        )}
      </Dialog>
    </DialogRoot>
  );
}

Installation

Copy and paste the following code into your project.

"use client";

import * as React from "react";
import { useSlotId } from "@react-aria/utils";
import {
  composeRenderProps,
  DialogContext as AriaDialogContext,
  DialogTrigger as AriaDialogTrigger,
  Dialog as AriaDialog,
  Heading as AriaHeading,
  Text as AriaText,
  type DialogProps as AriaDialogProps,
  type DialogTriggerProps as AriaDialogTriggerProps,
  type HeadingProps as AriaHeadingProps,
  type TextProps as AriaTextProps,
  TextContext,
} from "react-aria-components";
import { Provider } from "react-aria-components";
import { tv } from "tailwind-variants";
import { Overlay, type OverlayProps } from "./overlay";

const dialogStyles = tv({
  slots: {
    overlay: "",
    backdrop: "",
    content: "relative outline-none rounded-[inherit] p-4 flex flex-col max-w-full",
    header: "mb-4",
    title: "text-xl font-semibold",
    description: "text-sm text-fg-muted",
    body: "space-y-4",
    footer: "flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4",
    inset: "-mx-3 sm:-mx-6",
  },
});

type DialogRootProps = AriaDialogTriggerProps;
const DialogRoot = (props: DialogRootProps) => {
  const descriptionId = useSlotId();
  return (
    <Provider
      values={[
        [AriaDialogContext, { "aria-describedby": descriptionId }],
        [TextContext, { slots: { description: { id: descriptionId } } }],
      ]}
    >
      <AriaDialogTrigger {...props} />
    </Provider>
  );
};

interface DialogProps extends DialogContentProps {
  title?: string;
  description?: string;
  type?: OverlayProps["type"];
  mobileType?: OverlayProps["mobileType"];
  mediaQuery?: OverlayProps["mediaQuery"];
  isDismissable?: boolean;
}
const Dialog = ({
  title,
  description,
  type,
  mobileType,
  mediaQuery,
  isDismissable: isDismissableProp,
  ...props
}: DialogProps) => {
  const isDismissable = isDismissableProp ?? (props.role === "alertdialog" ? false : true);
  return (
    <Overlay
      isDismissable={isDismissable}
      type={type}
      mobileType={mobileType}
      mediaQuery={mediaQuery}
    >
      <DialogContent {...props}>
        {composeRenderProps(props.children, (children) => (
          <>
            {(title || description) && (
              <DialogHeader>
                {title && <DialogTitle>{title}</DialogTitle>}
                {description && <DialogDescription>{description}</DialogDescription>}
              </DialogHeader>
            )}
            {children}
          </>
        ))}
      </DialogContent>
    </Overlay>
  );
};

type DialogContentProps = AriaDialogProps;
const DialogContent = ({ children, className, ...props }: DialogContentProps) => {
  const { content } = dialogStyles();
  return (
    <AriaDialog className={content({ className })} {...props}>
      {children}
    </AriaDialog>
  );
};

type DialogHeaderProps = React.ComponentProps<"header">;
const DialogHeader = ({ children, className, ...props }: DialogHeaderProps) => {
  const { header } = dialogStyles();
  return (
    <header className={header({ className })} {...props}>
      {children}
    </header>
  );
};

type DialogTitleProps = AriaHeadingProps;
const DialogTitle = ({ className, ...props }: DialogTitleProps) => {
  const { title } = dialogStyles();
  return <AriaHeading slot="title" className={title({ className })} {...props} />;
};

type DialogDescriptionProps = AriaTextProps;
const DialogDescription = ({ className, ...props }: DialogDescriptionProps) => {
  const { description } = dialogStyles();
  return <AriaText slot="description" className={description({ className })} {...props} />;
};

type DialogBody = React.ComponentProps<"div">;
const DialogBody = ({ className, ...props }: DialogDescriptionProps) => {
  const { body } = dialogStyles();
  return <div className={body({ className })} {...props} />;
};

type DialogFooterProps = React.ComponentProps<"footer">;
const DialogFooter = ({ className, ...props }: DialogFooterProps) => {
  const { footer } = dialogStyles();
  return <footer slot="description" className={footer({ className })} {...props} />;
};

type DialogInsetProps = React.ComponentProps<"div">;
const DialogInset = ({ className, ...props }: DialogInsetProps) => {
  const { inset } = dialogStyles();
  return <div className={inset({ className })} {...props} />;
};

export type {
  DialogRootProps,
  DialogProps,
  DialogContentProps,
  DialogHeaderProps,
  DialogTitleProps,
  DialogDescriptionProps,
  DialogFooterProps,
  DialogInsetProps,
};
export {
  DialogRoot,
  DialogContent,
  Dialog,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogInset,
};

Update the import paths to match your project setup.

Usage

Use dialog to display contextual information, tasks, or workflows that appear over the user interface. Depending on the kind of dialog, further interactions may be blocked until the dialog is acknowledged.

Options

Title

Dialog is labeled with a title to provide context to the user. Use the title prop to provide a title for the dialog.
If a dialog does not have a visible heading element, an aria-label or aria-labelledby prop must be passed instead to identify the element to assistive technology.

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit username</Button>
      <Dialog title="Edit username">
        <TextField label="Username" defaultValue="@mehdibha_" />
      </Dialog>
    </DialogRoot>
  );
}

Description

A description can be supplied to the dialog via the description prop.

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit username</Button>
      <Dialog title="Edit username">
        <TextField label="Username" defaultValue="@mehdibha_" />
      </Dialog>
    </DialogRoot>
  );
}

Type

Dialogs can be rendered as modals, popovers, or drawers.

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit username</Button>
      <Dialog title="Edit username">
        <TextField label="Username" defaultValue="@mehdibha_" />
      </Dialog>
    </DialogRoot>
  );
}

Mobile type

By default, dialogs are displayed as drawers on mobile. You can override this behavior by setting the mobileType prop.

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit username</Button>
      <Dialog title="Edit username">
        <TextField label="Username" defaultValue="@mehdibha_" />
      </Dialog>
    </DialogRoot>
  );
}

Dismissable

By default, dialogs are dismissable allowing users to click outside to close the dialog. You can disable this behavior by setting the isDismissable prop.

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog, DialogBody, DialogFooter } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit Profile</Button>
      <Dialog
        title="Edit profile"
        description="Make changes to your profile."
        isDismissable={false}
      >
        {({ close }) => (
          <>
            <DialogBody>
              <TextField autoFocus label="Name" defaultValue="Mehdi" />
              <TextField label="Username" defaultValue="@mehdibha_" />
            </DialogBody>
            <DialogFooter>
              <Button variant="outline" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Cancel
              </Button>
              <Button variant="primary" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Save changes
              </Button>
            </DialogFooter>
          </>
        )}
      </Dialog>
    </DialogRoot>
  );
}

Dismiss button

By default, dialogs have a dismiss button when they're dismissable. You can disable this behavior by setting showDismissButton prop.

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog, DialogBody, DialogFooter } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit Profile</Button>
      <Dialog title="Edit profile" description="Make changes to your profile." isDismissable>
        {({ close }) => (
          <>
            <DialogBody>
              <TextField autoFocus label="Name" defaultValue="Mehdi" />
              <TextField label="Username" defaultValue="@mehdibha_" />
            </DialogBody>
            <DialogFooter>
              <Button variant="outline" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Cancel
              </Button>
              <Button variant="primary" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Save changes
              </Button>
            </DialogFooter>
          </>
        )}
      </Dialog>
    </DialogRoot>
  );
}

Inset content

Use the <DialogInset> component to add inset content to the dialog.

import React from "react";

function Demo() {
  return <p className="text-3xl">TODO</p>;
}

Popover

Use the type="popover" prop on the <Dialog> element to make a popover dialog.

"use client";

import React from "react";
import type { Key } from "react-aria-components";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { Item } from "@/lib/components/core/default/list-box";
import { NumberField } from "@/lib/components/core/default/number-field";
import { Select } from "@/lib/components/core/default/select";
import { Switch } from "@/lib/components/core/default/switch";
import { InfoIcon } from "@/lib/icons";

function Demo() {
  const [placement, setPlacement] = React.useState<Key>("top");
  const [offset, setOffset] = React.useState<number>(0);
  const [crossOffset, setCrossOffset] = React.useState<number>(0);
  const [containerPadding, setContainerPadding] = React.useState<number>(0);
  const [showArrow, setShowArrow] = React.useState<boolean>(true);
  return (
    <div className="flex w-full items-center">
      <div className="flex flex-1 items-center justify-center">
        <DialogRoot>
          <Button variant="outline" shape="square">
            <InfoIcon />
          </Button>
          <Dialog
            type="popover"
            title="Help"
            description="For help accessing your account, please contact support."
          />
        </DialogRoot>
      </div>
      <div className="space-y-4 rounded-md border p-4">
        <Select label="Placement" selectedKey={placement} onSelectionChange={setPlacement}>
          <Item id="top">Top</Item>
          <Item id="bottom">Bottom</Item>
        </Select>
        <NumberField label="Offset" value={offset} onChange={setOffset} />
        <NumberField label="Cross offset" value={crossOffset} onChange={setCrossOffset} />
        <NumberField
          label="Container padding"
          value={containerPadding}
          onChange={setContainerPadding}
        />
        <Switch isSelected={showArrow} onChange={setShowArrow}>
          Arrow
        </Switch>
      </div>
    </div>
  );
}

Drawer

Use the type="drawer" prop on the <Dialog> element to make a drawer dialog.

"use client";

import React from "react";
import type { Key } from "react-aria-components";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { Item } from "@/lib/components/core/default/list-box";
import { Select } from "@/lib/components/core/default/select";
import { Switch } from "@/lib/components/core/default/switch";

function Demo() {
  const [placement, setPlacement] = React.useState<Key>("top");
  const [swipeable, setSwipeable] = React.useState<boolean>(true);
  return (
    <div className="flex w-full items-center">
      <div className="flex flex-1 items-center justify-center">
        <DialogRoot>
          <Button variant="outline">Open drawer</Button>
          <Dialog
            type="drawer"
            title="Help"
            description="For help accessing your account, please contact support."
          />
        </DialogRoot>
      </div>
      <div className="space-y-4 rounded-md border p-4">
        <Select label="Placement" selectedKey={placement} onSelectionChange={setPlacement}>
          <Item id="top">Top</Item>
          <Item id="bottom">Bottom</Item>
        </Select>
        <Switch isSelected={swipeable} onChange={setSwipeable}>
          Swipeable
        </Switch>
      </div>
    </div>
  );
}

Alert Dialog

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

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog, DialogFooter } from "@/lib/components/core/default/dialog";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="danger">Delete project</Button>
      <Dialog
        title="Delete project"
        description="Are you sure you want to delete this project? This action is permanent and cannot be undone."
        role="alertdialog"
        // isDissmissible={false}
      >
        {({ close }) => (
          <>
            <DialogFooter>
              <Button variant="outline" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Cancel
              </Button>
              <Button variant="danger" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Delete project
              </Button>
            </DialogFooter>
          </>
        )}
      </Dialog>
    </DialogRoot>
  );
}

Controlled

Use the isOpen and onOpenChange props to control the dialog's open state.

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog, DialogBody, DialogFooter } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit Profile</Button>
      <Dialog title="Edit profile" description="Make changes to your profile.">
        {({ close }) => (
          <>
            <DialogBody>
              <TextField autoFocus label="Name" defaultValue="Mehdi" />
              <TextField label="Username" defaultValue="@mehdibha_" />
            </DialogBody>
            <DialogFooter>
              <Button variant="outline" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Cancel
              </Button>
              <Button variant="primary" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Save changes
              </Button>
            </DialogFooter>
          </>
        )}
      </Dialog>
    </DialogRoot>
  );
}

Composition

If you need to customize things further, you can drop down to the composition level.

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function Demo() {
  return (
    <DialogRoot>
      <Button variant="outline">Edit username</Button>
      <Dialog title="Edit username">
        <TextField label="Username" defaultValue="@mehdibha_" />
      </Dialog>
    </DialogRoot>
  );
}

Examples

Async form submission

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog, DialogBody, DialogFooter } from "@/lib/components/core/default/dialog";
import { TextField } from "@/lib/components/core/default/text-field";

function DialogDemo() {
  const [isLoading, setIsLoading] = React.useState(false);
  const handleSubmit = async () => {
    setIsLoading(true);
    await new Promise((resolve) => setTimeout(resolve, 1000));
    setIsLoading(false);
  };
  return (
    <DialogRoot>
      <Button variant="outline">Edit Profile</Button>
      <Dialog title="Edit profile" description="Make changes to your profile.">
        {({ close }) => (
          <>
            <DialogBody>
              <TextField autoFocus label="Name" defaultValue="Mehdi" className="w-full" />
              <TextField label="Username" defaultValue="@mehdibha_" />
            </DialogBody>
            <DialogFooter>
              <Button variant="outline" size={{ initial: "lg", sm: "md" }} onPress={close}>
                Cancel
              </Button>
              <Button
                isLoading={isLoading}
                variant="primary"
                size={{ initial: "lg", sm: "md" }}
                onPress={async () => {
                  await handleSubmit();
                  close();
                }}
              >
                Save changes
              </Button>
            </DialogFooter>
          </>
        )}
      </Dialog>
    </DialogRoot>
  );
}

Nested dialog

Dialogs support nesting, allowing you to open a dialog from within another dialog.

"use client";

import React from "react";
import { Button } from "@/lib/components/core/default/button";
import { DialogRoot, Dialog } from "@/lib/components/core/default/dialog";
import { Radio, RadioGroup } from "@/lib/components/core/default/radio-group";

type Type = "modal" | "drawer" | "popover";

function Demo() {
  const [type, setType] = React.useState<Type>("modal");
  return (
    <div className="flex w-full items-center">
      <div className="flex flex-1 items-center justify-center">
        <DialogRoot>
          <Button variant="outline">Dialog</Button>
          <Dialog title="Dialog" type={type}>
            <DialogRoot>
              <Button variant="outline">Nested dialog</Button>
              <Dialog title="Nested dialog" type={type}></Dialog>
            </DialogRoot>
          </Dialog>
        </DialogRoot>
      </div>
      <div className="space-y-4 rounded-md border px-10 py-6">
        <RadioGroup label="Type" value={type} onChange={(value) => setType(value as Type)}>
          <Radio value="modal">Modal</Radio>
          <Radio value="drawer">Drawer</Radio>
          <Radio value="popover">Popover</Radio>
        </RadioGroup>
      </div>
    </div>
  );
}

API Reference

DialogRoot

PropTypeDefaultDescription
children*
ReactNode
-
isOpen
boolean
-
Whether the overlay is open by default (controlled).
defaultOpen
boolean
-
Whether the overlay is open by default (uncontrolled).
EventTypeDescription
onOpenChange
(isOpen: boolean) => void
Handler that is called when the overlay's open state changes.

Dialog

PropTypeDefaultDescription
children
ReactNode | (opts: DialogRenderProps) => ReactNode
-
Children of the dialog. A function may be provided to access a function to close the dialog.
className
string
-
The CSS className for the element.
style
CSSProperties
-
The inline style for the element.

Accessibility

Keyboard interactions

KeyDescription
Space Enter
When focus is on the trigger, opens the dialog.
Tab
Moves focus to the next button inside the dialog (last becomes first). If none of the buttons are selected, the focus is set on the first button.
Shift+Tab
Moves focus to the previous button inside the dialog (first becomes last). If none of the buttons are selected, the focus is set on the last button.
Esc
Dismisses the dialog and moves focus to the trigger.

Built by mehdibha. The source code is available on GitHub.