React
Drawer on mobile and dialog on desktop, with one component
Drawer (vaul) below 768px, Dialog above it: the choice comes from matchMedia after mount, never during render, or hydration breaks.
Key points
- Drawer and Dialog are different primitives: one closes on a swipe, the other on an outside click.
- The wrapper picks with `matchMedia` inside an effect and starts from `true`, so server and client render the same tree.
- The title stays mandatory in both: without `DialogTitle` or `DrawerTitle` the component has no accessible name.
Short answer: write a wrapper that renders Dialog above 768 pixels and Drawer below, and decide the side with window.matchMedia inside a useEffect. Deciding during render produces a different tree on the server and hydration fails.
Two primitives, one payload
The payload is the same: a panel with a title and some fields. The gesture that closes it changes. The Dialog closes on an outside click or Escape, the Drawer also on a downward drag. On a phone the thumb reaches the bottom of the screen, and a swipe is the gesture the user already knows.
npx shadcn@latest add drawer dialog button
The wrapper that chooses
"use client";
import { useEffect, useState, type ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
function useIsDesktop() {
// true on the server and on the first client render: same tree, no mismatch.
const [isDesktop, setIsDesktop] = useState(true);
useEffect(() => {
const query = window.matchMedia("(min-width: 768px)");
const sync = () => setIsDesktop(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return isDesktop;
}
export function ResponsivePanel({
open,
onOpenChange,
title,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: ReactNode;
}) {
const isDesktop = useIsDesktop();
if (isDesktop) {
return (
{title}
{children}
);
}
return (
{title}
{children}
);
}
The detail that avoids the mismatch is useState(true): server and first client render paint the dialog, then the effect corrects it. No flash is visible because the panel only exists when open, and it usually opens after hydration.
The gesture is not the only difference
| Behaviour | Dialog | Drawer |
|---|---|---|
| Focus trapped | Yes | Yes |
| Escape closes | Yes | Yes |
| Outside click closes | Yes | Yes, by tapping the overlay |
| Swipe closes | No | Yes, downwards |
| Page scroll locked | Yes | Yes |
In both, the title is mandatory: Radix uses DialogTitle as the accessible name, and without it the console warns and the screen reader announces “dialog” without saying which one. The trap is described in why DialogContent requires DialogTitle.
A named limitation
The price is two components to maintain: two sets of styles, two tests and two behaviours to check whenever the payload changes. The CSS-only alternative, showing one and hiding the other with md:hidden, does not work: both mount a portal and a focus trap, and the hidden one still intercepts keys. matchMedia also does not exist during a server render, so every read belongs inside an effect.
Where to continue
In the catalogue you find the shadcn library, a dialog block with preview and the dialog category. For the base pattern read what shadcn/ui is and the mandatory dialog title; the roundup of blocks is the best dialog blocks of 2026. When the dialog closes by itself after opening from a menu, the cause is in a dialog closing from a dropdown; for forms inside the panel read a searchable select.
Frequently asked questions
Why can I not show Drawer and Dialog with responsive classes?
Because both create a portal and a focus trap: the hidden one stays in the DOM and intercepts Escape and Tab. The choice belongs in JavaScript, not in CSS.
Where does the 768 value come from?
It is the Tailwind `md` breakpoint. If your layout changes at 1024, use `(min-width: 1024px)`: the threshold must match the grid, not be a copied number.
The panel starts as a dialog on mobile too. Is that a problem?
For an instant, and only when the panel is open on the first render. If a click opens it, the effect has already corrected the value, so nothing flashes.
Do I have to duplicate the content for the two components?
No. You pass the same `children` and the same title: only the container changes, so the form inside exists once.
The Drawer does not close on Escape. What do I check?
That `onOpenChange` reaches the `Drawer` and that no parent swallows it. The vaul drawer closes on Escape only when focus sits inside the panel.