Dialog

Centered modal with a spring-scaled popup and fading backdrop — a detached floating plane, not an edge-attached panel like Sheet.

Sizes

The `sm` and `lg` width variants side by side.

sizes

With footer

A destructive confirmation with header, description, and footer actions.

with-footer

Install

Install the packages this component imports.

bun add motion @base-ui/react lucide-react
Terminal

Also requires lib/utils.ts, lib/springs.ts (see Installation), and Button primitive.

Source

Copy and paste the following code into your project.

"use client";

import type { ComponentProps } from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { motion } from "motion/react";
import { XIcon } from "lucide-react";

import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
import { Button } from "@/components/ui/button";

function Dialog({ ...props }: DialogPrimitive.Root.Props) {
  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}

function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}

function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}

/**
 * Rendered through Base UI's `render` function-prop rather than a plain
 * `className` so the backdrop can read `state.transitionStatus` — Base UI
 * keeps the element mounted through a "ending" status and only removes it
 * once it detects the animation on it has finished, so handing that status
 * to motion (instead of a CSS transition) is enough to get a correct
 * exit with no manual mount-tracking, unlike Accordion's Panel (which
 * doesn't defer removal the same way).
 */
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
  return (
    <DialogPrimitive.Backdrop
      data-slot="dialog-overlay"
      render={(backdropProps, state) => {
        const exiting = state.transitionStatus === "ending";
        return (
          <motion.div
            {...(backdropProps as Record<string, unknown>)}
            className={cn("fixed inset-0 z-50 bg-black/40 backdrop-blur-sm", className)}
            initial={{ opacity: 0 }}
            animate={{ opacity: exiting ? 0 : 1 }}
            transition={exiting ? spring.slow.exit : spring.slow.enter}
          />
        );
      }}
      {...props}
    />
  );
}

/**
 * Centered, fully-rounded surface — unlike Sheet (which sits flush against a
 * viewport edge), a dialog is a detached floating plane, so it gets the
 * `--popover` step and `shadow-panel` on every side rather than Sheet's
 * edge-attached treatment.
 */
function DialogContent({
  className,
  children,
  size = "sm",
  showCloseButton = true,
  ...props
}: DialogPrimitive.Popup.Props & {
  size?: "sm" | "lg";
  showCloseButton?: boolean;
}) {
  return (
    <DialogPortal>
      <DialogOverlay />
      <DialogPrimitive.Popup
        data-slot="dialog-content"
        render={(popupProps, state) => {
          const exiting = state.transitionStatus === "ending";
          return (
            <motion.div
              {...(popupProps as Record<string, unknown>)}
              {...(props as Record<string, unknown>)}
              className={cn(
                "fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-2rem)] flex-col rounded-xl bg-popover p-6 text-popover-foreground shadow-panel outline-none",
                size === "sm" && "max-w-sm",
                size === "lg" && "max-w-lg",
                className,
              )}
              initial={{ opacity: 0, scale: 0.97, x: "-50%", y: "-50%" }}
              animate={{
                opacity: exiting ? 0 : 1,
                scale: exiting ? 0.97 : 1,
                x: "-50%",
                y: "-50%",
              }}
              transition={exiting ? spring.slow.exit : spring.slow.enter}
            >
              {children}
              {showCloseButton && (
                <DialogPrimitive.Close
                  data-slot="dialog-close"
                  render={
                    <Button
                      variant="ghost"
                      // Ghost's default hover:bg-muted is calibrated against
                      // --background; on --popover it's nearly invisible in
                      // dark mode (0.3 vs muted's 0.295 lightness — a ~0.005
                      // step). A foreground-tinted overlay contrasts against
                      // any surface instead of matching one particular token.
                      className="absolute top-3 right-3 hover:bg-foreground/10 dark:hover:bg-foreground/10"
                      size="icon-sm"
                    />
                  }
                >
                  <XIcon />
                  <span className="sr-only">Close</span>
                </DialogPrimitive.Close>
              )}
            </motion.div>
          );
        }}
      />
    </DialogPortal>
  );
}

function DialogHeader({ className, ...props }: ComponentProps<"div">) {
  return (
    <div data-slot="dialog-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
  );
}

function DialogFooter({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn("mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
      {...props}
    />
  );
}

function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn("text-control font-medium text-foreground", className)}
      {...props}
    />
  );
}

function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
  return (
    <DialogPrimitive.Description
      data-slot="dialog-description"
      className={cn("text-caption text-muted-foreground", className)}
      {...props}
    />
  );
}

export {
  Dialog,
  DialogTrigger,
  DialogClose,
  DialogPortal,
  DialogOverlay,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
};
dialog.tsx

API Reference

Dialog
defaultOpenfalse

boolean

Whether the dialog is initially open, uncontrolled.

open

boolean

Controlled open state.

onOpenChange

(open: boolean) => void

Called when the open state changes.

modaltrue

boolean | "trap-focus"

Whether the rest of the page is inert while the dialog is open.

API Reference

DialogContent
size"sm"

"sm" | "lg"

Width of the dialog.

showCloseButtontrue

boolean

Whether to render the top-right close button.

children

ReactNode

Dialog content — typically DialogHeader and DialogFooter.

API Reference

DialogTitleDialogDescription
children

ReactNode

Title or description content.