# Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones

> Build a shadcn responsive dialog once: a centered card above 768px, a draggable bottom sheet below it, one copy of the content and no hydration flash.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile

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

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](https://ui.shadcn.com/docs/components/radix/drawer) say "You can combine the Dialog and Drawer components to create a responsive dialog", and the [drawer-dialog example](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/examples/drawer-dialog.tsx) 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`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/hooks/use-media-query.tsx) 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](https://github.com/redpangilinan/credenza) (MIT) and [Dice UI's Responsive Dialog](https://diceui.com/docs/components/radix/responsive-dialog) 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](https://wingo-ui.com/blog/mobile-first-react-components) guide has the right and wrong versions side by side. The free [useMediaQuery](https://wingo-ui.com/components/use-media-query) 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](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) 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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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.

> Live demo (Dialog): 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. Try it at [Dialog](https://wingo-ui.com/components/dialog) and install it with `npx wingo-ui@latest add dialog`.

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](https://wingo-ui.com/components/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](https://wingo-ui.com/pricing).

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

| Component | Built on | Drag to close | From 768px |
| --- | --- | --- | --- |
| [shadcn Drawer](https://ui.shadcn.com/docs/components/radix/drawer) (Radix) | Vaul | Yes | The same drawer |
| [shadcn Sheet](https://ui.shadcn.com/docs/components/radix/sheet) | Radix Dialog | No | A side panel |
| Wingo UI [Dialog](https://wingo-ui.com/components/dialog) | Radix Dialog + Drawer | On phones | A centered card |
| Wingo UI [Drawer](https://wingo-ui.com/components/drawer) | Vaul | Yes | Sheet, side panel or dialog |
| Wingo UI [Sheet](https://wingo-ui.com/components/sheet) | Radix Dialog + motion | On touch screens | A side panel |

On the same date, [Vaul's README](https://github.com/emilkowalski/vaul) says "This repo is unmaintained", and the [Base UI docs of the shadcn Drawer](https://ui.shadcn.com/docs/components/drawer) 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](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web).

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

> Live demo (Sheet): 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. Try it at [Sheet](https://wingo-ui.com/components/sheet) and install it with `npx wingo-ui@latest add sheet`.

## 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](https://developer.apple.com/design/human-interface-guidelines/sheets) 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](https://wingo-ui.com/blog/category/mobile-ui) 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](https://wingo-ui.com/components/dialog) (Free): The modal for focused tasks: a spring-scaled card on desktop that becomes a bottom sheet on phones, with nesting and a scrolling body. Install: `npx wingo-ui@latest add dialog`
- [Drawer](https://wingo-ui.com/components/drawer) (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `npx wingo-ui@latest add drawer`
- [Sheet](https://wingo-ui.com/components/sheet) (Free): 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. Install: `npx wingo-ui@latest add sheet`
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) (Free): SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint. Install: `npx wingo-ui@latest add use-media-query`

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

---

Source: https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile
