Dialog
A modal dialog with portal, overlay, focus trap and fade/zoom animations, composed from Trigger, Content, Header, Footer, Title, Description and Close parts.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export default function DialogDemo() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open dialog</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>You can revoke it at any time.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<Button tone="brand">Create key</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/dialogDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install radix-ui@^1.6.1 lucide-react@^1.23.0 tw-animate-css@^1.4.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/dialog.tsx"use client"
import type * as React from "react"
import { X } from "lucide-react"
import { Dialog as DialogPrimitive } from "@/components/ui/primitives"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/* max-h + overflow-y-auto are REQUIRED, not decoration: the dialog is centred
with `fixed` + `-translate-y-1/2`, and while it is open Radix locks body
scrolling. Without its own scroll container, the bottom of content taller
than the viewport cannot be reached on mobile by ANY means (measured
2026-07-26: last row off-screen, the box would not scroll). If the confirm
button sits at the bottom, the whole flow dies.
`dvh` is used: on iOS `vh` does not account for the address bar. Radix
renders nested overlays (select, popover) into a portal, so this scroll
container does not clip them. */
const dialogContentVariants = cva(
"fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover p-6 text-popover-foreground shadow-lg duration-(--motion-base) data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
{
variants: {
size: {
sm: "sm:max-w-sm",
md: "sm:max-w-lg",
lg: "sm:max-w-2xl",
xl: "sm:max-w-4xl",
full: "sm:max-w-[calc(100%-4rem)]",
},
},
defaultVariants: { size: "md" },
}
)
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 DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 motion-reduce:animate-none",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showClose = true,
size,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> &
VariantProps<typeof dialogContentVariants> & {
showClose?: boolean
}) {
return (
<DialogPrimitive.Portal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
data-size={size ?? "md"}
className={cn(dialogContentVariants({ size, className }))}
{...props}
>
{children}
{showClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md opacity-70 outline-none transition-opacity duration-(--motion-fast) after:absolute after:-inset-2 hover:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none">
<X />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5 text-center sm:text-left", 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-lg font-semibold leading-none", 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,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogOverlay,
dialogContentVariants,
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Usage
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>You can revoke it at any time.</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>Wrap the trigger with DialogTrigger asChild to attach the dialog to any element. Always include a DialogTitle (visually hidden with sr-only if needed) for accessibility.
Examples
With footer actions
Wrap a button with DialogClose asChild to make it dismiss the dialog without extra state.
Without close button
Custom width
Sizes
The size prop caps the panel max width from the sm breakpoint up: sm, md (the default, max-w-lg), lg, xl and full. On mobile every size stays full width minus a 1rem margin on each side. For a one-off width, keep the className override shown above.
Props
DialogContent adds two props on top of the radix Content props. Every part also forwards its underlying radix props, e.g. open / onOpenChange on Dialog.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | "md" | Caps the panel max width from the sm breakpoint up: sm (sm), md (max-w-lg), lg (2xl), xl (4xl), full (100% minus a 2rem margin each side). On mobile every size stays full width minus a 1rem margin. |
showClose | boolean | true | Renders the X close button in the top-right corner of DialogContent. |
ai2 Dialog: an accessible modal dialog for React
The ai2 Dialog is a shadcn-compatible modal dialog component for React, built on the radix-ui Dialog primitive and styled with Tailwind CSS v4. It handles everything a modal is supposed to handle: rendering in a portal above the page, dimming the background with an overlay, trapping focus inside the content, closing on Escape or an outside click, and returning focus to the trigger when it closes.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Dialog?
It is an eight-part composition: Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription and DialogClose, with DialogOverlay also exported. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working without changes.
DialogContent bundles the portal, the overlay and the panel in one component, adds a built-in X close button you can hide with showClose={false}, caps the panel width through a five-step size prop (sm, md, lg, xl, full), and animates open and close with fade and zoom on the shared ai2 motion tokens.
Why use it
- Focus trap done right: The radix-ui Dialog primitive moves focus into the dialog on open, keeps Tab cycling inside it, and returns focus to the trigger on close. This is the part of modals most hand-rolled versions get wrong.
- Correct ARIA wiring: The content gets role dialog and aria-modal, and DialogTitle and DialogDescription are announced through aria-labelledby and aria-describedby automatically.
- Escape and outside click: Both dismiss the dialog by default, and both are configurable through the radix onEscapeKeyDown and onPointerDownOutside callbacks when a flow must not be abandoned.
- Portal and overlay included: DialogContent renders in a portal above everything with a black/50 overlay, so z-index fights and clipped parents are not your problem.
- Agent-readable metadata: The registry item describes its parts and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, its dependencies and the @ai2/tokens theme to your project.
- Fade and zoom animation: The overlay fades and the panel zooms from 95 percent on open and close, timed by --motion-base from the shared token file.
- Optional close button: The X button in the corner renders by default and disappears with showClose={false}, for flows that must end through an explicit action.
- Responsive footer: DialogFooter stacks actions in reverse column order on small screens and right-aligns them in a row from the sm breakpoint up.
- Controlled and uncontrolled state: Let DialogTrigger manage the open state, or pass open and onOpenChange to drive the dialog from React state, for example after a form submit.
- Data attributes for styling: Every part exposes data-slot, and radix adds data-state on the overlay and content, so open and closed styles are targetable from CSS.
Production tips
- Always render a DialogTitle: Screen readers announce the dialog by its title. If the design has no visible heading, keep a DialogTitle with the sr-only class instead of omitting it.
- Use asChild for triggers: DialogTrigger asChild attaches the dialog to your own Button or any element without nesting two buttons in the DOM.
- Pick a size, or override with className: The size prop caps the panel width from the sm breakpoint up: sm, md (the default), lg, xl and full. On mobile every size spans the viewport minus a 1rem margin on each side. For a one-off width outside the scale, pass className="sm:max-w-2xl" on DialogContent instead.
- Close through DialogClose: Wrap Cancel buttons with DialogClose asChild so dismissal needs no state wiring; reserve controlled mode for closes that follow async work.
- Do not stack modals: A dialog opening another dialog disorients keyboard and screen reader users. Prefer a single dialog with steps, or an inline expansion.
Works with the rest of ai2
Dialogs are usually forms: ai2 Field rows with ai2 Input inside DialogContent and ai2 Button actions in the footer is the standard recipe. The ai2 Command palette builds its CommandDialog on this exact component.
For destructive confirmations that need an explicit choice, use the ai2 Alert Dialog instead; for side-anchored panels like filters or carts, reach for the ai2 Sheet. All three share the same overlay language and token source.