# React Bottom Sheet fürs Web: Snap Points, Drag, Tastatur

> So baust du ein React Bottom Sheet fürs Web mit vaul: Snap Points, Drag to Dismiss, verschachtelte Sheets, Bildschirmtastatur und Home-Indikator, mit Code.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 9. Okt. 2026
- Kategorie: [Mobile UI mit React](https://wingo-ui.com/de/blog/category/mobile-ui)
- Lesezeit: 9 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/react-bottom-sheet-for-the-web
- Englische Fassung: https://wingo-ui.com/blog/react-bottom-sheet-for-the-web

## Kurz gesagt

Ein React Bottom Sheet fürs Web ist ein ziehbarer Dialog am unteren Bildschirmrand, also baust du es auf einem Dialog-Primitive für das DOM: dem Drawer von Base UI (gepflegt) oder vaul (laut README nicht mehr gepflegt, auf Radix aber immer noch die vollständigste Option). Gib Snap Points als Brüche von 0 bis 1 in einem stabilen Array an, lass den Inhalt erst am letzten Punkt scrollen, heb das Sheet mit der visualViewport API über die Bildschirmtastatur und gib dem Teil am unteren Rand env(safe-area-inset-bottom) als Padding. Der kostenlose Drawer von Wingo UI erledigt all das auf Basis von vaul 1.1.2.

Wer nach einem React Bottom Sheet sucht, landet oft bei React-Native-Paketen wie @gorhom/bottom-sheet, die den Browser nur über React Native Web erreichen. Dieser Guide zeigt, was ein React Bottom Sheet webtauglich macht. Im Web hast du es mit einem echten Dialog im DOM zu tun, mit Pointer Events und `position: fixed`, mit einer Bildschirmtastatur, die den halben Screen verdeckt, und mit einem Home-Indikator unter deinem Speichern-Button. Er behandelt die fünf Teile, die entscheiden, ob sich ein Sheet auf dem Smartphone nativ anfühlt (Snap Points, Drag to Dismiss, verschachtelte Sheets, die Tastatur und die Safe Area), mit Code für vaul und für den kostenlosen [Drawer](https://wingo-ui.com/components/drawer) von Wingo UI, der darauf aufbaut.

## Welche React-Bottom-Sheet-Bibliothek funktioniert im Web?

Für Apps, die ins DOM rendern, gibt es zwei ernsthafte Kandidaten: vaul und den Drawer von Base UI. Beide sitzen auf einem Dialog-Primitive, also bringen sie Fokus-Falle, Escape und den barrierefreien Titel gleich mit. Stand Oktober 2026, laut npm-Registry und der Doku der Projekte:

| Bibliothek | Basis | Snap Points | Verschachtelte Sheets | Letztes Release |
| --- | --- | --- | --- | --- |
| [vaul](https://github.com/emilkowalski/vaul) | Radix Dialog | Brüche oder px-Strings | `Drawer.NestedRoot` | 1.1.2, 14. Dez. 2024 |
| [Base UI Drawer](https://base-ui.com/react/components/drawer) | Base UI Dialog | Brüche, px oder rem | Ja, gestylt über CSS-Variablen | @base-ui/react 1.8.0, 4. Sep. 2026 |
| [react-modal-sheet](https://github.com/temzasse/react-modal-sheet) | Motion | Brüche oder px, inklusive 0 (geschlossen) und 1 | Nicht dokumentiert | 5.6.0, 27. März 2026 |
| [react-spring-bottom-sheet](https://github.com/stipsan/react-spring-bottom-sheet) | react-spring, react-use-gesture | Eine Funktion der minimalen und maximalen Höhe des Inhalts | Nicht dokumentiert | 3.4.1, 1. Juni 2022 |

Drei Fakten, die die Tabelle nicht zeigt, geben den Ausschlag. Das [README von vaul](https://github.com/emilkowalski/vaul) erklärt das Repo für nicht mehr gepflegt, und seit Dezember 2024 kam kein Release mehr; die Peer-Range enthält trotzdem React 19, und der Drawer von Wingo UI läuft darauf mit React 19.3. Die Peer-Range von react-spring-bottom-sheet endet bei React 18. Das [README von react-modal-sheet](https://github.com/temzasse/react-modal-sheet) sagt, dass die Bibliothek keine eingebauten Barrierefreiheits-Eigenschaften mitbringt, also musst du Fokus-Falle und Dialog-Semantik selbst ergänzen.

Wenn dein Projekt schon Base UI nutzt, seit [Juli 2026](https://ui.shadcn.com/docs/changelog/2026-07-base-ui-default) der Standard für neue shadcn/ui-Projekte, nimm dessen Drawer: Er wird gepflegt und deckt Snap Points, Verschachtelung und die Tastatur ab (`Drawer.VirtualKeyboardProvider`). Auf Radix ist vaul immer noch die vollständigste Option, und der Rest dieses Guides behandelt die Lücken, die du drumherum schließt.

## Wie funktionieren vaul Drawer Snap Points?

Vaul Drawer Snap Points sind Haltepunkte auf dem Weg nach oben, aufsteigend sortiert, vom kleinsten bis zum größten sichtbaren Teil: Eine Zahl ist ein Bruchteil der Bildschirmhöhe (`0.45` sind 45%), ein String sind Pixel (`"320px"`). Das Sheet rendert in voller Höhe und wird bis zum aktiven Punkt nach unten verschoben. Drei Details bringen in vaul 1.1.2 viele ins Stolpern:

1. **Brüche von 0 bis 1.** Der Typ-Kommentar spricht von einem Array mit Zahlen von 0 bis 100, aber der Code multipliziert jede Zahl mit der Fensterhöhe. `50` heißt also fünfzig Screens. Schreib `0.5`.
2. **Nur Pixel.** Ein String läuft durch `parseInt`, also wird `"30rem"` zu 30 Pixeln.
3. **Definier das Array außerhalb der Komponente.** Vaul führt seinen Snap-Effekt jedes Mal neu aus, wenn sich die Identität von `snapPoints` ändert. Ein Inline-Literal lässt das Sheet also bei jedem Render der Elternkomponente neu einrasten.

Dann entscheidest du, was scrollt. Unterhalb des letzten Punkts sollte der Inhalt stillstehen, damit ein Wischen nach oben das Sheet zieht, statt eine Liste zu scrollen, die nur halb zu sehen ist. `fadeFromIndex` legt fest, ab welchem Punkt das Overlay abdunkelt; Standard ist der letzte, also bleibt die Seite bei einem halb offenen Sheet hell.

Mach das React Bottom Sheet modal, wenn es eine Aufgabe enthält. Gib `modal={false}` an, wenn die Seite dahinter bedienbar bleiben muss, etwa eine Ergebnisliste über einer Karte:

```tsx
"use client";

import { useState } from "react";
import { Drawer, type DrawerSnapPoint } from "@/components/ui/drawer";

// Bruchteile der Bildschirmhöhe, am wenigsten sichtbar zuerst; ein Array für die ganze Laufzeit der App
const SNAP_POINTS: DrawerSnapPoint[] = [0.45, 1];

type Apartment = { id: string; title: string; rent: string };

export function MapResults({ apartments }: { apartments: Apartment[] }) {
  const [snap, setSnap] = useState<DrawerSnapPoint | null>(SNAP_POINTS[0]);
  return (
    <Drawer
      defaultOpen
      modal={false}
      dismissible={false}
      snapPoints={SNAP_POINTS}
      activeSnapPoint={snap}
      onActiveSnapPointChange={setSnap}
      title="Wohnungen in Chicago"
      description={`${apartments.length} Ergebnisse im Kartenbereich`}
    >
      <ul className="flex flex-col gap-3">
        {apartments.map((a) => (
          <li key={a.id} className="flex justify-between gap-3 text-[15px]">
            <span className="truncate">{a.title}</span>
            <span className="font-semibold tabular-nums">{a.rent}</span>
          </li>
        ))}
      </ul>
    </Drawer>
  );
}
```

Mit Snap Points hält `dismissible={false}` das Sheet an seinem ersten Punkt an, die Liste lässt sich also nie hinter die Karte wegziehen. Der Drawer hält für jede unterschiedliche Punkteliste ein einziges Array, auch wenn du ein Literal übergibst, lässt den Inhalt erst am letzten Punkt scrollen und springt bei einem Tippen auf den Griff zum nächsten Punkt.

## Wie entscheidet Drag to Dismiss, wann das Sheet schließt?

Nach Strecke oder Geschwindigkeit, gemessen in dem Moment, in dem der Finger loslässt. Ohne Snap Points schließt vaul das Sheet, wenn du es nach mehr als 25% seiner Höhe loslässt (`closeThreshold`, Standard 0.25) oder schneller als 0.4 Pixel pro Millisekunde wischst; alles darunter gleitet in 500ms mit `cubic-bezier(0.32, 0.72, 0, 1)` zurück. Mit Snap Points landet es auf dem nächstgelegenen Punkt, ein schneller Wisch bewegt es einen Punkt in seine Richtung, und ein Wisch über 2 Pixel pro Millisekunde schließt das Sheet oder öffnet es ganz. Schwieriger ist die Frage, wann ein Drag gar nicht erst starten sollte. Vaul 1.1.2 lehnt ihn ab:

- in einem scrollbaren Element, das nicht ganz nach oben gescrollt ist, damit eine lange Liste zuerst scrollt;
- für einen kurzen Moment, nachdem der Inhalt gescrollt hat (`scrollLockTimeout`: 100ms im Code, obwohl der Typ-Kommentar 500ms nennt);
- während Text markiert ist, auf einem nativen `<select>` oder auf allem innerhalb von `[data-vaul-no-drag]`;
- in den ersten 500ms nach dem Öffnen.

Linke und rechte Drawer überspringen die Scroll- und Zeitprüfungen. Für Inhalte mit eigenen horizontalen Gesten (ein Karussell, eine Karte, ein Range-Slider) setzt du `handleOnly`, damit nur der Griff zieht. Und gib jedem Drag eine sichtbare Alternative: Escape, ein Tippen auf die abgedunkelte Seite und einen Schließen-Button. Der Drawer von Wingo UI lässt den Schließen-Button bei Bottom Sheets standardmäßig weg, also übergib `showClose` (Panels und Dialoge haben immer einen).

Die Demo unten ist eine Seite mit Inseraten. „Filters“ öffnet ein Sheet mit Filtern für Preis, Zustand und Kategorie und einem fixierten Button mit der Zahl der Treffer.

> Live-Demo (Drawer): Tippe auf Filters und zieh das Sheet nach unten: Lass es nach mehr als einem Viertel seiner Höhe los oder wisch schnell, dann schließt es; ein kürzerer Zug federt zurück. Scroll zuerst die Kategorien: Das Sheet zieht erst, wenn die Liste wieder oben steht. Probier sie unter [Drawer](https://wingo-ui.com/components/drawer) aus und installier sie mit `npx wingo-ui@latest add drawer`.

## Wie verschachtelst du Bottom Sheets?

Öffne das zweite Sheet aus dem ersten heraus und schieb das erste nach hinten, wie unter iOS. In vaul ist das `Drawer.NestedRoot`, gerendert innerhalb von `Drawer.Content` des Eltern-Drawers. Ohne Eltern-Drawer wirft es einen Fehler. Während sich der Kind-Drawer öffnet, macht es den Eltern-Drawer 16px schmaler, hebt ihn um 16px an und folgt dem Drag des Kindes. Den Eltern-Drawer bewegt es nur aus seinem eigenen `onOpenChange`. Öffnest du das Kind über `open` aus dem State, bleibt der Eltern-Drawer also unverändert.

Der Drawer von Wingo UI erkennt die Verschachtelung über den Context. Ein Drawer, der im Inhalt eines anderen Drawers rendert, nutzt den Nested Root von selbst und schiebt den Eltern-Drawer auch bei kontrolliertem Öffnen zurück:

```tsx
"use client";

import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerRoot, DrawerTrigger } from "@/components/ui/drawer";

export function DeliveryAddress({ addresses, newAddressForm }: { addresses: ReactNode; newAddressForm: ReactNode }) {
  return (
    <DrawerRoot>
      <DrawerTrigger asChild>
        <Button variant="soft" size="sm">Ändern</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader title="Lieferadresse" description="Ankunft in 1-2 Werktagen" />
        <DrawerBody>
          {addresses}
          {/* im Inhalt des ersten Sheets, also verschachtelt es sich und schiebt das erste zurück */}
          <Drawer
            title="Neue Adresse"
            trigger={<Button variant="ghost" fullWidth>Neue Adresse hinzufügen</Button>}
            footer={
              <DrawerClose asChild>
                <Button size="lg" fullWidth>Adresse speichern</Button>
              </DrawerClose>
            }
          >
            {newAddressForm}
          </Drawer>
        </DrawerBody>
        <DrawerFooter>
          <DrawerClose asChild>
            <Button size="lg" fullWidth>Hierhin liefern</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </DrawerRoot>
  );
}
```

Hör bei zwei Ebenen auf. Auf einem 390px-Screen ist ein drittes Sheet eigentlich eine Seite, also navigier zu einer eigenen.

## Was passiert, wenn sich in einem Bottom Sheet die Tastatur öffnet?

Die Tastatur verdeckt das Sheet, solange nichts es verschiebt. In Safari auf iOS und in Chrome auf Android seit Version 108 verkleinert die Tastatur nur den Visual Viewport ([Ankündigung von Chrome](https://developer.chrome.com/blog/viewport-resize-behavior)), also bleibt ein Element mit `position: fixed; bottom: 0` dahinter. Die Option `repositionInputs` von vaul ist standardmäßig aktiv und hört auf Resize-Events von `visualViewport`: Solange ein Feld den Fokus hat, hebt es das Sheet um die Höhe an, die die Tastatur verdeckt, und verkleinert es, wenn es nicht mehr über die Tastatur passt.

Zwei Regeln sorgen dafür, dass das in der Praxis funktioniert:

- **Fokussier beim Öffnen kein Feld.** Der Drawer von Wingo UI fokussiert das Sheet selbst, damit die Tastatur nicht das halbe Sheet verdeckt, bevor jemand es gelesen hat; Tab springt dann zum ersten Control. Wenn Tippen die ganze Aufgabe ist, etwa beim Umbenennen einer Liste, übergib `onOpenAutoFocus`, ruf `event.preventDefault()` auf und fokussier das Feld selbst.
- **Nimm 16px Schrift in Eingabefeldern** (`text-base md:text-sm`). Safari auf iOS zoomt in ein fokussiertes Feld mit kleinerer Schrift hinein.

Schalt `repositionInputs` ab, wenn das Sheet die Tastatur selbst behandelt, zum Beispiel ein Chat-Eingabefeld, das der Tastatur mit dem Pro-Hook [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) folgt.

## Wie hältst du ein Bottom Sheet vom Home-Indikator fern?

Lass die Seite mit `viewport-fit=cover` bis an die Bildschirmränder reichen (in Next.js `viewportFit: "cover"` im `viewport`-Export des Root-Layouts) und gib dann dem Teil des Sheets, der den unteren Rand berührt, `max(env(safe-area-inset-bottom), 16px)` als Padding. Das `max()` lässt 16px Luft auf Geräten, die einen Inset von 0 melden. Gib nur dem letzten Teil dieses Padding: den Footer, wenn es einen gibt, sonst den scrollenden Inhalt, damit seine letzte Zeile über den Indikator scrollen kann. Begrenz die Höhe auf `92dvh`, damit ein Streifen der Seite zum Antippen sichtbar bleibt. Bei einem einfachen vaul Drawer:

```tsx
"use client";

import type { ReactNode } from "react";
import { Drawer } from "vaul";

type SheetProps = { trigger: ReactNode; title: string; children: ReactNode; actions: ReactNode };

export function BottomSheet({ trigger, title, children, actions }: SheetProps) {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>{trigger}</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
        <Drawer.Content
          aria-describedby={undefined}
          className="fixed inset-x-0 bottom-0 z-50 flex max-h-[92dvh] flex-col rounded-t-[28px] bg-popover outline-none"
        >
          <Drawer.Handle className="mt-2.5" />
          <Drawer.Title className="px-5 pt-3 pb-2 text-[17px] font-semibold">{title}</Drawer.Title>
          <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5">{children}</div>
          {/* der Teil am unteren Rand bekommt das Padding für den Home-Indikator */}
          <div className="flex flex-col gap-2 px-5 pt-3 pb-[max(env(safe-area-inset-bottom),16px)]">{actions}</div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}
```

Der Drawer von Wingo UI gibt das Padding immer dem Teil, der zuletzt kommt. Mit `variant="floating"` schwebt er stattdessen 8px über der Safe Area, mit allen Ecken abgerundet, so wie das [Action Sheet](https://wingo-ui.com/components/action-sheet) in seiner iOS-Variante. Dieselben Insets für Tab Bars, FABs und Toasts findest du in unserem [Tutorial zu Safe Area Insets in Tailwind](https://wingo-ui.com/de/blog/tailwind-safe-area-insets) (auf Englisch).

## Welches Bottom Sheet nimmst du für welche Aufgabe?

Die eine Bottom Sheet UI React-Komponente, die jeden Zweck abdeckt, gibt es nicht. Wähl nach der Aufgabe:

| Komponente | Wofür | Basis | Zugang |
| --- | --- | --- | --- |
| [Drawer](https://wingo-ui.com/components/drawer) | Formulare, Filter, Picker, Ergebnisse über einer Karte; ab 768px ein Seitenpanel oder ein Dialog (`desktop`) | vaul | Kostenlos |
| [Action Sheet](https://wingo-ui.com/components/action-sheet) | Eine kurze Liste von Aktionen zu einer Sache, mit separatem Abbrechen und `await show()` | Drawer | Pro |
| [Family Drawer](https://wingo-ui.com/components/family-drawer) | Ein schwebendes Sheet, das zwischen Ansichten morpht, etwa einem Menü und einer Bestätigung | vaul | Kostenlos |
| [Sheet](https://wingo-ui.com/components/sheet) | Ein Seitenpanel auf dem Desktop, das auf Smartphones zum Bottom Sheet wird (`mobileSide="bottom"`) | Radix Dialog und motion | Kostenlos |

Das Action Sheet funktioniert auch imperativ: Bind einmal einen `ActionSheetProvider` ein, hol dir in einer Komponente `const actionSheet = useActionSheet()`, und in einem Click-Handler liefert `await actionSheet.show({ actions })` die ID der gewählten Aktion, oder `null` bei Abbrechen oder Schließen. Gibt das `onSelect` einer Aktion ein Promise zurück, bleibt das Sheet offen und zeigt einen Spinner in ihrer Zeile; es schließt, wenn das Promise erfüllt wird, und wenn es abgelehnt wird, wackelt die Zeile und bleibt bereit für einen neuen Versuch.

> Live-Demo (Action Sheet): Tippe auf den ⋯-Button beim Inserat: Das iOS-Sheet schwebt über dem Home-Indikator, Cancel sitzt in einer eigenen Karte. Delete listing zeigt einen Spinner in seiner Zeile, bis das Promise erfüllt ist. Probier sie unter [Action Sheet](https://wingo-ui.com/components/action-sheet) aus und installier sie mit `npx wingo-ui@latest add action-sheet`.

Der Family Drawer behält ein einziges schwebendes Sheet und animiert dessen Höhe zwischen den Ansichten. Das passt zu einem kurzen Ablauf wie einem Menü, das zu einer Bestätigung führt.

> Live-Demo (Family Drawer): Tippe auf Try it out und wähl eine Option: Das schwebende Sheet morpht auf die Höhe der neuen Ansicht. Zieh es nach unten oder tippe daneben, um es zu schließen. Probier sie unter [Family Drawer](https://wingo-ui.com/components/family-drawer) aus und installier sie mit `npx wingo-ui@latest add family-drawer`.

In der Registry (Release 1.7.0) importieren 39 weitere Items den Drawer, darunter Popover, Select, Dropdown Menu und Dialog, die sich unter 768px als Bottom Sheets öffnen. Jedes Sheet einer App verhält sich beim Ziehen, Einrasten und Padding gleich.

## Wie geht es weiter?

Installier den kostenlosen Drawer mit `npx wingo-ui@latest add drawer`, öffne deine App bei 390px und zieh ein Sheet zu, dessen Liste halb gescrollt ist. Für den Wechsel zwischen zentrierter Karte und Sheet lies das [Tutorial zum responsiven shadcn Dialog](https://wingo-ui.com/de/blog/shadcn-responsive-dialog-on-mobile); für Touch-Ziele, Safe Areas und die übrigen Smartphone-Regeln den [Guide zu Mobile-First-React-Komponenten](https://wingo-ui.com/de/blog/mobile-first-react-components) und die Kategorie [Mobile UI in React](https://wingo-ui.com/de/blog/category/mobile-ui). Das Action Sheet gehört zu [Wingo UI Pro](https://wingo-ui.com/de/pricing).

## Komponenten in diesem Artikel

- [Drawer](https://wingo-ui.com/components/drawer) (Free): A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Installation: `npx wingo-ui@latest add drawer`
- [Family Drawer](https://wingo-ui.com/components/family-drawer) (Free): A React bottom drawer that floats above the page and morphs its height between stacked views, inspired by the Family app. Installation: `npx wingo-ui@latest add family-drawer`
- [Action Sheet](https://wingo-ui.com/components/action-sheet) (Pro): A React iOS-style action sheet: a short list of actions with a title, red destructive rows, a separate Cancel and an awaitable show(). Installation: `npx wingo-ui@latest add action-sheet`
- [Sheet](https://wingo-ui.com/components/sheet) (Free): A React sheet that slides in from any edge on a spring for filters, a cart, an inspector or phone navigation, swipeable to close on touch. Installation: `npx wingo-ui@latest add sheet`

## Häufige Fragen

### Kann ich @gorhom/bottom-sheet in einer React-Web-App nutzen?

Nur über React Native Web, denn es ist eine React-Native-Bibliothek, die Reanimated und Gesture Handler braucht. In einer Next.js- oder Vite-App, die ins DOM rendert, nimmst du vaul oder den Drawer von Base UI, die auf barrierefreien Dialog-Primitives aufbauen.

### Wird vaul noch gepflegt?

Nein. Stand Oktober 2026 erklärt das README das Repo für nicht mehr gepflegt, und das letzte Release ist 1.1.2 vom 14. Dezember 2024. React 19 steht weiterhin in den unterstützten Versionen; die gepflegte Alternative ist der Drawer von Base UI.

### Warum funktionieren meine vaul Snap Points mit Werten wie 50 nicht?

Vaul multipliziert jede Zahl mit der Bildschirmhöhe, also müssen Zahlen Brüche von 0 bis 1 sein (0.5 für den halben Screen), auch wenn ein Typ-Kommentar 0 bis 100 nennt. Strings werden als Pixel gelesen, schreib also "320px" und nie rem.

### Wie verhindere ich, dass sich ein vaul Drawer mitbewegt, wenn ich in einem Karussell darin wische?

Setz handleOnly, damit nur der Griff das Sheet zieht, oder gib dem Root-Element des Karussells das Attribut data-vaul-no-drag. Drags, die auf einem nativen Select starten oder während Text markiert ist, ignoriert vaul bereits.

### Ist der Drawer von Wingo UI kostenlos?

Ja. Drawer, Family Drawer und Sheet sind kostenlos, das Action Sheet gehört zu Pro. npx wingo-ui@latest add drawer kopiert die Komponente samt Button, Hooks und Helfern, die sie importiert, in dein Projekt.

---

Quelle: https://wingo-ui.com/de/blog/react-bottom-sheet-for-the-web
