GitHub

Dialog

A modal overlay rendered via portal. Supports Escape to close, backdrop click to dismiss, body scroll lock, and four sizes.

AccessibleDark ModePortal4 SizesCompound
01

Theme Preview

Static dialog panel rendered inline — forced light and dark styling for direct comparison.

Light

Delete item?

This action is permanent and cannot be undone.

Dark

Delete item?

This action is permanent and cannot be undone.

02

Live Demo

Click a button to open the corresponding dialog.

03

Code Snippet

src/ui/Dialog.tsx
import { Dialog } from "@/ui/Dialog";
import { Button } from "@/ui/Button";

// Confirm dialog
const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>Delete item</Button>

<Dialog.Root open={open} onClose={() => setOpen(false)} size="sm">
  <Dialog.Header>
    <Dialog.Title>Delete item?</Dialog.Title>
    <Dialog.Description>
      This action is permanent and cannot be undone.
    </Dialog.Description>
  </Dialog.Header>
  <Dialog.Footer>
    <Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
    <Button variant="destructive" onClick={() => setOpen(false)}>Delete</Button>
  </Dialog.Footer>
</Dialog.Root>

// Sizes: "sm" | "md" | "lg" | "xl"
<Dialog.Root open={open} onClose={() => setOpen(false)} size="lg">
  ...
</Dialog.Root>

Flat exports seperti DialogHeader, DialogContent, dan lainnya tetap didukung untuk migrasi bertahap.

04

Copy-Paste (Single File)

Versi ini sudah self-contained, termasuk helper class merge dan animated mount, jadi minim setup saat dipindahkan.

Dialog.tsx
"use client";

import { useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from "react";
import { createPortal } from "react-dom";

type ClassValue = string | false | null | undefined;
const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" ");

export type DialogSize = "sm" | "md" | "lg" | "xl";

export interface DialogProps {
  open: boolean;
  onClose: () => void;
  size?: DialogSize;
  children: ReactNode;
  className?: string;
}

const SIZE_CLASSES: Record<DialogSize, string> = {
  sm: "max-w-sm",
  md: "max-w-md",
  lg: "max-w-lg",
  xl: "max-w-xl",
};

function useAnimatedMount(open: boolean, durationMs = 200) {
  const [mounted, setMounted] = useState(open);
  const [visible, setVisible] = useState(open);

  useEffect(() => {
    if (open) {
      setMounted(true);
      requestAnimationFrame(() => setVisible(true));
      return;
    }

    setVisible(false);
    const timer = window.setTimeout(() => setMounted(false), durationMs);
    return () => window.clearTimeout(timer);
  }, [open, durationMs]);

  return { mounted, visible };
}

function DialogHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("px-6 pt-6 pb-4", className)} {...props}>{children}</div>;
}

function DialogTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
  return <h2 className={cn("pr-8 text-lg font-semibold text-slate-900", className)} {...props}>{children}</h2>;
}

function DialogDescription({ className, children, ...props }: HTMLAttributes<HTMLParagraphElement>) {
  return <p className={cn("mt-1.5 text-sm leading-relaxed text-slate-500", className)} {...props}>{children}</p>;
}

function DialogContent({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("px-6 py-2", className)} {...props}>{children}</div>;
}

function DialogFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("flex items-center justify-end gap-3 border-t border-slate-100 px-6 py-4", className)} {...props}>{children}</div>;
}

function DialogRoot({ open, onClose, size = "md", children, className }: DialogProps) {
  const overlayRef = useRef<HTMLDivElement>(null);
  const { mounted, visible } = useAnimatedMount(open, 200);

  useEffect(() => {
    if (!open) return;

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") onClose();
    };

    document.addEventListener("keydown", onKeyDown);
    document.body.style.overflow = "hidden";

    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = "";
    };
  }, [open, onClose]);

  if (!mounted) return null;

  return createPortal(
    <div
      ref={overlayRef}
      className="fixed inset-0 z-50 flex items-center justify-center p-4"
      onClick={(event) => {
        if (event.target === overlayRef.current) onClose();
      }}
    >
      <div className={cn("absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-200", visible ? "opacity-100" : "opacity-0")} />

      <div
        role="dialog"
        aria-modal="true"
        className={cn(
          "relative w-full rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-black/20 transition-all duration-200",
          visible ? "scale-100 opacity-100" : "scale-95 opacity-0",
          SIZE_CLASSES[size],
          className
        )}
      >
        <button
          onClick={onClose}
          aria-label="Close dialog"
          className="absolute right-4 top-4 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
        >
          ×
        </button>
        {children}
      </div>
    </div>,
    document.body
  );
}

type DialogCompound = typeof DialogRoot & {
  Root: typeof DialogRoot;
  Header: typeof DialogHeader;
  Title: typeof DialogTitle;
  Description: typeof DialogDescription;
  Content: typeof DialogContent;
  Footer: typeof DialogFooter;
};

export const Dialog = Object.assign(DialogRoot, {
  Root: DialogRoot,
  Header: DialogHeader,
  Title: DialogTitle,
  Description: DialogDescription,
  Content: DialogContent,
  Footer: DialogFooter,
}) as DialogCompound;
05

Props

PropTypeDefaultDescription
openbooleanControls dialog visibility.
onClose() => voidCalled when Escape is pressed, backdrop is clicked, or the × button is clicked.
size"sm" | "md" | "lg" | "xl""md"Controls max-width of the dialog panel.
childrenReactNodeDialog content, typically composed with sub-components.
classNamestringExtra classes applied to the dialog panel.
react-principles