# Shadcn Impl Responsive Dialog Drawer

> Use when building a modal surface that must look correct on both desktop and mobile in shadcn ui, when the design calls for a centered Dialog on large screens but a bottom-sheet Drawer on phones, when the user reports that the current Dialog feels cramped on a phone or that the on-screen keyboard covers the form fields, when wiring a delete-confirmation, edit-profile, filter-panel, share-sheet, or settings modal that must respect platform conventions (native swipe-down dismiss on iOS / Android), when building a responsive Combobox where the dropdown must become a full-height Drawer on mobile, when porting an existing Dialog to also work well on touch devices, or when the user asks "how do I make a responsive modal". Prevents the recurring failure modes of an ad-hoc responsive modal: rendering BOTH the Dialog and the Drawer simultaneously so two open surfaces appear at once and the ARIA tree has duplicate role=dialog nodes, calling useMediaQuery without an SSR guard so Next.js logs a hydration mismatch on ever

- Skill: `impertio-studio/shadcn-impl-responsive-dialog-drawer` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/shadcn-impl-responsive-dialog-drawer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/shadcn-impl-responsive-dialog-drawer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: Impertio-Studio (https://skillmd.com/u/impertio-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/impertio-studio/shadcn-impl-responsive-dialog-drawer

---


# shadcn ui: responsive Dialog (desktop) + Drawer (mobile)

This skill is the IMPLEMENTATION recipe for the most-requested compositional pattern in shadcn ui: a single modal surface that renders as a centered `Dialog` on desktop and as a bottom-sheet `Drawer` on mobile. The pattern comes directly from the official `drawer-dialog` example in the shadcn ui repository.

ALWAYS read `shadcn-syntax-dialog` first if the Dialog subcomponent tree (`DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`, `DialogClose`) is unclear.

ALWAYS read `shadcn-syntax-drawer` first if the Drawer subcomponent tree, the `direction` prop, or the Vaul-specific semantics (snap points, dismissible, repositionInputs) are unclear.

## Quick Reference

### The pattern in five lines

```tsx
"use client"
const [open, setOpen] = React.useState(false)
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) return <Dialog open={open} onOpenChange={setOpen}>...<Content />...</Dialog>
return <Drawer open={open} onOpenChange={setOpen}>...<Content />...</Drawer>
```

Five non-negotiable invariants:
1. ONE `useState` pair for the open flag. Dialog and Drawer share it.
2. ONE `useMediaQuery` hook to pick the surface. NEVER hard-code the breakpoint inline.
3. ONE shared content component (`<ProfileForm />`, `<DeleteForm />`, etc). Same props on both branches.
4. NEVER render both Dialog and Drawer at the same time. Use `if (isDesktop) return ...` (early return), NOT both branches mounted.
5. ALWAYS include `DialogTitle` + `DialogDescription` on the desktop branch and `DrawerTitle` + `DrawerDescription` on the mobile branch. Both Radix Dialog and Vaul require a labelled title for screen readers.

### Files you generate

```
src/
├── hooks/
│   └── use-media-query.ts          # the SSR-safe useMediaQuery hook
└── components/
    ├── ui/
    │   ├── dialog.tsx              # shadcn add dialog
    │   └── drawer.tsx              # shadcn add drawer
    └── responsive-modal.tsx        # the orchestrator (optional but recommended)
```

## Decision Tree: Dialog only, Drawer only, or responsive?

```
Is the modal ALWAYS modal (blocking, must-acknowledge)?
├─ YES, AND target is form-factor-agnostic (edit profile, delete confirm, filters)
│   └─ USE responsive Dialog + Drawer (this skill)
├─ YES, AND target is desktop-only admin UI
│   └─ USE Dialog only (see shadcn-syntax-dialog)
└─ NO, it is a mobile-first action sheet (share, sort, add-to-cart)
    └─ USE Drawer only on all viewports (see shadcn-syntax-drawer)
```

When in doubt: responsive. The official example uses it for an "Edit Profile" form, which is the canonical case.

## Step 1: Install the useMediaQuery hook

shadcn ui itself does NOT ship a `useMediaQuery` hook in `components/ui/`. You write it once into `src/hooks/use-media-query.ts`. The official shadcn ui example imports from `@/hooks/use-media-query`, which means the project owner authored it.

ALWAYS use the `useSyncExternalStore` variant (React 18+). NEVER use a `useState` + `useEffect` variant in a Next.js App Router project, because the initial render runs server-side, where `window` does not exist, and a `useEffect` variant produces a hydration mismatch on the very first paint.

Full implementation in `references/examples.md`. Signature:

```ts
function useMediaQuery(query: string): boolean
```

Returns `true` when the query matches, `false` otherwise. On the server, `useSyncExternalStore` falls back to the `getServerSnapshot` slot, which in this hook throws. shadcn ui's official example calls the hook from inside a `"use client"` component, so the server never executes it.

Breakpoint choice: `(min-width: 768px)` matches Tailwind's `md:` breakpoint. This is what the official shadcn `drawer-dialog` example uses and what this skill standardizes on. If a project uses a different design system breakpoint, pass it as an argument to a `ResponsiveModal` prop. NEVER copy the literal `768px` across many components.

## Step 2: Write the shared content component

The single most important design rule of the pattern: ONE content component renders inside BOTH surfaces. NEVER duplicate the form, body, or action layout per branch. NEVER show fewer fields on mobile and more on desktop, because a viewport rotation mid-edit then silently drops the hidden state.

The shared component MUST accept `className` (because Drawer often needs `px-4` padding that Dialog already supplies via `DialogContent`'s default padding) and MAY accept callbacks like `onSubmit` that close the modal.

```tsx
function ProfileForm({ className, onDone }: {
  className?: string
  onDone?: () => void
}) {
  return (
    <form className={cn("grid items-start gap-6", className)} onSubmit={...}>
      ...inputs...
      <Button type="submit">Save changes</Button>
    </form>
  )
}
```

Full example with all imports in `references/examples.md`.

## Step 3: Wire the conditional render

The canonical shape from the official shadcn ui example, with the responsive branching kept inside ONE component:

```tsx
"use client"

import * as React from "react"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Button } from "@/components/ui/button"
import {
  Dialog, DialogContent, DialogDescription, DialogHeader,
  DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer, DrawerClose, DrawerContent, DrawerDescription,
  DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,
} from "@/components/ui/drawer"

export function EditProfileModal() {
  const [open, setOpen] = React.useState(false)
  const isDesktop = useMediaQuery("(min-width: 768px)")

  if (isDesktop) {
    return (
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogTrigger asChild>
          <Button variant="outline">Edit Profile</Button>
        </DialogTrigger>
        <DialogContent className="sm:max-w-[425px]">
          <DialogHeader>
            <DialogTitle>Edit profile</DialogTitle>
            <DialogDescription>
              Make changes to your profile here. Click save when done.
            </DialogDescription>
          </DialogHeader>
          <ProfileForm onDone={() => setOpen(false)} />
        </DialogContent>
      </Dialog>
    )
  }

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerTrigger asChild>
        <Button variant="outline">Edit Profile</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader className="text-left">
          <DrawerTitle>Edit profile</DrawerTitle>
          <DrawerDescription>
            Make changes to your profile here. Click save when done.
          </DrawerDescription>
        </DrawerHeader>
        <ProfileForm className="px-4" onDone={() => setOpen(false)} />
        <DrawerFooter className="pt-2">
          <DrawerClose asChild>
            <Button variant="outline">Cancel</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

Note four details from the official example:
- `DialogContent` carries `className="sm:max-w-[425px]"` so it does not stretch to the full Dialog max-width.
- `DrawerHeader` carries `className="text-left"` because the default `DrawerHeader` is centered (mobile bottom sheet convention) but a form header reads better left-aligned.
- `ProfileForm` carries `className="px-4"` only inside the Drawer, because `DialogContent` already pads its content while `DrawerContent` does not pad horizontally.
- `DrawerFooter` contains a `DrawerClose asChild` Cancel button. The Dialog branch does not include a Cancel button because Dialog ships a built-in close affordance (top-right X) via `DialogContent`'s `showCloseButton` default. The Drawer does not.

## Step 4: Extract the orchestrator (`ResponsiveModal`)

After two or three usages of the pattern, the if-else branching duplicates. Extract a `ResponsiveModal` orchestrator that hides the branch from feature code. ALWAYS forward `open`, `onOpenChange`, `title`, `description`, and `children` so the orchestrator stays content-agnostic.

```tsx
// components/responsive-modal.tsx
"use client"

import * as React from "react"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"

type ResponsiveModalProps = {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  description?: string
  trigger?: React.ReactNode
  children: React.ReactNode
  breakpoint?: string
}

export function ResponsiveModal({
  open, onOpenChange, title, description, trigger, children,
  breakpoint = "(min-width: 768px)",
}: ResponsiveModalProps) {
  const isDesktop = useMediaQuery(breakpoint)
  if (isDesktop) {
    return (
      <Dialog open={open} onOpenChange={onOpenChange}>
        {trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
        <DialogContent className="sm:max-w-[425px]">
          <DialogHeader>
            <DialogTitle>{title}</DialogTitle>
            {description ? <DialogDescription>{description}</DialogDescription> : null}
          </DialogHeader>
          {children}
        </DialogContent>
      </Dialog>
    )
  }
  return (
    <Drawer open={open} onOpenChange={onOpenChange}>
      {trigger ? <DrawerTrigger asChild>{trigger}</DrawerTrigger> : null}
      <DrawerContent>
        <DrawerHeader className="text-left">
          <DrawerTitle>{title}</DrawerTitle>
          {description ? <DrawerDescription>{description}</DrawerDescription> : null}
        </DrawerHeader>
        {children}
      </DrawerContent>
    </Drawer>
  )
}
```

Feature code then reads cleanly:

```tsx
<ResponsiveModal
  open={open}
  onOpenChange={setOpen}
  title="Edit profile"
  description="Make changes to your profile here."
  trigger={<Button variant="outline">Edit Profile</Button>}
>
  <ProfileForm className="md:px-0 px-4" onDone={() => setOpen(false)} />
</ResponsiveModal>
```

The orchestrator deliberately does NOT include a `DrawerFooter` + `DrawerClose` block, because the Drawer footer is content-specific (Cancel vs. Confirm vs. Apply). Place footer buttons inside `children`. See `references/examples.md` for a `DeleteConfirmation` variant that adds an explicit destructive-action footer.

## Step 5: Breakpoint choice rationale

`(min-width: 768px)` maps to Tailwind's `md:` and matches the official shadcn `drawer-dialog` example. The rationale:
- iPhone 14 Pro Max landscape is 932px wide so it lands on the Dialog branch in landscape; the Drawer is reserved for portrait phones, which is where the bottom-sheet ergonomics matter (thumb reach).
- iPad Mini portrait is 768px so it lands on the boundary; the example uses `min-width: 768px`, which includes 768px exactly, so iPad Mini portrait gets the Dialog.
- Smaller tablets (768px landscape but narrower in portrait) follow the same logic.

If the design system uses a different breakpoint (some teams prefer 640px to match Tailwind `sm:`, others 1024px to match `lg:` because they consider iPad-sized devices "mobile"), pass it via the `ResponsiveModal` `breakpoint` prop. NEVER fork the hook per call site.

## Anti-patterns

NEVER render both `<Dialog>` and `<Drawer>` and toggle them via CSS (`hidden md:block` + `block md:hidden`). Both Radix Dialog and Vaul mount portal nodes with `role="dialog"` and `aria-modal="true"`; rendering both creates a duplicated ARIA tree, focus-trap collisions, and `Title must be inside Dialog.Root` warnings from Radix when Vaul portals into a Dialog-managed focus zone. Use the early-return pattern instead.

Full list of seven failure modes with concrete examples in `references/anti-patterns.md`.

## Reference Files

- `references/methods.md`: `useMediaQuery` signature, `ResponsiveModal` props contract, shared-content-component contract.
- `references/examples.md`: SSR-safe `useMediaQuery` source, shared `FormContent`, `ResponsiveModal` orchestrator, full delete-confirmation flow, responsive Combobox (Popover-on-desktop / Drawer-on-mobile).
- `references/anti-patterns.md`: seven recurring failure modes with the wrong-then-right code for each.

## Companion Skills

- `shadcn-syntax-dialog` (Batch B4): Dialog subcomponent tree, `showCloseButton`, `DialogTitle` / `DialogDescription` a11y requirements, controlled `open` / `onOpenChange`.
- `shadcn-syntax-drawer` (Batch B5): Drawer subcomponent tree, Vaul-specific props (`direction`, `snapPoints`, `fadeFromIndex`, `dismissible`, `modal`, `repositionInputs`), bottom-sheet conventions.
- `shadcn-impl-form-validation`: when the responsive modal hosts a form (90% of cases), the form-state, submit, and `setError` patterns live here.
- `shadcn-impl-rsc-vs-client-boundaries`: the responsive modal MUST live in a `"use client"` boundary because `useMediaQuery` reads `window.matchMedia`. The orchestrator file itself carries `"use client"`; pages importing it stay server components.

## Sources

- Official responsive Dialog/Drawer example: `apps/v4/registry/new-york-v4/examples/drawer-dialog.tsx` in `shadcn-ui/ui` (verified 2026-05-19).
- shadcn ui Drawer docs: https://ui.shadcn.com/docs/components/radix/drawer (verified 2026-05-19).
- shadcn ui Dialog docs: https://ui.shadcn.com/docs/components/radix/dialog (verified 2026-05-19).
- `useMediaQuery` with `useSyncExternalStore`: https://github.com/uidotdev/usehooks (verified 2026-05-19).
- Vaul Drawer primitive: https://github.com/emilkowalski/vaul.

