Radix dialog with house modal physics: overlay fade, center-origin 0.97 scale enter, faster non-retracing exit, optional backdrop blur.
npx shadcn@latest add @paragon/dialog"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/*
* House modal physics: overlay fades, content scales 0.97 -> 1 from center
* (modals never inherit trigger origin) over 200ms ease-out. Exit runs at
* 150ms ease-exit and settles to 0.99 — it never retraces the enter.
* Keyframes animate the individual `scale`/`opacity` properties so the
* centering `translate` utilities are never clobbered. Radix keeps the
* element mounted until the closed animation finishes, so exits play.
*/
const dialogStyles = `
@keyframes pg-dialog-overlay-in { from { opacity: 0; } }
@keyframes pg-dialog-overlay-out { to { opacity: 0; } }
@keyframes pg-dialog-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-dialog-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-dialog-in { from { opacity: 0; } }
@keyframes pg-dialog-out { to { opacity: 0; } }
}
`;
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger(
props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal(
props: React.ComponentProps<typeof DialogPrimitive.Portal>,
) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose(
props: React.ComponentProps<typeof DialogPrimitive.Close>,
) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
interface DialogOverlayProps
extends React.ComponentProps<typeof DialogPrimitive.Overlay> {
/** Adds a backdrop blur behind the dialog. */
blur?: boolean;
}
function DialogOverlay({ className, blur = false, ...props }: DialogOverlayProps) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/40 dark:bg-black/60",
"data-[state=open]:animate-[pg-dialog-overlay-in_200ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-dialog-overlay-out_150ms_var(--ease-exit)_forwards]",
blur && "backdrop-blur-sm",
className,
)}
{...props}
/>
);
}
export interface DialogContentProps
extends React.ComponentProps<typeof DialogPrimitive.Content> {
/** Blur the page behind the overlay. */
overlayBlur?: boolean;
/** Hide the top-right close button. */
showClose?: boolean;
}
function DialogContent({
className,
children,
overlayBlur = false,
showClose = true,
...props
}: DialogContentProps) {
return (
<DialogPortal>
<style href="paragon-dialog" precedence="paragon">{dialogStyles}</style>
<DialogOverlay blur={overlayBlur} />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-card p-6 text-card-foreground shadow-overlay outline-none sm:max-w-md",
"data-[state=open]:animate-[pg-dialog-in_200ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-dialog-out_150ms_var(--ease-exit)_forwards]",
className,
)}
{...props}
>
{children}
{showClose && (
<DialogPrimitive.Close
aria-label="Close"
className="absolute top-4 right-4 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogClose,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};