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.

Installation

npx shadcn@latest add @alexandrejoly/dialog-drawer

Update 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
        └── DialogDrawerClose

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

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.

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.

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.

Desktop-only props

These props apply at 768px and up and are dropped below:

  • showCloseButton on the Content. A drawer has no close button in the corner; it closes on swipe or on an overlay tap. showCloseButton on the Footer works on both sides.
  • Detached triggers on Base UI: handle, triggerId and defaultTriggerId on the root, handle and payload on the Trigger. Below 768px, the drawer opens only from a DialogDrawerTrigger placed inside DialogDrawer, or from a controlled open.

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

PropTypeDefault
drawerOmit<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

PropTypeDefault
drawerOmit<ComponentProps<typeof DrawerTrigger>, "children" | "handle" | "payload">-

Radix notes

  • drawer={{...}} is typed on Vaul: direction, dismissible, handleOnly, snapPoints, the onDrag and onRelease callbacks, and so on. Vaul's onOpenChange receives only open, 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 nested DialogDrawer opens 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.

On this page