Dialog
A modal on the top layer with focus trap, a nested close button and actions at the end.
Installation
$ pnpm dlx shadcn@latest add @ssntl/dialogUsage
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild><Button variant="destructive">Delete product</Button></DialogTrigger>
<DialogContent>
<DialogTitle>Delete Ceramic cup?</DialogTitle>
<DialogDescription>It leaves your store and your inventory.</DialogDescription>
<DialogFooter>
<DialogClose asChild><Button variant="secondary">Keep product</Button></DialogClose>
<Button variant="destructive" onClick={remove}>Delete product</Button>
</DialogFooter>
</DialogContent>
</Dialog>Rules
What this component expects from you, as it ships in components/ui/dialog.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.
## Rules for Dialog
- **Modal depth.** Elevation-4 over a 40% scrim, the top layer; the page behind is inert.
- Focus moves in and is trapped; Escape and the close button dismiss; focus returns to the trigger (Radix).
- **Action placement.** Actions sit at the end, the confirming one last, in every dialog.
- A destructive dialog names what it removes and labels both buttons with verbs (Keep product, Delete product), never OK and Cancel.
- **Concentric.** The 48px close button sits 8px from the 32px corner.
- **Never taller than the screen.** Put long content in `DialogBody`, which scrolls on its own while the title and actions stay in view; scrolling never reaches the page behind.Code
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CloseButton } from "@/registry/ssntl/ui/close-button"
// A modal dialog (Radix): focus moves in and is trapped, Escape and the
// close button dismiss it, focus returns to the trigger, and the page behind
// is inert. Modal depth: elevation 4 over a scrim, the highest layer.
// Concentric: the close button (48px) sits 8px from a 32px corner.
// Action placement: the footer keeps actions at the end, the confirming one
// last, in every dialog.
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
function DialogContent({
className,
children,
closeLabel = "Close dialog",
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
/** The close button's accessible name. */
closeLabel?: string
}) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className="fixed inset-0 z-50 grid place-items-center overscroll-contain bg-black/40 p-4 data-[state=closed]:pop-out data-[state=open]:pop-in"
>
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
// Never taller than the screen: long content scrolls in DialogBody
// while the title and the actions stay in view.
"relative flex max-h-[calc(100dvh-2rem)] w-full max-w-md flex-col gap-2 rounded-lg bg-card p-6 pe-16 text-left text-card-foreground elevation-4 [--pop-duration:var(--duration-panel)]",
"data-[state=closed]:pop-out data-[state=open]:pop-in",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close asChild>
<CloseButton label={closeLabel} className="absolute end-2 top-2" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Overlay>
</DialogPrimitive.Portal>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-heading", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-body text-pretty text-muted-foreground", className)}
{...props}
/>
)
}
/** Long content: scrolls on its own so the actions never leave the screen. */
function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-body"
className={cn(
"-me-10 min-h-0 overflow-y-auto overscroll-contain pe-10",
className
)}
{...props}
/>
)
}
/** Actions at the end; the one that confirms goes last. */
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-me-10 mt-4 flex flex-wrap items-center justify-end gap-2",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
}