Skip to content
ssntl/ui

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/dialog

Usage

tsx
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.

components/ui/dialog.md
## 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.
Open the full rules file (opens in a new tab)

Code

components/ui/dialog.tsx
"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,
}