Shadcn Dialog for React and Tailwind
A modal window that appears on top of the main content.
Installation
bunx --bun shadcn@latest add https://kit.dev/r/dialog.jsonpnpm dlx shadcn@latest add https://kit.dev/r/dialog.jsonnpx shadcn@latest add https://kit.dev/r/dialog.jsonyarn shadcn@latest add https://kit.dev/r/dialog.json<Step>This component depends on Button and Scroll Area. Install them first if you haven't already.</Step>
Install the following dependencies:
bun add @ark-ui/react lucide-react tailwind-variantspnpm add @ark-ui/react lucide-react tailwind-variantsnpm install @ark-ui/react lucide-react tailwind-variantsyarn add @ark-ui/react lucide-react tailwind-variantsCopy and paste the following code into your project.
"use client";
import {
Dialog as ArkDialog,
useDialog as useArkDialog,
useDialogContext as useArkDialogContext,
} from "@ark-ui/react/dialog";
import { ark } from "@ark-ui/react/factory";
import { Portal } from "@ark-ui/react/portal";
import { XIcon } from "lucide-react";
import React from "react";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { usePreviewFrame } from "@/registry/react/lib/preview-frame";
export const useDialog = useArkDialog;
export const useDialogContext = useArkDialogContext;
export const DialogContext = ArkDialog.Context;
interface DialogContextProps {
/**
* Used internally to show or hide overlay
*
* @default true
*/
modal?: boolean;
}
const DialogModalContext = React.createContext({} as DialogContextProps);
export const Dialog = (props: React.ComponentProps<typeof ArkDialog.Root>) => {
const {
modal = true,
lazyMount = true,
unmountOnExit = true,
preventScroll,
...rest
} = props;
const inPreview = usePreviewFrame();
return (
<DialogModalContext.Provider value={{ modal }}>
<ArkDialog.Root
lazyMount={lazyMount}
modal={modal}
preventScroll={preventScroll ?? (inPreview ? false : modal)}
unmountOnExit={unmountOnExit}
{...rest}
/>
</DialogModalContext.Provider>
);
};
export const DialogRootProvider = (
props: React.ComponentProps<typeof ArkDialog.RootProvider>
) => {
const { lazyMount = true, unmountOnExit = true, ...rest } = props;
return (
<DialogModalContext.Provider value={{ modal: true }}>
<ArkDialog.RootProvider
lazyMount={lazyMount}
unmountOnExit={unmountOnExit}
{...rest}
/>
</DialogModalContext.Provider>
);
};
export const DialogTrigger = (
props: React.ComponentProps<typeof ArkDialog.Trigger>
) => <ArkDialog.Trigger data-slot="dialog-trigger" {...props} />;
export const dialogOverlayVariants = tv({
base: [
"fixed inset-0 z-50",
"bg-black/32 backdrop-blur-xs",
"duration-200",
"peer peer-data-[slot=dialog-overlay]:hidden",
"data-[state=open]:fade-in-0 data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=closed]:animate-out",
"motion-reduce:animate-none!",
],
});
export const DialogOverlay = (
props: React.ComponentProps<typeof ArkDialog.Backdrop>
) => {
const { className, ...rest } = props;
const { modal } = _useDialog();
if (!modal) {
return null;
}
return (
<ArkDialog.Backdrop
className={cn(dialogOverlayVariants(), className)}
data-slot="dialog-overlay"
{...rest}
/>
);
};
export const DialogPositioner = (
props: React.ComponentProps<typeof ArkDialog.Positioner>
) => {
const { className, ...rest } = props;
return (
<ArkDialog.Positioner
className={cn(
"fixed inset-0 z-50",
"h-svh w-screen",
"grid grid-rows-[1fr_auto_3fr] justify-items-center",
"p-4",
className
)}
data-slot="dialog-positioner"
{...rest}
/>
);
};
export const dialogContentVariants = tv({
base: [
"[--space:--spacing(6)]",
"z-[calc(50+var(--layer-index,0))]",
"relative",
"row-start-2",
"max-h-[calc(100svh-2rem)] min-h-0 w-full min-w-0",
"flex flex-col overflow-hidden",
"bg-popover",
"text-popover-foreground",
"rounded-2xl border shadow-lg/5",
"outline-none",
"translate-y-[calc(-1.25rem*var(--nested-layer-count))]",
"transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform",
"data-[nested=dialog]:data-[state=closed]:slide-in-from-bottom-10 data-[nested=dialog]:data-[state=open]:slide-in-from-bottom-10 data-[has-nested=dialog]:origin-top",
"scale-[calc(1-0.1*var(--nested-layer-count))] opacity-[calc(1-0.1*var(--nested-layer-count))]",
"data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-[98%] data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[98%] data-[state=open]:animate-in",
"motion-reduce:animate-none! motion-reduce:transition-none!",
],
defaultVariants: {
size: "md",
},
variants: {
bottomStickOnMobile: {
true: [
"max-sm:max-h-[calc(100svh-3rem)]",
"max-sm:max-w-none",
"max-sm:rounded-none max-sm:rounded-t-2xl max-sm:border-x-0 max-sm:border-t max-sm:border-b-0",
"max-sm:opacity-[calc(1-min(var(--nested-dialogs),1))]",
"max-sm:data-[state=closed]:slide-out-to-bottom-5 max-sm:data-[state=open]:slide-in-from-bottom-5",
"max-sm:data-[state=closed]:zoom-out-100 max-sm:data-[state=open]:zoom-in-100",
],
},
size: {
"2xl": ["max-w-3xl"],
"3xl": ["max-w-4xl"],
"4xl": ["max-w-5xl"],
"5xl": ["max-w-6xl"],
"6xl": ["max-w-7xl"],
fullscreen: ["size-full"],
lg: ["max-w-xl"],
md: ["max-w-lg"],
sm: ["max-w-md"],
xl: ["max-w-2xl"],
},
},
});
interface DialogContentProps
extends React.ComponentProps<typeof ArkDialog.Content>,
VariantProps<typeof dialogContentVariants> {
/**
* Stick the dialog to the bottom of the screen on mobile
*
* @default true
*/
bottomStickOnMobile?: boolean;
/**
* Class names on the positioner (the viewport-fixed wrapper).
*/
positionerClassName?: string;
/**
* Show close button at the top right corner
*
* @default true
*/
showCloseButton?: boolean;
}
export const DialogContent = (props: DialogContentProps) => {
const {
showCloseButton = true,
bottomStickOnMobile = true,
positionerClassName,
size = "md",
className,
children,
...rest
} = props;
return (
<Portal>
<DialogOverlay />
<DialogPositioner
className={cn(
bottomStickOnMobile &&
"max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12",
positionerClassName
)}
>
<ArkDialog.Content
className={cn(
dialogContentVariants({ bottomStickOnMobile, size }),
className
)}
data-slot="dialog-content"
{...rest}
>
{children}
{!!showCloseButton && (
<DialogClose asChild>
<Button
aria-label="Close"
className="absolute inset-e-2 top-2 opacity-64 hover:opacity-100"
size="icon-sm"
variant="ghost"
>
<XIcon />
</Button>
</DialogClose>
)}
</ArkDialog.Content>
</DialogPositioner>
</Portal>
);
};
interface DialogBodyProps extends React.ComponentProps<typeof ark.div> {
/**
* Add a fade effect to the scroll area
*
* @default false
*/
scrollFade?: boolean;
}
export const DialogBody = (props: DialogBodyProps) => {
const { scrollFade = false, className, ...rest } = props;
return (
<ScrollArea className="min-h-0 flex-1" scrollFade={scrollFade}>
<ark.div
className={cn(
"p-(--space)",
"in-[[data-slot=dialog-content]:has([data-slot=dialog-header])]:pt-0",
"in-[[data-slot=dialog-content]:has([data-slot=dialog-footer]:not(.border-t))]:pb-1",
className
)}
data-slot="dialog-body"
{...rest}
/>
</ScrollArea>
);
};
interface DialogHeaderProps extends React.ComponentProps<typeof ark.div> {
/**
* The description of the dialog
*/
description?: string;
/**
* The title of the dialog
*/
title?: string;
}
export const DialogHeader = (props: DialogHeaderProps) => {
const { className, title, description, children, ...rest } = props;
return (
<ark.div
className={cn(
"shrink-0",
"p-(--space)",
"flex flex-col gap-2",
"in-[[data-slot=dialog-content]:has([data-slot=dialog-body])]:pb-3",
className
)}
data-slot="dialog-header"
{...rest}
>
{!!title && <DialogTitle>{title}</DialogTitle>}
{!!description && <DialogDescription>{description}</DialogDescription>}
{!title && typeof children === "string" ? (
<DialogTitle>{children}</DialogTitle>
) : (
children
)}
</ark.div>
);
};
export const DialogTitle = (
props: React.ComponentProps<typeof ArkDialog.Title>
) => {
const { className, ...rest } = props;
return (
<ArkDialog.Title
className={cn(
"font-heading font-semibold text-lg leading-none",
className
)}
data-slot="dialog-title"
{...rest}
/>
);
};
export const DialogDescription = (
props: React.ComponentProps<typeof ArkDialog.Description>
) => {
const { className, ...rest } = props;
return (
<ArkDialog.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="dialog-description"
{...rest}
/>
);
};
export const DialogClose = (
props: React.ComponentProps<typeof ArkDialog.CloseTrigger>
) => <ArkDialog.CloseTrigger data-slot="dialog-close-trigger" {...props} />;
export const DialogFooter = (props: React.ComponentProps<typeof ark.div>) => {
const { className, ...rest } = props;
return (
<ark.div
className={cn(
"shrink-0",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"sm:rounded-b-[calc(var(--radius-2xl)-1px)]",
"px-(--space) py-4",
"bg-muted/48",
"border-t",
className
)}
data-slot="dialog-footer"
{...rest}
/>
);
};
const _useDialog = () => {
const context = React.useContext(DialogModalContext);
if (!context) {
throw new Error("useDialog must be used within a DialogProvider");
}
return context;
};Update the import paths to match your project setup.
Anatomy
Dialog
├── DialogTrigger
└── DialogContent
├── DialogOverlay (built in)
├── DialogPositioner (built in)
├── DialogHeader
│ ├── DialogTitle
│ └── DialogDescription
├── DialogBody
├── DialogFooter
└── DialogCloseUsage
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogBody,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";<Dialog>
<DialogTrigger />
<DialogContent>
<DialogHeader>
<DialogTitle />
<DialogDescription />
</DialogHeader>
<DialogBody>
{/** your content here */}
</DialogBody>
<DialogFooter>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>Controlled
Use open and onOpenChange on the root to control the dialog state.
Title & Description
DialogHeader supports two usage patterns:
Using props
Pass title and description props directly to DialogHeader.
<DialogHeader title="Dialog Title" description="Dialog description" />This approach does not require
DialogTitleorDialogDescriptioncomponents.
Using components
Use DialogTitle and DialogDescription as children for more control.
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>Dialog description</DialogDescription>
</DialogHeader>Root Provider
Use useDialog with DialogRootProvider when you need the dialog API outside the tree.
Size
Use the size prop on DialogContent to control the maximum width of the panel.
Examples
Inside scroll
Use DialogBody to scroll the content area while header and footer stay fixed.
Outside scroll
Make the positioner scroll so the dialog can extend past the viewport.
No Close Button
Use the showCloseButton={false} prop to hide the close button in the top right corner.
Close behavior
Use closeOnInteractOutside and closeOnEscape props to prevent closing on outside click and escape.
Open from Menu
Open a dialog imperatively from a menu item using the onSelect handler.
Non-Modal
Use modal={false} to allow interaction with elements outside the dialog.
Initial focus
Use initialFocusEl to control which element receives focus when the dialog opens.
Final focus
Use finalFocusEl to control which element receives focus when the dialog closes. Defaults to the trigger.
Lazy mount
shadcn.io sets lazyMount and unmountOnExit to true by default. Keep Dialog mounted and control it with open / onOpenChange instead of unmounting the root.
Nested
Nest dialogs. The parent content receives data-has-nested and --nested-layer-count for scale and fade.
Context
Use DialogContext or useDialogContext to read open state.
Multiple triggers
Share one dialog across triggers. Pass value on each DialogTrigger and handle onTriggerValueChange.
Confirmation
Intercept close when there are unsaved changes.
With tooltip
Wrap the trigger in a Tooltip.
Alert dialog
For destructive or critical confirmations, use Alert Dialog (role="alertdialog"). It focuses a safe action and does not close on outside click.
Custom spacing
Use [--space:--spacing("value")] on DialogContent to adjust internal padding.
Default spacing is --spacing(6).
You can use breakpoint utilities to change the internal spacing at different screen sizes.
md:[--space:--spacing(6)] lg:[--space:--spacing(8)]Guides
Close behavior
closeOnEscape={false}— do not close on EscapecloseOnInteractOutside={false}— do not close on outside click- For conditional control, use
onEscapeKeyDownoronInteractOutsideand callevent.preventDefault()
Conditional rendering
Unmounting Dialog when toggling open can break focus, scroll lock, and cleanup. Keep the root mounted and control it with open / onOpenChange. shadcn.io already sets lazyMount and unmountOnExit so portal content leaves the DOM while closed.
Z-index
Stacked and nested dialogs use --layer-index and --nested-layer-count. Overlay and content already offset from z-50.
Dynamic imports
When using lazyMount with React.lazy or Next.js dynamic, wrap the imported tree in Suspense.
API Reference
shadcn.io wraps Ark UI Dialog. Defaults below are shadcn.io values. lazyMount and unmountOnExit default to true (Ark: false). preventScroll follows modal, and is forced off inside docs previews.
asChild merges props onto a single child element.
Dialog
Root. Renders no extra chrome; overlay and positioner are created by DialogContent.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | - | Label when DialogTitle is not rendered. |
closeOnEscape | boolean | true | Close when Escape is pressed. |
closeOnInteractOutside | boolean | true | Close when the outside is clicked. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
defaultTriggerValue | string | - | Uncontrolled initial active trigger value. |
finalFocusEl | () => MaybeElement | - | Element to focus when the dialog closes. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-closed content. activity needs React 19+. |
id | string | - | Unique id for the machine. |
ids | Partial<{ trigger: string | ((value?: string) => string); positioner: string; backdrop: string; content: string; closeTrigger: string; title: string; description: string }> | - | Element ids for composition. |
immediate | boolean | - | Apply presence changes immediately instead of the next frame. |
initialFocusEl | () => MaybeElement | - | Element to focus when the dialog opens. |
lazyMount | boolean | true | Mount content on first open. |
modal | boolean | true | Trap pointer events and hide content behind the dialog. |
onEscapeKeyDown | (event: KeyboardEvent) => void | - | Called when Escape is pressed. |
onExitComplete | () => void | - | Called when the close animation finishes. |
onFocusOutside | (event: FocusOutsideEvent) => void | - | Called when focus moves outside. |
onInteractOutside | (event: InteractOutsideEvent) => void | - | Called on outside interaction. |
onOpenChange | (details: OpenChangeDetails) => void | - | Called when open state changes. OpenChangeDetails is { open: boolean }. |
onPointerDownOutside | (event: PointerDownOutsideEvent) => void | - | Called on pointer down outside. |
onRequestDismiss | (event: LayerDismissEvent) => void | - | Called when a parent layer dismisses this one. |
onTriggerValueChange | (details: TriggerValueChangeDetails) => void | - | Called when the active trigger changes. { value: string }. |
open | boolean | - | Controlled open state. |
persistentElements | (() => Element | null)[] | - | Elements that keep pointer events and do not dismiss the dialog. |
present | boolean | - | Controlled presence. |
preventScroll | boolean | true when modal | Prevent scrolling behind the dialog. Off in docs previews. |
restoreFocus | boolean | - | Restore focus to the previously focused element. |
role | "dialog" | "alertdialog" | "dialog" | Dialog role. Use Alert Dialog for critical confirms. |
skipAnimationOnMount | boolean | false | Skip the initial presence animation. |
trapFocus | boolean | true | Trap focus inside the dialog. |
triggerValue | string | - | Controlled active trigger value. |
unmountOnExit | boolean | true | Unmount content after the close animation. |
DialogTrigger
Opens the dialog. Renders a button.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the trigger. |
value | string | - | Id for this trigger when several share one dialog. |
| Attribute | Description |
|---|---|
data-slot | dialog-trigger |
data-scope | dialog |
data-part | trigger |
data-value | The trigger value |
data-state | "open" or "closed" |
data-current | Present when this trigger is the active one |
DialogContent
Portaled panel. Includes overlay, positioner, and an optional corner close button.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "fullscreen" | "md" | Max width of the panel. |
showCloseButton | boolean | true | Show the top-end close button. |
bottomStickOnMobile | boolean | true | Stick the panel to the bottom of the viewport below sm. |
positionerClassName | string | - | Class names on the viewport-fixed positioner. |
asChild | boolean | false | Merge onto a single child of the panel. |
className | string | - | Class names on the panel. |
| Attribute | Description |
|---|---|
data-slot | dialog-content |
data-scope | dialog |
data-part | content |
data-state | "open" or "closed" |
data-nested | Present when this dialog is nested |
data-has-nested | Present when this dialog has nested dialogs |
| CSS variable | Description |
|---|---|
--space | Internal padding. Default --spacing(6). |
--layer-index | Index in the dismissable layer stack. |
--nested-layer-count | Number of nested dialogs. |
DialogOverlay
Dimmed backdrop. Not rendered when modal={false}. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the overlay. |
| Attribute | Description |
|---|---|
data-slot | dialog-overlay |
data-scope | dialog |
data-part | backdrop |
data-state | "open" or "closed" |
| CSS variable | Description |
|---|---|
--layer-index | Index in the dismissable layer stack |
DialogPositioner
Viewport-fixed wrapper. Usually created by DialogContent. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the positioner. |
| Attribute | Description |
|---|---|
data-slot | dialog-positioner |
data-scope | dialog |
data-part | positioner |
DialogHeader
Header for title and description. Pass title / description or compose DialogTitle and DialogDescription.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | Renders DialogTitle. |
description | string | - | Renders DialogDescription. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the header. |
| Attribute | Description |
|---|---|
data-slot | dialog-header |
DialogTitle
Accessible title. Renders an h2. Required for accessibility (use sr-only to hide it visually).
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the title. |
| Attribute | Description |
|---|---|
data-slot | dialog-title |
data-scope | dialog |
data-part | title |
DialogDescription
Accessible description. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the description. |
| Attribute | Description |
|---|---|
data-slot | dialog-description |
data-scope | dialog |
data-part | description |
DialogBody
Scrollable body. Uses Scroll Area.
| Prop | Type | Default | Description |
|---|---|---|---|
scrollFade | boolean | false | Fade the edges of the scroll area. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the body. |
| Attribute | Description |
|---|---|
data-slot | dialog-body |
DialogFooter
Footer for actions.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the footer. |
| Attribute | Description |
|---|---|
data-slot | dialog-footer |
DialogClose
Closes the dialog. Renders a button.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the close control. |
| Attribute | Description |
|---|---|
data-slot | dialog-close-trigger |
data-scope | dialog |
data-part | close-trigger |
DialogRootProvider
Root alternative that takes the API from useDialog.
| Prop | Type | Default | Description |
|---|---|---|---|
value | UseDialogReturn | required | Return value of useDialog(). |
asChild | boolean | false | Merge onto a single child. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-closed content. |
immediate | boolean | - | Apply presence changes immediately. |
lazyMount | boolean | true | Mount content on first open. |
onExitComplete | () => void | - | Called when the close animation finishes. |
present | boolean | - | Controlled presence. |
skipAnimationOnMount | boolean | false | Skip the initial presence animation. |
unmountOnExit | boolean | true | Unmount after the close animation. |
Pass open, modal, and other machine options to useDialog(), not to DialogRootProvider.
useDialog
Creates the dialog API for DialogRootProvider. Accepts the same options as Dialog except layout-only props.
const dialog = useDialog();
dialog.setOpen(true);DialogContext / useDialogContext
Render-prop or hook access to dialog state. Use inside Dialog or DialogRootProvider.
| Property | Type | Description |
|---|---|---|
open | boolean | Whether the dialog is open. |
setOpen | (open: boolean) => void | Open or close the dialog. |
triggerValue | string | null | Active trigger value. |
setTriggerValue | (value: string | null) => void | Set the active trigger. |
DialogContext children: (context) => ReactNode.
Accessibility
Complies with the Dialog WAI-ARIA design pattern. Always include DialogTitle (visually hidden with sr-only if needed).
Keyboard support
| Key | Description |
|---|---|
Enter | When focus is on the trigger, opens the dialog. |
Tab | Move to the next focusable element in the content. Focus is trapped. |
Shift + Tab | Move to the previous focusable element. Focus is trapped. |
Escape | Close the dialog and move focus to the trigger or finalFocusEl. |