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

ComportamentoDialogDrawer
Focus intrappolatoSìSì
Chiusura con EscapeSìSì
Chiusura con click fuoriSìSì, toccando l'overlay
Chiusura con swipeNoSì, verso il basso
Scroll della pagina bloccatoSì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.