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

> Page: https://ui.eneawork.it/blog/shadcn-drawer-mobile
> Markdown: https://ui.eneawork.it/blog/shadcn-drawer-mobile.md

*2026-11-01*

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.

vaul, la dipendenza del Drawer, è alla versione 1.1.2 nel registro npm, verificata il 1 novembre 2026. Nell'indice dei blocchi pubblicato 37 blocchi hanno «dialog» nel nome e 0 hanno «drawer», contati il 1 novembre 2026: il drawer arriva quasi sempre come variante di un dialog esistente. Il catalogo EneUI misura 111.985 componenti, 1.556 blocchi e 12.808 icone da 1.472 librerie, letti da /api/stats il 1 novembre 2026.

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

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.

## FAQ

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