A modal for a focused task or decision. Traps focus, closes on Escape, and scrolls when tall.
pnpm dlx shadcn@latest add @latheui/dialogFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { fade, spring, useMotionSafe } from "@/lib/motion"
// Spread rather than written as an attribute: in Base UI projects the shadcn CLI
// rewrites a literal `asChild` into a `render` prop, and these are Radix parts.
const slot = { asChild: true } as const
const OpenContext = React.createContext(false)
export type DialogProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Root>,
"open" | "defaultOpen" | "onOpenChange"
> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
function Dialog({ open: openProp, defaultOpen = false, onOpenChange, ...props }: DialogProps) {
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
return (
<OpenContext.Provider value={open}>
<DialogPrimitive.Root open={open} onOpenChange={setOpen} {...props} />
</OpenContext.Provider>
)
}
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} />
}
export type DialogContentProps = React.ComponentProps<typeof DialogPrimitive.Content> & {
/** Hide the corner close button, for example when the footer has a cancel action. */
hideClose?: boolean
}
function DialogContent({ className, children, hideClose = false, ...props }: DialogContentProps) {
const open = React.useContext(OpenContext)
const { reduced, safe } = useMotionSafe()
return (
<AnimatePresence>
{open && (
<DialogPrimitive.Portal forceMount>
<DialogPrimitive.Overlay {...slot} forceMount>
<motion.div
data-slot="dialog-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fade}
// The overlay scrolls, so a dialog taller than the viewport stays reachable.
className="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/40 p-4 dark:bg-black/60"
>
<DialogPrimitive.Content {...slot} forceMount {...props}>
<motion.div
data-slot="dialog-content"
initial={{ opacity: 0, scale: reduced ? 1 : 0.96, y: reduced ? 0 : 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.97, transition: fade }}
transition={safe(spring.smooth)}
className={cn(
"relative flex w-full max-w-md flex-col gap-4 rounded-xl border border-border bg-popover p-6 text-popover-foreground shadow-xl shadow-black/10 outline-none",
className
)}
>
{children}
{!hideClose && (
<DialogPrimitive.Close
aria-label="Close"
className="absolute top-3.5 right-3.5 grid size-8 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-4" />
</DialogPrimitive.Close>
)}
</motion.div>
</DialogPrimitive.Content>
</motion.div>
</DialogPrimitive.Overlay>
</DialogPrimitive.Portal>
)}
</AnimatePresence>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-header" className={cn("flex flex-col gap-1.5 pr-8", className)} {...props} />
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 pt-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-6 font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm leading-6 text-pretty text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
}Depends on
Also accepts Radix Dialog.Root props such as modal.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called when the dialog opens or closes. |
Also accepts Radix Dialog.Content props such as onEscapeKeyDown, onInteractOutside and onOpenAutoFocus.
| Prop | Type | Default | Description |
|---|---|---|---|
| hideClose | boolean | false | Hides the corner close button. |
| className | string | — | Classes for the panel. Set max-w-* to change the width. |
Buttons that open and close the dialog. Use asChild to render your own Button.
Layout and text parts. DialogTitle is required for an accessible name.
<DialogContent className="max-w-2xl">…</DialogContent><DialogContent onInteractOutside={(event) => event.preventDefault()}>…</DialogContent>