React
Drawer su mobile e dialog su desktop, con un solo componente
Drawer (vaul) sotto i 768px, Dialog sopra: la scelta si fa con matchMedia dopo il mount, non durante il render, altrimenti l'idratazione si rompe.
In sintesi
- Drawer e Dialog sono primitive diverse: una chiude con lo swipe, l'altra con il click fuori.
- Il wrapper sceglie con `matchMedia` dentro un effect e parte da `true`, così server e client rendono lo stesso albero.
- Il titolo resta obbligatorio in entrambi: senza `DialogTitle` o `DrawerTitle` il componente non ha un nome accessibile.
Risposta breve: scrivi un wrapper che rende Dialog sopra i 768 pixel e Drawer sotto, e decidi il lato con window.matchMedia dentro un useEffect. La scelta durante il render produce un albero diverso sul server e l'idratazione fallisce.
Due primitive, un contenuto
Il contenuto è lo stesso: un pannello con un titolo e dei campi. Cambia il gesto che lo chiude. Il Dialog si chiude cliccando fuori o premendo Escape, il Drawer anche trascinando verso il basso. Su un telefono il pollice arriva in fondo allo schermo, e lo swipe è il gesto che l'utente già conosce.
npx shadcn@latest add drawer dialog button
Il wrapper che sceglie
"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}
);
}
Il dettaglio che evita il mismatch è useState(true): server e primo render client disegnano il dialog, poi l'effect corregge. Il lampo non si vede perché il pannello esiste solo quando è aperto, e di solito si apre dopo l'idratazione.
Il gesto non è l'unica differenza
| Comportamento | Dialog | Drawer |
|---|---|---|
| Focus intrappolato | Sì | Sì |
| Chiusura con Escape | Sì | Sì |
| Chiusura con click fuori | Sì | Sì, toccando l'overlay |
| Chiusura con swipe | No | Sì, verso il basso |
| Scroll della pagina bloccato | Sì | Sì |
In entrambi il titolo è obbligatorio: Radix usa DialogTitle come nome accessibile, e senza di lui la console avverte e il lettore di schermo annuncia «dialog» senza dire quale. La trappola è descritta in perché DialogContent richiede DialogTitle.
Limite dichiarato
Il prezzo è due componenti da mantenere: due set di stili, due test e due comportamenti da verificare a ogni cambio di contenuto. L'alternativa in solo CSS, mostrare l'uno e nascondere l'altro con md:hidden, non funziona: entrambi montano un portal e una trappola di focus, e quello nascosto intercetta comunque i tasti. matchMedia inoltre non esiste durante il render sul server, quindi ogni lettura va dentro un effect.
Dove continuare
Nel catalogo trovi la libreria shadcn, un blocco dialog con anteprima e la categoria dialog. Per il pattern di base leggi che cos'è shadcn/ui e il titolo obbligatorio del dialog; la rassegna dei blocchi è i migliori blocchi dialog del 2026. Se il dialog si chiude da solo quando parte da un menu, la causa è in dialog che si chiude da un dropdown; per i form dentro il pannello leggi select con ricerca.
Domande frequenti
Perché non posso mostrare Drawer e Dialog con le classi responsive?
Perché entrambi creano un portal e una trappola di focus: quello nascosto resta nel DOM e intercetta Escape e Tab. La scelta va fatta nel JavaScript, non nel CSS.
Da dove arriva il valore 768?
È il breakpoint `md` di Tailwind. Se il tuo layout cambia a 1024, usa `(min-width: 1024px)`: la soglia deve coincidere con quella della griglia, non con un numero copiato.
Il pannello parte come dialog anche su mobile: è un problema?
Per un istante, e solo se il pannello è aperto al primo render. Se lo apri con un click, l'effect ha già corretto il valore: nessun lampo visibile.
Devo duplicare il contenuto per i due componenti?
No. Passi lo stesso `children` e lo stesso titolo: cambia solo il contenitore, quindi il form dentro è uno solo.
Il Drawer non si chiude con Escape: cosa controllo?
Che `onOpenChange` sia passato al `Drawer` e che non ci sia un `onOpenChange` ignorato in un componente padre. Il Drawer di vaul chiude con Escape solo se il focus è dentro il pannello.