Dialog

A dialog is a window that opens over the page and asks for focus. Use it for a task that is better done in one place, away from everything else.

import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";

export default function DialogDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button />}>Edit profile</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>Change how you appear on the site. Save when you are done.</DialogDescription>
        </DialogHeader>
        <Field name="name">
          <FieldLabel>Name</FieldLabel>
          <Input defaultValue="Njoh Simplice" />
        </Field>
        <DialogFooter>
          <DialogClose render={<Button variant="secondary" />}>Cancel</DialogClose>
          <DialogClose render={<Button />}>Save changes</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { X } from "lucide-react";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { Button } from "@/components/ui/button";
import { cx } from "@/lib/cx";
import { backdropStyles, popupMotion, popupSurface } from "@/lib/styles";
import type { WithClass } from "@/lib/types";

export const Dialog = BaseDialog.Root;

// Renders a button by default. To open it from your own element, such as a Button, pass it as
// render: <DialogTrigger render={<Button />}>.
export const DialogTrigger = BaseDialog.Trigger;

// A dim backdrop, and a panel centered on the screen with a close button in the corner. Focus is
// held inside while it is open, and goes back to the trigger when it closes.
export function DialogContent({ className, children, ...props }: WithClass<ComponentProps<typeof BaseDialog.Popup>>) {
  return (
    <BaseDialog.Portal>
      <BaseDialog.Backdrop className={backdropStyles} />
      <BaseDialog.Popup
        className={cx(
          popupSurface,
          popupMotion,
          "fixed top-1/2 left-1/2 z-50 flex w-[28rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 p-6",
          className,
        )}
        {...props}
      >
        {children}
        <BaseDialog.Close render={<Button variant="secondary" size="icon" aria-label="Close" className="absolute top-3 right-3" />}>
          <X />
        </BaseDialog.Close>
      </BaseDialog.Popup>
    </BaseDialog.Portal>
  );
}

export function DialogHeader({ className, ...props }: ComponentProps<"div">) {
  return <div className={cx("flex flex-col gap-1.5 pr-10", className)} {...props} />;
}

export function DialogTitle({ className, ...props }: WithClass<ComponentProps<typeof BaseDialog.Title>>) {
  return <BaseDialog.Title className={cx("text-xl leading-tight font-semibold text-text", className)} {...props} />;
}

export function DialogDescription({ className, ...props }: WithClass<ComponentProps<typeof BaseDialog.Description>>) {
  return <BaseDialog.Description className={cx("text-base text-text/70", className)} {...props} />;
}

export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
  return <div className={cx("flex flex-col gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
}

export const DialogClose = BaseDialog.Close;

Props

PropTypeDefaultWhat it does
defaultOpenbooleanfalseStarts the dialog open, when it manages its own state.
openboolean—Whether it is open, when you control it yourself.
onOpenChangefunction—Called with the new open state.
renderelement—On DialogTrigger and DialogClose. Uses your own element, such as a Button, instead of a plain button.

Every other prop goes to the Base UI Dialog underneath. Always give the dialog a DialogTitle.

The thinking

A dialog interrupts, so it has to be worth it. It is for a self-contained job: edit a profile, confirm a choice, read something before going on.

  • Built on Base UI: Base UI holds focus inside, restores it when the dialog closes, locks the page behind and closes on Escape.
  • Parts: DialogContent is the window with its backdrop and close button. DialogHeader, DialogTitle, DialogDescription and DialogFooter lay out the inside.
  • Dialog or Alert Dialog: A dialog can be dismissed by clicking outside or with the corner button. If the question has to be answered, use an Alert Dialog.

Interface design

  • Backdrop: Black at 60%, so the page is still there but clearly out of reach.
  • Window: The shared overlay surface, centered, 28rem wide and never wider than the screen minus a 16px margin on each side.
  • Footer: Actions sit on the right on desktop. On a phone they stack, with the main action at the bottom under the thumb.
  • Close button: An icon-only button in the top corner, for people who want out.

Interaction

  • Open: Click the trigger, or press Enter or Space when it has focus.
  • Focus: Focus moves into the dialog and Tab stays inside it. When the dialog closes, focus goes back to the trigger.
  • Close: Escape, the corner button, a click on the backdrop, or a DialogClose button.
  • Scroll: The page behind does not scroll while the dialog is open.
  • Motion: A 150ms fade, with a small scale on the window. With reduced motion turned on it appears at once.

Empathy

  • Named: The dialog is announced with its title and described by its description.
  • Nothing behind is reachable: Screen reader and keyboard users can't wander into the page underneath.
  • Always a way out: Escape and the close button are both there, and Cancel sits next to the main action.
  • Keep it short: On a small screen a long dialog scrolls badly. If it needs a whole page, make it a page.