Built on @radix-ui/react-dialog — a centered, modal overlay. Modal by default: focus trap, scroll lock, and aria-hidden siblings all come free from Radix, not reimplemented here.
npx chandra-asri-ui add dialog"use client";
import { buttonVariants } from "@/app/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/app/components/ui/dialog";
import { cn } from "@/lib/utils";
import * as React from "react";
/**
* Controlled Dialog (state via `useState`, synced through `onOpenChange` so
* Escape / overlay-click / the close button all still work) — the same
* pattern used by `WebAnalyzerEmailOverlay.tsx` / `AddEventModal.tsx` on
* the source platform. `DialogTrigger` renders a real `<button>` itself, so
* it's styled directly with `buttonVariants` instead of wrapping `Button`
* with `asChild`.
*/
export function DialogDemo() {
const [open, setOpen] = React.useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className={cn(buttonVariants({ variant: "default" }))}>
Open Dialog
</DialogTrigger>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
Confirm action
</DialogTitle>
</DialogHeader>
<div className="px-4">
<DialogDescription>
This closes on Escape, on a click outside, or via the button below —
all wired up automatically by Radix, not manually.
</DialogDescription>
</div>
<DialogFooter className="p-4 pt-0">
<DialogClose className={cn(buttonVariants({ variant: "secondary" }))}>
Cancel
</DialogClose>
<button
type="button"
className={cn(buttonVariants({ variant: "default" }))}
onClick={() => setOpen(false)}
>
Confirm
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Because the modal prop is never overridden (stays at Radix's own default of true), opening a Dialog traps keyboard focus inside DialogContent, auto-focuses its first focusable element, locks <body> scroll, and marks every sibling element aria-hidden. Closing returns focus to whatever triggered it. It dismisses on Escape and on a click on the overlay — both can be prevented via onEscapeKeyDown / onPointerDownOutside if a specific dialog needs to opt out.
Radix requires a DialogTitle inside every DialogContent for aria-labelledby — if a design doesn't want a visible title, render one with className="sr-only" instead of leaving it out; omitting it throws a console warning and breaks screen-reader labeling.
DialogContent sits at z-[75]— a deliberate bump above shadcn's default z-50, which Sheet, Popover, and Tooltip all still use — so an open Dialog always layers above an open Sheet.
Uncontrolled — just a DialogTrigger, open/closed state managed internally by Radix — works for the common case. Switch to controlled (open + onOpenChange, as in the example above) when something other than a click needs to open it. If controlled, onOpenChange must stay wired up — it fires on Escape, overlay click, and DialogClose too, not just the trigger — otherwise those dismiss paths stop working.
"use client";
import { cn } from "@/lib/utils";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import * as React from "react";
/**
* Built on @radix-ui/react-dialog — modal by default (the `modal` prop is
* never overridden), so focus trap, auto-focus on open, return-focus on
* close, body scroll lock, `aria-hidden` on sibling content, Escape
* dismiss, and click-outside dismiss are all bawaan Radix behavior, not
* reimplemented here. `z-[75]` is a deliberate departure from shadcn's
* default `z-50` — see `sheet.tsx`, which stays at `z-50`, so an open
* Dialog always layers above an open Sheet.
*
* Animation is driven by framer-motion, not CSS keyframes. A hand-rolled
* `data-state`-driven CSS version was tried first and kept coming out
* visibly crooked — getting a percentage-based centering transform
* (`translate(-50%, -50%)`) to also slide in from one direction via plain
* CSS keyframes is easy to get subtly wrong (see git history). Framer
* Motion sidesteps that entirely: `open` is tracked in a small context
* below and used to gate an `AnimatePresence`, with `forceMount` on
* `DialogPrimitive.Portal`/`Overlay`/`Content` so Radix's own internal
* Presence (which normally unmounts them the instant `open` goes false)
* steps aside and leaves mount/unmount timing entirely to framer-motion —
* this is Radix's own documented pattern for pairing its primitives with
* an external animation library, not a workaround.
*
* `DialogContent` centers with flexbox (`fixed inset-0 flex items-center
* justify-center`) instead of the classic `top-1/2 left-1/2
* translate(-50%,-50%)` percentage trick — no percentage-based transform
* math involved at all, so there's nothing for the rise-up motion below to
* interact with or throw off. `DialogPrimitive.Content` (asChild) renders
* directly as the `motion.div` that fades up 16px on its own local Y axis,
* a movement that, by construction, can't ever end up diagonal.
*
* Radix requires a `DialogTitle` inside every `DialogContent` for
* `aria-labelledby` — if a design doesn't want a visible title, render one
* with `className="sr-only"` instead of omitting it (dropping it entirely
* throws a console warning and breaks screen-reader labeling).
*/
const DialogOpenContext = React.createContext(false);
export function Dialog({
open,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root>) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = open !== undefined;
const currentOpen = isControlled ? open : uncontrolledOpen;
return (
<DialogOpenContext.Provider value={currentOpen}>
<DialogPrimitive.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={(next) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
}}
{...props}
/>
</DialogOpenContext.Provider>
);
}
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
const OVERLAY_TRANSITION = { duration: 0.15, ease: "easeOut" as const };
const CONTENT_TRANSITION = { duration: 0.2, ease: "easeOut" as const };
export const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
const open = React.useContext(DialogOpenContext);
return (
<AnimatePresence>
{open && (
<DialogPrimitive.Portal forceMount>
<DialogPrimitive.Overlay forceMount asChild>
<motion.div
className="fixed inset-0 z-[75] bg-black/10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={OVERLAY_TRANSITION}
/>
</DialogPrimitive.Overlay>
<div className="fixed inset-0 z-[75] flex items-center justify-center">
<DialogPrimitive.Content ref={ref} forceMount asChild {...props}>
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.96 }}
transition={CONTENT_TRANSITION}
className={cn(
"border-border bg-background grid w-[95vw] gap-4 rounded-lg border shadow-lg",
className,
)}
>
{children}
<DialogPrimitive.Close
className={cn(
"ring-offset-background focus:ring-primary-30 data-[state=open]:bg-surface-alt data-[state=open]:text-muted absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none",
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</motion.div>
</DialogPrimitive.Content>
</div>
</DialogPrimitive.Portal>
)}
</AnimatePresence>
);
});
DialogContent.displayName = "DialogContent";
export function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"border-border flex flex-col space-y-1.5 border-b p-4 text-left font-semibold",
className,
)}
{...props}
/>
);
}
DialogHeader.displayName = "DialogHeader";
export function DialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
}
DialogFooter.displayName = "DialogFooter";
export const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-sm leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted text-sm", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;