DialogDrawer
A centered Dialog at desktop that becomes a swipeable Drawer below 768px.
A drop-in replacement for the shadcn Dialog: at 768px and up it renders your Dialog unchanged; below, the same children render inside your Drawer. The breakpoint and the override prop work the same for every pair, so this page covers only what is specific to this one.
At 768px and up: opens as a Dialog. Resize the window across 768px while it is open: it stays open on the other side.
import {
DialogDrawer,
DialogDrawerContent,
DialogDrawerDescription,
DialogDrawerFooter,
DialogDrawerHeader,
DialogDrawerTitle,
DialogDrawerTrigger,
} from "@/components/ui/dialog-drawer"
export function EditProfile() {
return (
<DialogDrawer>
<DialogDrawerTrigger>Edit profile</DialogDrawerTrigger>
<DialogDrawerContent
className="sm:max-w-sm"
drawer={{ className: "sm:max-w-none" }}
>
<DialogDrawerHeader>
<DialogDrawerTitle>Edit profile</DialogDrawerTitle>
<DialogDrawerDescription>
Change the name shown on your profile.
</DialogDrawerDescription>
</DialogDrawerHeader>
<form className="grid gap-4 p-4 md:p-0">
<label htmlFor="name">Name</label>
<input id="name" defaultValue="Jordan Lee" />
</form>
<DialogDrawerFooter showCloseButton />
</DialogDrawerContent>
</DialogDrawer>
)
}Installation
npx shadcn@latest add @alexandrejoly/dialog-drawerUpdate the import paths to match your project setup.
Migrating
Change the imports and insert Drawer after Dialog in each part name:
DialogContent becomes DialogDrawerContent, and so on, with the same props
and children (see Migrating). Then check
three things below 768px: width classes on the Content (see
Sizing classes), padding around the body (see
Body padding), and the
desktop-only props.
Usage
import {
DialogDrawer,
DialogDrawerClose,
DialogDrawerContent,
DialogDrawerDescription,
DialogDrawerFooter,
DialogDrawerHeader,
DialogDrawerTitle,
DialogDrawerTrigger,
} from "@/components/ui/dialog-drawer"<DialogDrawer>
<DialogDrawerTrigger>Open</DialogDrawerTrigger>
<DialogDrawerContent>
<DialogDrawerHeader>
<DialogDrawerTitle>Title</DialogDrawerTitle>
<DialogDrawerDescription>Description</DialogDrawerDescription>
</DialogDrawerHeader>
<DialogDrawerFooter>
<DialogDrawerClose>Cancel</DialogDrawerClose>
</DialogDrawerFooter>
</DialogDrawerContent>
</DialogDrawer>Composition
DialogDrawer
├── DialogDrawerTrigger
└── DialogDrawerContent
├── DialogDrawerHeader
│ ├── DialogDrawerTitle
│ └── DialogDrawerDescription
└── DialogDrawerFooter
└── DialogDrawerCloseOverriding the Drawer
Everything inside drawer={{...}} is your installed Drawer's own API, for
example snap points on Base UI (for Vaul, see Radix notes):
At 768px and up: a Dialog. The snap points apply below 768px only.
<DialogDrawer
drawer={{
snapPoints: [0.5, 1],
defaultSnapPoint: 0.5,
showSwipeHandle: true,
}}
>
{/* … */}
</DialogDrawer>Sizing classes
The className on DialogDrawerContent applies to both sides. A width such
as sm:max-w-sm, from the shadcn Dialog examples, starts at 640px, below the
768px breakpoint, so it also narrows the drawer on large phones and small
tablets. Reset it in the override with the same sm: prefix, since cn only
replaces a class that has the same prefix, or write the width with md::
At 768px and up: a Dialog capped at sm:max-w-sm.
<DialogDrawerContent
className="sm:max-w-sm"
drawer={{ className: "sm:max-w-none" }}
>Body padding
The Dialog pads its whole content box; the Drawer pads only its header and footer. Give the body between them its own padding below 768px:
At 768px and up: md:p-0 drops the form padding, since the Dialog pads its whole box.
<DialogDrawerContent>
<DialogDrawerHeader>{/* … */}</DialogDrawerHeader>
<form className="grid gap-4 p-4 md:p-0">{/* … */}</form>
<DialogDrawerFooter>{/* … */}</DialogDrawerFooter>
</DialogDrawerContent>Opening on load
On the first render, before the viewport is known, the Dialog renders. A
defaultOpen dialog on a phone therefore shows briefly as a Dialog before it
becomes a Drawer. To open on load without that flash, control open and set
it after mount.
onOpenChange
onOpenChange on the root has your Dialog's signature and also fires below
768px, so onOpenChange={setOpen} works on both sides. Below 768px it can
receive reasons only the Drawer has, such as "swipe", which the Dialog's
types do not list. To act on them, use the Drawer's own handler, which runs
after the shared one:
Closed with a swipe: 0
At 768px and up: the Dialog has no swipe, so the count stays the same.
<DialogDrawer
onOpenChange={setOpen}
drawer={{
onOpenChange: (open, eventDetails) => {
if (eventDetails.reason === "swipe") {
// below 768px only, after the shared handler
}
},
}}
>
{/* … */}
</DialogDrawer>Desktop-only props
These props apply at 768px and up and are dropped below:
showCloseButtonon the Content. A drawer has no close button in the corner; it closes on swipe or on an overlay tap.showCloseButtonon the Footer works on both sides.- Detached triggers on Base UI:
handle,triggerIdanddefaultTriggerIdon the root,handleandpayloadon the Trigger. Below 768px, the drawer opens only from aDialogDrawerTriggerplaced insideDialogDrawer, or from a controlledopen.
A className function on a part receives your Dialog's state at 768px and up
and your Drawer's state below.
Nested dialogs
A DialogDrawer inside another one renders a drawer inside a drawer below
768px. On Base UI, your Drawer styles the parent as a stacked drawer while the
child is open.
API reference
Each part takes every prop of its shadcn Dialog counterpart, plus a
drawer prop that takes the props of the matching Drawer part, except
children: drawer on the Content takes your DrawerContent props, and so
on. Only the parts below leave out more.
DialogDrawer
| Prop | Type | Default |
|---|---|---|
drawer | Omit<ComponentProps<typeof Drawer>, "children" | RootState> | - |
RootState stands for the keys that drawer leaves out because they belong
to the shared root: open and defaultOpen, since both sides share one open
state, and the detached-trigger props handle, triggerId and
defaultTriggerId. drawer.onOpenChange runs after the shared
onOpenChange and receives your Drawer's own arguments.
DialogDrawerTrigger
| Prop | Type | Default |
|---|---|---|
drawer | Omit<ComponentProps<typeof DrawerTrigger>, "children" | "handle" | "payload"> | - |
Radix notes
drawer={{...}}is typed on Vaul:direction,dismissible,handleOnly,snapPoints, theonDragandonReleasecallbacks, and so on. Vaul'sonOpenChangereceives onlyopen, with no reason.- Like Radix Dialog, the drawer moves focus inside when it opens. Pass
drawer={{ autoFocus: false }}to keep Vaul's own default. - Vaul nests drawers through
Drawer.NestedRoot, which your shadcn Drawer does not export: a nestedDialogDraweropens a second drawer on top, without Vaul's nested-drawer styling. - Vaul, the drawer library under the Radix Drawer, is no longer maintained. It still works; the Base UI Drawer is the maintained option.
Learn more
The underlying components are documented upstream: shadcn Dialog and Drawer, built on Base UI Dialog and Base UI Drawer, or on Radix Dialog and Vaul.