Shadcn Responsive Dialog: Karte am Desktop, Sheet am Handy

Der shadcn responsive dialog ist das Muster aus der Drawer-Doku von shadcn/ui: am Desktop ein zentrierter Dialog, auf dem Handy ein Drawer, also ein Bottom Sheet, das du nach unten ziehen kannst. Das Rezept funktioniert. Aber du schreibst Trigger und Header doppelt, und der Zweig wird von einem Hook gewählt, der beim ersten Render rät. Beides führt zu Bugs, sobald du einen shadcn Dialog mobile-tauglich machst: Die Kopien laufen auseinander, und der Zweig kippt nach dem Mount. Dieses Tutorial baut eine einzige Dialog-API, die ab 768px eine Karte und darunter ein ziehbares Sheet rendert. Den Inhalt schreibst du einmal, und während der Hydration flackert nichts.
Was macht das Rezept für den shadcn responsive dialog?
Es rendert zwei komplette Bäume und wählt einen per Media Query. Stand Oktober 2026 schlägt die Drawer-Doku von shadcn (opens in a new tab) vor, Dialog und Drawer zu einem responsiven Dialog zu kombinieren, und das Beispiel drawer-dialog (opens in a new tab) sieht etwa so aus:
Dass open über dem Zweig liegt, ist richtig: Ein Resize über 768px hinweg lässt den Dialog offen. Drei Dinge kosten dich später:
- Zwei Kopien. Trigger, Titel und Beschreibung stehen in beiden Zweigen, und der Cancel-Footer existiert nur im Drawer. Jede Textänderung musst du an zwei Stellen einpflegen.
- Eine Vermutung bei jedem Mount. Der
useMediaQuery(opens in a new tab) des Beispiels startet mituseState(false)und liestmatchMediain einem Effect. Das ergibt auf dem Server false, was in Ordnung ist, aber auch beim ersten Client-Render jedes späteren Mounts. Am Desktop rendert also jeder Mount zuerst den Drawer-Baum und tauscht ihn nach dem Effect aus. Ein Dialog, der schon offen gemountet wird (etwa über eine URL mit?edit=1), startet als falsche Komponente. - Ein Remount bei jedem Wechsel. Dialog und Drawer sind verschiedene Komponententypen. Ein Wechsel unmountet also den einen Baum und mountet den anderen, samt allem State darin.
Fertige Wrapper lösen den ersten Punkt. Stand Oktober 2026 packen Credenza (opens in a new tab) (MIT) und der Responsive Dialog von Dice UI (opens in a new tab) einen Dialog und einen Vaul-Drawer in ein gemeinsames Set von Teilen. Credenza schaltet bei 768px um, und auch sein useIsMobile startet mit false und kippt in einem Effect. Das zweite Problem bleibt also. Wenn du beim Standard-shadcn/ui bleiben willst, nimm einen davon oder den Wrapper unten, und prüf, was sein Hook zurückgibt, bevor der erste Effect läuft.
Wo sollte die Umschaltung zwischen Dialog und Drawer sitzen?
In der Root, und nur dort. Die Root liest den Breakpoint, hält den offenen Zustand und sagt jedem Teil, in welchem Modus es ist. Jedes Teil rendert dann das Dialog-Stück oder das Drawer-Stück. Das klappt, weil Trigger, Close, Title und Description von Vaul die Teile von Radix Dialog selbst sind. Der Trigger ist also in beiden Modi derselbe Button.
Hier ist dieser Wrapper für einen responsive dialog drawer auf Basis von Dialog und Drawer aus dem Standard-shadcn/ui (Radix-Versionen, Stand Oktober 2026 gegen ihren Quellcode typgeprüft):
Die beiden zusätzlichen Klassen sind wichtig. DrawerContent hat kein eigenes Padding, also braucht der Body im Sheet-Modus px-4. DrawerFooter stapelt seine Buttons von oben nach unten. Ohne flex-col-reverse stünde im Sheet Abbrechen über Speichern. Mit der Klasse schreibst du Abbrechen zuerst und Speichern zuletzt, und Speichern landet in der Karte rechts und im Sheet oben. Den Header lässt du in Ruhe: Der Drawer zentriert ihn mit einem Selektor group-data-[vaul-drawer-direction=bottom], der ein einfaches text-left überstimmt. Deshalb ändert auch das className="text-left" im shadcn-Beispiel nichts.
Wie vermeidest du ein Hydration-Flackern mit useMediaQuery?
Sorg dafür, dass die Antwort des Servers und die erste Antwort des Clients gleich sind, und halte alles, was vor dem ersten Antippen sichtbar ist, in beiden Modi identisch. Ein geschlossener Dialog rendert nur seinen Trigger, und dieser Trigger ist in beiden Bäumen derselbe Radix-Button. Der Server kann ihn also rendern, ohne die Bildschirmbreite zu kennen. Der Hook muss während der Hydration nur mit dem Server übereinstimmen und bei jedem Render danach stimmen. Genau das macht useSyncExternalStore:
React ruft die dritte Funktion auf dem Server und während der Hydration auf. Beide stimmen also überein, und es gibt keine Mismatch-Warnung. Jeder Render danach, auch der erste Render eines Dialogs, der später gemountet wird, ruft die zweite Funktion auf und bekommt die echte Antwort, ohne Effect dazwischen. Handys korrigieren die Vermutung direkt nach der Hydration: Die Root tauscht Dialog gegen Drawer unter einem geschlossenen Trigger, und auf dem Bildschirm ändert sich nichts. Wenn du die Sidebar von shadcn installiert hast, exportiert hooks/use-mobile.ts bereits ein useIsMobile mit useState und einem Effect. Gib ihm diesen Funktionsrumpf, und die Sidebar bekommt denselben Fix.
Den Fall „wird später gemountet“ haben wir am Dialog von Wingo UI geprüft, der genau so gebaut ist. Sein Playground lädt die Demo nach der Hydration. Wir haben den Dialog also beim Laden öffnen lassen und die Seite in Chromium über Playwright bei 390px in jedem Animation Frame abgetastet. Ab dem ersten Frame, in dem der Dialog existierte, war er das Sheet, nie die zentrierte Karte, und die Konsole blieb leer.
Was weiterhin flackert, ist sichtbares Markup, das anhand des Hooks verzweigt, etwa ein Formular, das am Desktop inline steht und auf dem Handy hinter einem Button. Schalte so etwas mit CSS-Breakpoints um. Der Guide zu Mobile-first-React-Komponenten zeigt die richtige und die falsche Version nebeneinander. Der kostenlose Hook useMediaQuery ist dieses Snippet, verallgemeinert auf jede Query, mit einem Parameter serverFallback, und er exportiert dasselbe useIsMobile. Unser Tutorial zum SSR-sicheren React use mobile Hook vergleicht ihn in einer echten Hydration mit den Versionen mit useState und typeof window.
Wie baust du das mit dem Dialog von Wingo UI?
Installier den kostenlosen Dialog und schreib ihn einmal: Er erledigt die Umschaltung selbst über die Prop responsive, die standardmäßig true ist. Die Root liest den Breakpoint wie oben über useSyncExternalStore und rendert ab 768px einen Radix-Dialog und darunter das Bottom Sheet aus dem Drawer. Jedes Teil (Header, Titel, Beschreibung, Body, Footer) rendert das passende Stück.
1. Den Dialog installieren
Die CLI kopiert components/ui/dialog.tsx und alles, was die Datei importiert, etwa den Drawer, den Media-Query-Hook, den Button und die Motion-Helfer. Alles davon ist kostenlos.
2. Den Inhalt einmal schreiben
Die Hauptaktion kommt zuletzt in den Footer. Am Desktop landet sie rechts; im Sheet auf dem Handy stapeln sich die Buttons in voller Breite mit 48px Höhe, Speichern oben.
3. Bei beiden Breiten öffnen
Probier den Playground unten erst am Laptop aus, dann auf dem Handy oder in einem Fenster, das du vor dem Antippen auf unter 768px verkleinerst.
$ npx wingo-ui@latest add dialogUnter 768px ändert dasselbe JSX neben dem Layout auch das Verhalten:
- Schließen. Ein Griff erscheint, das Sheet folgt deinem Finger, und es schließt sich, wenn du es weiter als ein Viertel seiner Höhe ziehst und loslässt oder schnell nach unten wischst.
- Fokus. Die Karte am Desktop fokussiert das erste Feld. Das Sheet fokussiert sich selbst, damit die Tastatur nicht das halbe Sheet verdeckt, bevor die Person ein Feld wählt; Tab geht von oben weiter.
- Scrollen. Ein langer Body scrollt zuerst und zieht das Sheet erst, wenn er wieder oben ist, und unten bleibt Platz für den Home Indicator.
- Größe.
size="full"ist auf dem Handy ein Sheet mit 92% Höhe und am Desktop eine Karte mit 16px Abstand zu jedem Rand.
classNames gelten in beiden Modi, und die Teile des Sheets behalten die Namen data-slot="dialog-*". CSS oder Tests, die auf [data-slot=dialog-footer] zielen, treffen also Karte und Sheet gleichermaßen.
4. Formular-State über dem Dialog halten
Beim Überschreiten von 768px bleibt der Dialog offen, aber sein Inhalt wird neu gemountet, und State darin wird zurückgesetzt. Wir haben bei 1024px in das Titelfeld des Playgrounds getippt und das Fenster auf 390px verkleinert: Das Sheet war offen, und das Feld stand wieder auf seinem ursprünglichen Wert. Das shadcn-Beispiel und der Wrapper oben verhalten sich genauso, weil die Root den Komponententyp wechselt. In der Praxis passiert das bei einem Tablet, das über den Breakpoint hinweg gedreht wird, etwa ein iPad mini mit 744px Breite im Hochformat und 1133px im Querformat. Der Fix ist der aus Schritt 2: Halte den Entwurf in der Komponente, die den Dialog rendert. Innerhalb des Dialogs liefert dir useDialog() das Objekt { open, setOpen, sheet }, falls ein Teil im Sheet ein anderes Layout braucht.
5. Abschalten, wo ein Sheet nicht passt
Übergib responsive={false}, um auf dem Handy eine zentrierte Karte zu behalten, oder setz es für die ganze App mit <UIProvider defaults={{ dialog: { responsive: false } }}>. Der Alert Dialog wird nie zum Sheet: Auf dem Handy bleibt eine destruktive Frage eine zentrierte Karte mit Buttons in voller Breite übereinander. Er gehört zu Wingo UI Pro.
Shadcn drawer vs sheet: Was solltest du nehmen?
Der Drawer ist ein Bottom Sheet mit Ziehgeste; das Sheet ist ein Panel, das mit einer CSS-Animation von einer Kante hereingleitet, ohne Ziehgeste. Beide können von jeder Kante aus öffnen, der Drawer standardmäßig von unten und das Sheet von rechts. Nimm den Drawer oder einen responsiven Dialog für Aufgaben und Auswahlen auf dem Handy, und das Sheet für Inhalte, die am Desktop neben der Seite stehen, etwa Navigation, Filter oder einen Warenkorb. Stand Oktober 2026:
Zum selben Zeitpunkt steht im README von Vaul (opens in a new tab), dass das Repo nicht mehr gepflegt wird, und laut der Base-UI-Doku des shadcn Drawer (opens in a new tab) nutzt er inzwischen Base UI statt Vaul. Der Drawer von Wingo UI und damit der Handy-Modus des Dialogs laufen weiterhin auf Vaul 1.1.2. Snap Points, verschachtelte Sheets und die Tastatur in einem Sheet behandelt unser Guide zum React Bottom Sheet.
Das Sheet deckt den anderen häufigen Fall ab: ein Filter-Panel, das am Desktop von rechts kommt und auf dem Handy über mobileSide="bottom" zum Bottom Sheet wird.
$ npx wingo-ui@latest add sheetModal vs bottom sheet: Wann sollte ein Handy trotzdem eine zentrierte Karte bekommen?
Nimm ein Bottom Sheet für Aufgaben und Auswahlen, die man abschließt, um dann zur Seite zurückzukehren, und behalte eine zentrierte Karte für eine kurze Frage, die beantwortet werden muss. Die Human Interface Guidelines von Apple (opens in a new tab) beschreiben ein Sheet als nützlich, um gezielt Informationen abzufragen oder eine einfache Aufgabe zu zeigen, nach der man zur Ausgangsansicht zurückkehrt. Für komplexe oder lange Abläufe raten sie, Alternativen zu Sheets zu prüfen. Unsere Regeln:
- Formulare, Picker, Filter und Teilen-Optionen: ein Bottom Sheet. Es öffnet sich dort, wo der Daumen schon ist, und schließt mit derselben Bewegung.
- Destruktive Bestätigungen wie „3 Rechnungen löschen?“: ein zentrierter Alert Dialog. Durch das Wegziehen eines Sheets lässt sich eine Frage, die eine Antwort braucht, zu leicht beiseiteschieben.
- Lange Editoren und mehrstufige Abläufe: ein Sheet in voller Höhe (
size="full") oder eine eigene Seite.
Weitere Muster dieser Art findest du in der Kategorie Mobile UI in React.
Was solltest du als Nächstes tun?
Installier den kostenlosen Dialog mit npx wingo-ui@latest add dialog, öffne deine App bei 390px und zieh ein Sheet zu. Wenn du beim Standard-shadcn/ui bleibst, kopier den Wrapper und den Hook mit useSyncExternalStore von oben in dein Projekt.
Häufige Fragen
Wie mache ich einen shadcn Dialog auf dem Handy responsive?
Render unter 768px den Drawer und darüber den Dialog, aus einer Root, die den offenen Zustand hält, und mit Teilen, die das passende Drawer- oder Dialog-Stück wählen. Die shadcn-Doku zeigt eine Version mit zwei Zweigen; ein Wrapper mit Context, Credenza, Dice UI oder der Dialog von Wingo UI halten den Inhalt an einer Stelle.
Was ist der Unterschied zwischen dem shadcn Drawer und dem Sheet?
Der Drawer ist ein Bottom Sheet, das du zum Schließen ziehen kannst. In der Radix-Version basiert er auf Vaul, in der Base-UI-Version auf Base UI. Das Sheet erweitert den Dialog um ein Panel, das von einer beliebigen Kante hereingleitet; die Radix-Version animiert mit CSS und hat keine Ziehgeste.
Warum zeigt mein responsiver Dialog kurz die falsche Komponente?
Viele Media-Query-Hooks, auch der im shadcn-Beispiel, starten mit false und korrigieren sich in einem Effect. Der erste Render jedes Mounts arbeitet also mit einer Vermutung. Lies die Query mit useSyncExternalStore und verzweige nie darauf für etwas, das vor dem Öffnen des Dialogs sichtbar ist.
Ab welcher Breite sollte ein Dialog zum Bottom Sheet werden?
768px, der md-Breakpoint von Tailwind, ist die übliche Wahl: Das shadcn-Beispiel, Credenza und Wingo UI schalten alle dort um. Darunter lässt eine zentrierte Karte wenig Platz über der Bildschirmtastatur und setzt ihre Buttons weit weg vom Daumen.
Ist der Dialog von Wingo UI kostenlos?
Ja. Dialog, Drawer, Sheet und der Hook useMediaQuery sind kostenlos, und npx wingo-ui@latest add dialog installiert auch den Drawer, den er unter 768px nutzt. Der Alert Dialog, der auf dem Handy eine zentrierte Karte bleibt, gehört zu Pro.
- Mobile UI
- Dialog
- Bottom Sheet
- shadcn/ui
- React
- Next.js


