WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones
  1. Blog
  2. Mobile UI in React
  3. Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones
Mobile UI in React
Mobile UI in React

Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 10 min read

On this page

0%
  1. What does the shadcn responsive dialog recipe do?
  2. Where should the switch between dialog and drawer live?
  3. How do you avoid a hydration flash with useMediaQuery?
  4. How do you build it with the Wingo UI Dialog?
    1. 1. Install the dialog
    2. 2. Write the content once
    3. 3. Open it at both widths
    4. 4. Keep form state above the dialog
    5. 5. Opt out where a sheet is wrong
  5. Shadcn drawer vs sheet: which one should you use?
  6. Modal vs bottom sheet: when should a phone still get a centered card?
  7. What should you do next?
  8. FAQ
    1. How do I make a shadcn dialog responsive on mobile?
    2. What is the difference between the shadcn Drawer and Sheet?
    3. Why does my responsive dialog show the wrong component for a moment?
    4. At what width should a dialog become a bottom sheet?
    5. Is the Wingo UI Dialog free?

TL;DR

A shadcn responsive dialog renders the Dialog above 768px and the Drawer, a bottom sheet you can drag down, below it. Put that switch inside one root component whose parts read the mode from context, so the content is written once and the open state survives a resize. Read the breakpoint with useSyncExternalStore so the server and the hydration render agree, and keep everything visible before the first tap identical in both modes, so nothing can flash.

Published Oct 9, 2026

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:

tsx
const [open, setOpen] = React.useState(false)
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) {
return <Dialog open={open} onOpenChange={setOpen}>{/* trigger, header, form */}</Dialog>
}
return <Drawer open={open} onOpenChange={setOpen}>{/* trigger, header, form, footer */}</Drawer>

Keeping open above the branch is right: a resize across 768px keeps the dialog open. Three things cost you later:

  1. 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.
  2. A guess on every mount. The example's useMediaQuery (opens in a new tab) starts with useState(false) and reads matchMedia in 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=1 URL, say) starts as the wrong component.
  3. 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):

tsx
"use client";
import { createContext, useContext, useState, type ComponentType, type ReactNode } from "react";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { cn } from "@/lib/utils";
const SheetMode = createContext(false);
export function ResponsiveDialog({ children }: { children: ReactNode }) {
const sheet = useIsMobile();
// one open state above both trees, so crossing 768px keeps it open
const [open, setOpen] = useState(false);
return (
<SheetMode.Provider value={sheet}>
{sheet ? (
<Drawer open={open} onOpenChange={setOpen}>{children}</Drawer>
) : (
<Dialog open={open} onOpenChange={setOpen}>{children}</Dialog>
)}
</SheetMode.Provider>
);
}
type PartProps = { asChild?: boolean; className?: string; children?: ReactNode };
// each part renders the dialog piece or the drawer piece, plus the classes the drawer needs
function part(Card: ComponentType<PartProps>, Sheet: ComponentType<PartProps>, sheetClass = "") {
return function ResponsivePart({ className, ...props }: PartProps) {
const sheet = useContext(SheetMode);
const Part = sheet ? Sheet : Card;
return <Part className={cn(sheet && sheetClass, className)} {...props} />;
};
}
const Div = ({ className, children }: PartProps) => <div className={className}>{children}</div>;
export const ResponsiveDialogTrigger = part(DialogTrigger, DrawerTrigger);
export const ResponsiveDialogClose = part(DialogClose, DrawerClose);
export const ResponsiveDialogContent = part(DialogContent, DrawerContent);
export const ResponsiveDialogHeader = part(DialogHeader, DrawerHeader);
export const ResponsiveDialogTitle = part(DialogTitle, DrawerTitle);
export const ResponsiveDialogDescription = part(DialogDescription, DrawerDescription);
// the drawer has no body padding and stacks its footer top down
export const ResponsiveDialogBody = part(Div, Div, "px-4");
export const ResponsiveDialogFooter = part(DialogFooter, DrawerFooter, "flex-col-reverse");

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:

ts
// hooks/use-is-mobile.ts
import { useSyncExternalStore } from "react";
const QUERY = "(max-width: 767.98px)";
function subscribe(onChange: () => void) {
const list = window.matchMedia(QUERY);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
}
export function useIsMobile() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
// the server and the hydration render assume a desktop
() => false,
);
}

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

bash
npx wingo-ui@latest add 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

tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
type Profile = { name: string; email: string };
export function EditProfileDialog({ profile, onSave }: { profile: Profile; onSave: (next: Profile) => Promise<void> }) {
const [open, setOpen] = useState(false);
// the draft lives above the dialog, so crossing 768px while it is open keeps what was typed
const [draft, setDraft] = useState(profile);
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
try {
await onSave(draft);
setOpen(false);
} finally {
setSaving(false);
}
}
return (
<Dialog
open={open}
onOpenChange={setOpen}
trigger={
<Button variant="soft" onClick={() => setDraft(profile)}>
Edit profile
</Button>
}
title="Edit profile"
description="Your name and email show on every invoice."
footer={
<>
<DialogClose asChild>
<Button variant="soft">Cancel</Button>
</DialogClose>
<Button loading={saving} loadingText="Saving" onClick={save}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Input label="Name" autoComplete="name" value={draft.name} onValueChange={(name) => setDraft({ ...draft, name })} />
<Input label="Email" type="email" autoComplete="email" value={draft.email} onValueChange={(email) => setDraft({ ...draft, email })} />
</div>
</Dialog>
);
}

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.

Tap Edit: on a wide screen the card scales in and focuses the first field; below 768px the same dialog opens as a bottom sheet you can drag down to close
$ npx wingo-ui@latest add dialog
FreeDialog docs

Below 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:

ComponentBuilt onDrag to closeFrom 768px
shadcn Drawer (opens in a new tab) (Radix)VaulYesThe same drawer
shadcn Sheet (opens in a new tab)Radix DialogNoA side panel
Wingo UI DialogRadix Dialog + DrawerOn phonesA centered card
Wingo UI DrawerVaulYesSheet, side panel or dialog
Wingo UI SheetRadix Dialog + motionOn touch screensA side panel

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

Tap Filters: on a wide screen the panel slides in from the right; below 768px it rises from the bottom, and on a touch screen you drag its handle down to close it
$ npx wingo-ui@latest add sheet
FreeSheet docs

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

Components in this post

  • Dialog

    The modal for focused tasks: a spring-scaled card on desktop that becomes a bottom sheet on phones, with nesting and a scrolling body.

    Free
  • Drawer

    The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free
  • Sheet

    A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch.

    Free
  • useMediaQuery

    SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint.

    Free

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Next.js and Tailwind CSS

React Use Mobile Hook: SSR-Safe, No Hydration Mismatch

Why a React use mobile hook is false on the server, when CSS breakpoints beat it, and an SSR-safe useSyncExternalStore version with no hydration mismatch.

SRSerban Rusu·Oct 9, 2026·9 min read
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

React Bottom Sheet for the Web: Snap Points, Drag, Keyboard

Build a React bottom sheet for the web on vaul: snap points, drag to dismiss, nested sheets, the on-screen keyboard and the home indicator, with working code.

SRSerban Rusu·Oct 9, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI