Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones
The shadcn responsive dialog is the pattern from the shadcn/ui Drawer docs: a centered Dialog on desktop and a Drawer, a bottom sheet you can drag down, on phones. The recipe works, but it has you write the trigger and the header twice and pick the branch with a hook that guesses on the first render. Both cause bugs once you make a shadcn dialog mobile friendly this way: the copies drift apart, and the branch flips after mount. This tutorial builds one dialog API that renders a card above 768px and a draggable sheet below it, with the content written once and nothing to flash during hydration.
What does the shadcn responsive dialog recipe do?
It renders two complete trees and picks one with a media query. As of October 2026 the shadcn Drawer docs (opens in a new tab) say "You can combine the Dialog and Drawer components to create a responsive dialog", and the drawer-dialog example (opens in a new tab) is shaped like this:
Keeping open above the branch is right: a resize across 768px keeps the dialog open. Three things cost you later:
- Two copies. The trigger, title and description appear in both branches, and the Cancel footer exists only in the Drawer. Every copy change has to land twice.
- A guess on every mount. The example's
useMediaQuery(opens in a new tab) starts withuseState(false)and readsmatchMediain an effect. That is false on the server, which is fine, and also on the first client render of every later mount. On a desktop each mount renders the Drawer tree first and swaps after the effect, so a dialog that mounts already open (from a?edit=1URL, say) starts as the wrong component. - A remount on every flip. Dialog and Drawer are different component types, so a flip unmounts one tree and mounts the other, along with any state inside.
Ready-made wrappers fix the first point. As of October 2026, Credenza (opens in a new tab) (MIT) and Dice UI's Responsive Dialog (opens in a new tab) wrap a dialog and a Vaul drawer in one set of parts. Credenza switches at 768px, and its useIsMobile also starts as false and flips in an effect, so it keeps the second problem. If you want to stay on stock shadcn/ui, use one of them or the wrapper below, and check what its hook returns before the first effect runs.
Where should the switch between dialog and drawer live?
In the root, once. The root reads the breakpoint, owns the open state and tells every part which mode it is in; each part renders the Dialog piece or the Drawer piece. This works because Vaul's Trigger, Close, Title and Description are Radix Dialog's own parts, so the trigger is the same button in both modes.
Here is that responsive dialog drawer wrapper on top of the stock shadcn/ui Dialog and Drawer (Radix versions, type-checked against their source as of October 2026):
The two extra classes matter. DrawerContent has no padding of its own, so the body needs px-4 in sheet mode. DrawerFooter stacks its buttons top down, so without flex-col-reverse the sheet would show Cancel above Save. With it, you write Cancel first and Save last, and Save lands on the right of the card and on top of the sheet. Leave the header alone: the drawer centers it with a group-data-[vaul-drawer-direction=bottom] selector, which outranks a plain text-left. That is also why the className="text-left" in the shadcn example changes nothing.
How do you avoid a hydration flash with useMediaQuery?
Make the server's answer and the first client answer the same, and keep everything visible before the first tap identical in both modes. A closed dialog renders only its trigger, and that trigger is the same Radix button in both trees, so the server can render it without knowing the screen width. The hook just has to agree with the server during hydration and be right on every render after it, which is what useSyncExternalStore does:
React calls the third function on the server and during hydration, so the two match and there is no mismatch warning. Every render after that, including the first render of a dialog that mounts later, calls the second function and gets the real answer, with no effect in between. Phones correct the guess right after hydration: the root swaps Dialog for Drawer under a closed trigger, which changes nothing on screen. If you installed the shadcn Sidebar, hooks/use-mobile.ts already exports a useIsMobile built on useState and an effect; give it this body and the sidebar gets the same fix.
We checked the "mounts later" case on the Wingo UI Dialog, which is built this way. Its playground loads the demo after hydration, so we set the dialog to open on load and sampled the page on every animation frame in Chromium through Playwright at 390px. From the first frame the dialog existed it was the sheet, never the centered card, and the console stayed empty.
What still flashes is visible markup that branches on the hook, such as a form inline on desktop and behind a button on phones. Switch that with CSS breakpoints instead; the mobile-first React components guide has the right and wrong versions side by side. The free useMediaQuery hook is this snippet generalized to any query with a serverFallback parameter, and it exports the same useIsMobile. Our SSR-safe React use mobile hook tutorial compares it with the useState and typeof window versions through a real hydration.
How do you build it with the Wingo UI Dialog?
Install the free Dialog and write it once: it does the switch itself through a responsive prop that defaults to true. The root reads the breakpoint through useSyncExternalStore, as above, and renders a Radix dialog from 768px or the Drawer's bottom sheet below it, and every part (header, title, description, body, footer) renders the matching piece.
1. Install the dialog
The CLI copies components/ui/dialog.tsx and what it imports, such as the Drawer, the media query hook, the Button and the motion helpers. All of them are free.
2. Write the content once
The primary action goes last in the footer. On desktop it lands on the right; on the phone sheet the buttons stack full width at 48px with Save on top.
3. Open it at both widths
Try the playground below on a laptop, then on a phone or with the window narrowed under 768px before you tap.
$ npx wingo-ui@latest add dialogBelow 768px the same JSX changes behavior as well as layout:
- Dismissal. A handle appears, the sheet follows your finger, and letting go past a quarter of its height or flicking it closes it.
- Focus. The desktop card focuses the first field. The sheet focuses itself, so the keyboard does not cover half the sheet before the person picks a field; Tab moves on from the top.
- Scrolling. A long body scrolls first and only drags the sheet once it is back at the top, and the bottom pads the home indicator.
- Size.
size="full"is a 92% tall sheet on phones and a card 16px from every edge on desktop.
classNames apply in both modes, and the sheet's parts keep the data-slot="dialog-*" names, so CSS or tests that target [data-slot=dialog-footer] match the card and the sheet alike.
4. Keep form state above the dialog
Crossing 768px keeps the dialog open but remounts its content, so state that lives inside it resets. We typed into the playground's title field at 1024px and resized the window to 390px: the sheet was open and the field was back to its original value. The shadcn example and the wrapper above behave the same way, because the root changes component type. The real case is a tablet rotated across the breakpoint, such as an iPad mini, which is 744px wide in portrait and 1133px in landscape. The fix is the one in step 2: keep the draft in the component that renders the dialog. Inside the dialog, useDialog() returns { open, setOpen, sheet } if a part needs a different layout on the sheet.
5. Opt out where a sheet is wrong
Pass responsive={false} to keep a centered card on phones, or set it for the whole app with <UIProvider defaults={{ dialog: { responsive: false } }}>. The Alert Dialog never becomes a sheet: on a phone a destructive question stays a centered card with its buttons stacked full width. It is part of Wingo UI Pro.
Shadcn drawer vs sheet: which one should you use?
The Drawer is a bottom sheet with a drag gesture; the Sheet is a panel that slides in from an edge on a CSS animation, with nothing to drag. Both can open from any edge, the Drawer from the bottom by default and the Sheet from the right. Use the Drawer, or a responsive dialog, for tasks and choices on phones, and the Sheet for content that sits beside the page on desktop, such as navigation, filters or a cart. As of October 2026:
On the same date, Vaul's README (opens in a new tab) says "This repo is unmaintained", and the Base UI docs of the shadcn Drawer (opens in a new tab) say it "now uses Base UI instead of Vaul". The Wingo UI Drawer, and with it the Dialog's phone mode, still runs on Vaul 1.1.2. Snap points, nested sheets and the keyboard inside a sheet are covered in our React bottom sheet guide.
The Sheet covers the other common case: a filter panel from the right on desktop that becomes a bottom sheet on phones, through mobileSide="bottom".
$ npx wingo-ui@latest add sheetModal vs bottom sheet: when should a phone still get a centered card?
Use a bottom sheet for tasks and choices someone finishes and returns from, and keep a centered card for a short question that must be answered. Apple's Human Interface Guidelines (opens in a new tab) describe a sheet as "useful for requesting specific information from people or presenting a simple task that they can complete before returning to the parent view", and add: "For complex or prolonged user flows, consider alternatives to sheets." The rules we follow:
- Forms, pickers, filters and share options: a bottom sheet. It opens where the thumb already is and closes with the same motion.
- Destructive confirmations such as "Delete 3 invoices?": a centered alert dialog. A sheet's drag to dismiss makes a question that needs an answer too easy to wave away.
- Long editors and multi-step flows: a full-height sheet (
size="full") or a page of its own.
More patterns like these live in the Mobile UI in React category.
What should you do next?
Install the free Dialog with npx wingo-ui@latest add dialog, open your app at 390px and drag a sheet closed. If you stay on stock shadcn/ui, copy the wrapper and the useSyncExternalStore hook above into your project.
FAQ
How do I make a shadcn dialog responsive on mobile?
Render the Drawer below 768px and the Dialog above it, from one root that owns the open state and parts that pick the matching Drawer or Dialog piece. The shadcn docs show a two-branch version; a context-based wrapper, Credenza, Dice UI or the Wingo UI Dialog keep the content in one place.
What is the difference between the shadcn Drawer and Sheet?
The Drawer is a bottom sheet you can drag to close, built on Vaul in the Radix version and on Base UI in the Base UI version. The Sheet extends the Dialog to slide a panel in from any edge; the Radix version animates with CSS and has no drag gesture.
Why does my responsive dialog show the wrong component for a moment?
Many media query hooks, including the one in the shadcn example, start as false and correct themselves in an effect, so the first render of every mount uses a guess. Read the query with useSyncExternalStore and never branch on it for anything visible before the dialog opens.
At what width should a dialog become a bottom sheet?
768px, Tailwind's md breakpoint, is the usual choice: the shadcn example, Credenza and Wingo UI all switch there. Below it a centered card leaves little room above the on-screen keyboard and puts its buttons far from the thumb.
Is the Wingo UI Dialog free?
Yes. The Dialog, Drawer, Sheet and the useMediaQuery hook are free, and npx wingo-ui@latest add dialog also installs the Drawer it uses below 768px. The Alert Dialog, which stays a centered card on phones, is Pro.
- Mobile UI
- Dialog
- Bottom sheet
- shadcn/ui
- React
- Next.js