# React Bottom Sheet for the Web: Snap Points, Drag, Keyboard

> Build a React bottom sheet for the web on vaul: snap points, drag to dismiss, nested sheets, the on-screen keyboard and the home indicator, with working code.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-bottom-sheet-for-the-web

## TL;DR

A React bottom sheet for the web is a draggable dialog pinned to the bottom of the screen, so build it on a DOM dialog primitive: Base UI's Drawer (maintained) or vaul (unmaintained per its README, still the most complete option on Radix). Pass snap points as fractions from 0 to 1 in a stable array, let the body scroll only at the last point, raise the sheet above the on-screen keyboard with the visualViewport API, and pad the part that touches the bottom edge with env(safe-area-inset-bottom). The free Wingo UI Drawer does all of this on top of vaul 1.1.2.

Search for a React bottom sheet and many of the top results are React Native packages such as @gorhom/bottom-sheet, which reach the browser only through React Native Web. This guide is about the React bottom sheet web apps need: a real dialog in the DOM, pointer events, `position: fixed`, an on-screen keyboard that covers half the screen and a home indicator under your Save button. It covers the five parts that decide whether a sheet feels native on a phone (snap points, drag to dismiss, nested sheets, the keyboard and the safe area), with code for vaul and for the free Wingo UI [Drawer](https://wingo-ui.com/components/drawer), which is built on it.

## Which React bottom sheet library works on the web?

For apps that render to the DOM, vaul and Base UI's Drawer are the two serious choices, because both sit on a dialog primitive: focus trapping, Escape and the accessible title come with them. As of October 2026, from the npm registry and each project's docs:

| Library | Built on | Snap points | Nested sheets | Latest release |
| --- | --- | --- | --- | --- |
| [vaul](https://github.com/emilkowalski/vaul) | Radix Dialog | Fractions or px strings | `Drawer.NestedRoot` | 1.1.2, Dec 14, 2024 |
| [Base UI Drawer](https://base-ui.com/react/components/drawer) | Base UI Dialog | Fractions, px or rem | Yes, styled with CSS variables | @base-ui/react 1.8.0, Sep 4, 2026 |
| [react-modal-sheet](https://github.com/temzasse/react-modal-sheet) | Motion | Fractions or px, including 0 (closed) and 1 | Not documented | 5.6.0, Mar 27, 2026 |
| [react-spring-bottom-sheet](https://github.com/stipsan/react-spring-bottom-sheet) | react-spring, react-use-gesture | A function of the content's min and max height | Not documented | 3.4.1, Jun 1, 2022 |

Three facts the table cannot show change the decision. Vaul's README says "This repo is unmaintained", and no release has shipped since December 2024; its peer range still includes React 19, and the Wingo UI Drawer runs on it with React 19.3. react-spring-bottom-sheet's peer range stops at React 18. react-modal-sheet's README says it "doesn't include any built-in accessibility properties", so focus trapping and dialog semantics are yours to add.

If your project already uses Base UI, the default for new shadcn/ui projects since [July 2026](https://ui.shadcn.com/docs/changelog/2026-07-base-ui-default), use its Drawer: it is maintained and covers snap points, nesting and the keyboard (`Drawer.VirtualKeyboardProvider`). On Radix, vaul is still the most complete option, and the rest of this guide is about the gaps you cover around it.

## How do vaul drawer snap points work?

Vaul drawer snap points are stops on the way up, listed from least to most visible: a number is a fraction of the screen height (`0.45` is 45%), a string is pixels (`"320px"`). The sheet renders at full height and is translated down to the active point. Three details trip people up in vaul 1.1.2:

1. **Fractions from 0 to 1.** The type comment says "Array of numbers from 0 to 100", but the code multiplies each number by the window height, so `50` means fifty screens. Write `0.5`.
2. **Pixels only.** A string goes through `parseInt`, so `"30rem"` becomes 30 pixels.
3. **Hoist the array.** Vaul re-runs its snap effect whenever `snapPoints` changes identity, so an inline literal re-snaps the sheet on every parent render.

Then decide what scrolls. Below the last point the body should stay still, so an upward swipe drags the sheet instead of scrolling a list the user can only half see. `fadeFromIndex` picks the point where the overlay starts to dim; it defaults to the last one, so a half-open sheet leaves the page bright.

Make the React bottom sheet modal when it holds a task. Pass `modal={false}` when the page behind must stay usable, like a results list over a map:

```tsx
"use client";

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

// fractions of the screen height, least visible first; one array for the app's lifetime
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="Apartments in Chicago"
      description={`${apartments.length} results in the map area`}
    >
      <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>
  );
}
```

With snap points, `dismissible={false}` stops the sheet at its first point, so the list can never be dragged away behind the map. The Drawer keeps one array per distinct list of points even when you pass a literal, lets the body scroll only at the last point, and steps to the next point on a tap on the handle.

## How does drag to dismiss decide when to close?

By distance or speed, measured when the finger lifts. Without snap points, vaul closes the sheet when you let go past 25% of its height (`closeThreshold`, default 0.25) or flick it faster than 0.4 pixels per millisecond; anything less eases back over 500ms on `cubic-bezier(0.32, 0.72, 0, 1)`. With snap points it settles on the nearest point, a flick moves one point in its direction, and a flick faster than 2 pixels per millisecond closes the sheet or opens it fully. The harder part is when a drag should not start at all. Vaul 1.1.2 refuses one:

- from inside a scrollable element that is not at its top, so a long list scrolls first;
- for a moment after the content scrolled (`scrollLockTimeout`: 100ms in the code, although the type comment says 500ms);
- while text is selected, from a native `<select>`, or from anything inside `[data-vaul-no-drag]`;
- in the first 500ms after opening.

Left and right drawers skip the scroll and timing checks. For content with its own horizontal gestures (a carousel, a map, a range slider) set `handleOnly`, so only the handle drags. And give every drag a visible alternative: Escape, a tap on the dimmed page and a close button. The Wingo UI Drawer leaves the close button off bottom sheets by default, so pass `showClose` (panels and dialogs always have one).

The demo below is a listings page. Filters opens a sheet with price, condition and category filters and a pinned button with the result count.

> Live demo (Drawer): Tap Filters, then drag the sheet down: let go past a quarter of its height or flick it and it closes, a shorter drag settles back. Scroll the categories first: the sheet only drags once the list is back at the top. Try it at [Drawer](https://wingo-ui.com/components/drawer) and install it with `npx wingo-ui@latest add drawer`.

## How do you nest bottom sheets?

Open the second sheet from inside the first and push the first one back, like iOS. In vaul that is `Drawer.NestedRoot`, rendered inside the parent's `Drawer.Content`; it throws when it has no parent drawer, and as the child opens it shrinks the parent 16px narrower, lifts it 16px and follows the child's drag. It moves the parent only from its own `onOpenChange`, so a child opened by setting `open` from state leaves the parent flat.

The Wingo UI Drawer detects nesting from context. A drawer rendered inside another drawer's content uses the nested root on its own and pushes the parent for controlled opens too:

```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">Change</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader title="Delivery address" description="Arrives in 1-2 business days" />
        <DrawerBody>
          {addresses}
          {/* inside the first sheet's content, so it nests and pushes the first one back */}
          <Drawer
            title="New address"
            trigger={<Button variant="ghost" fullWidth>Add a new address</Button>}
            footer={
              <DrawerClose asChild>
                <Button size="lg" fullWidth>Save address</Button>
              </DrawerClose>
            }
          >
            {newAddressForm}
          </Drawer>
        </DrawerBody>
        <DrawerFooter>
          <DrawerClose asChild>
            <Button size="lg" fullWidth>Deliver here</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </DrawerRoot>
  );
}
```

Stop at two levels. On a 390px screen a third sheet is a page, so navigate to one.

## What happens when the keyboard opens inside a bottom sheet?

The keyboard covers the sheet unless something moves it. On Safari for iOS, and on Chrome for Android since version 108, the keyboard resizes only the visual viewport ([Chrome's announcement](https://developer.chrome.com/blog/viewport-resize-behavior)), so an element at `position: fixed; bottom: 0` stays behind it. Vaul's `repositionInputs`, on by default, listens to `visualViewport` resize events: while a field has focus it raises the sheet by the height the keyboard covers and shrinks it when it no longer fits above the keyboard.

Two rules make that work in practice:

- **Do not focus a field on open.** The Wingo UI Drawer focuses the sheet itself, so the keyboard does not cover half the sheet before anyone has read it; Tab moves on to the first control. When typing is the whole task, such as renaming a list, pass `onOpenAutoFocus`, call `event.preventDefault()` and focus the field yourself.
- **Use 16px text in inputs** (`text-base md:text-sm`). iOS Safari zooms into a focused field with smaller text.

Turn `repositionInputs` off when the sheet handles the keyboard itself, for example a chat composer that follows the keyboard with the Pro [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) hook.

## How do you keep a bottom sheet clear of the home indicator?

Let the page reach the screen edges with `viewport-fit=cover` (in Next.js, `viewportFit: "cover"` in the root layout's `viewport` export), then pad the part of the sheet that touches the bottom edge with `max(env(safe-area-inset-bottom), 16px)`. The `max()` keeps 16px of air on devices that report an inset of 0. Pad only the last part: the footer when there is one, otherwise the scrolling body, so its last row can scroll above the indicator. Cap the height at `92dvh` so a strip of the page stays visible to tap. On a plain 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>
          {/* the part that touches the bottom edge pads the home indicator */}
          <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>
  );
}
```

The Wingo UI Drawer pads whichever part comes last. Its `variant="floating"` instead sits 8px above the safe area with every corner rounded, the look the [Action Sheet](https://wingo-ui.com/components/action-sheet) uses for its iOS variant. The same insets for tab bars, FABs and toasts are in our [Tailwind safe area insets tutorial](https://wingo-ui.com/blog/tailwind-safe-area-insets).

## Which bottom sheet should you use for each job?

There is no single bottom sheet UI React apps should use for everything. Pick by the job:

| Component | Use it for | Built on | Access |
| --- | --- | --- | --- |
| [Drawer](https://wingo-ui.com/components/drawer) | Forms, filters, pickers, results over a map; a side panel or a dialog from 768px (`desktop`) | vaul | Free |
| [Action Sheet](https://wingo-ui.com/components/action-sheet) | A short list of actions about one thing, with a separate Cancel and `await show()` | Drawer | Pro |
| [Family Drawer](https://wingo-ui.com/components/family-drawer) | A floating sheet that morphs between views, such as a menu and a confirmation | vaul | Free |
| [Sheet](https://wingo-ui.com/components/sheet) | A side panel on desktop that becomes a bottom sheet on phones (`mobileSide="bottom"`) | Radix Dialog and motion | Free |

The Action Sheet also works imperatively: mount an `ActionSheetProvider` once, take `const actionSheet = useActionSheet()` in a component, and in a click handler `await actionSheet.show({ actions })` resolves with the chosen action's id, or `null` on Cancel or a dismiss. An action whose `onSelect` returns a promise keeps the sheet open with a spinner in its row; the sheet closes when the promise resolves, and the row shakes and stays ready to retry when it rejects.

> Live demo (Action Sheet): Tap the ⋯ button on the listing: the iOS sheet floats above the home indicator with Cancel in its own card. Delete listing shows a spinner in its row until its promise resolves. Try it at [Action Sheet](https://wingo-ui.com/components/action-sheet) and install it with `npx wingo-ui@latest add action-sheet`.

The Family Drawer keeps one floating sheet and animates its height between views, which suits a short flow like a menu that leads to a confirmation.

> Live demo (Family Drawer): Tap Try it out and pick an option: the floating sheet morphs to the height of the new view. Drag it down or tap outside to close it. Try it at [Family Drawer](https://wingo-ui.com/components/family-drawer) and install it with `npx wingo-ui@latest add family-drawer`.

In the registry (release 1.7.0), 39 other items import the Drawer, including the Popover, Select, Dropdown Menu and Dialog, which open as bottom sheets below 768px. Every sheet in an app drags, snaps and pads the same way.

## What should you do next?

Install the free Drawer with `npx wingo-ui@latest add drawer`, open your app at 390px and drag a sheet closed with its list scrolled halfway. For the switch between a centered card and a sheet, read the [shadcn responsive dialog tutorial](https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile); for touch targets, safe areas and the rest of the phone rules, the [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components) and the [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui) category. The Action Sheet is part of [Wingo UI Pro](https://wingo-ui.com/pricing).

## Components in this post

- [Drawer](https://wingo-ui.com/components/drawer) (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `npx wingo-ui@latest add drawer`
- [Family Drawer](https://wingo-ui.com/components/family-drawer) (Free): A floating bottom drawer that morphs smoothly between stacked views, inspired by the Family app. Install: `npx wingo-ui@latest add family-drawer`
- [Action Sheet](https://wingo-ui.com/components/action-sheet) (Pro): The iOS action sheet: a short list of actions about one thing, with a title, red destructive rows and a separate Cancel, plus await show(). Install: `npx wingo-ui@latest add action-sheet`
- [Sheet](https://wingo-ui.com/components/sheet) (Free): A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch. Install: `npx wingo-ui@latest add sheet`

## FAQ

### Can I use @gorhom/bottom-sheet in a React web app?

Only through React Native Web: it is a React Native library that needs Reanimated and Gesture Handler. In a Next.js or Vite app that renders to the DOM, use vaul or Base UI's Drawer, which are built on accessible dialog primitives.

### Is vaul still maintained?

No. As of October 2026 its README says the repo is unmaintained, and the last release is 1.1.2 from December 14, 2024. It still declares React 19 support; Base UI's Drawer is the maintained alternative.

### Why do my vaul snap points not work with values like 50?

Vaul multiplies each number by the screen height, so numbers must be fractions from 0 to 1 (0.5 for half the screen), even though a type comment says 0 to 100. Strings are read as pixels, so write "320px", never rem.

### How do I stop a vaul drawer from dragging when I swipe a carousel inside it?

Set handleOnly so only the handle drags the sheet, or add the data-vaul-no-drag attribute to the carousel's root element. Vaul already ignores drags that start on a native select or while text is selected.

### Is the Wingo UI Drawer free?

Yes. The Drawer, Family Drawer and Sheet are free and the Action Sheet is Pro. npx wingo-ui@latest add drawer copies the component into your project with the Button, hooks and helpers it imports.

---

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