React Bottom Sheet for the Web: Snap Points, Drag, Keyboard
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, 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:
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 (opens in a new tab), 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:
- 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
50means fifty screens. Write0.5. - Pixels only. A string goes through
parseInt, so"30rem"becomes 30 pixels. - Hoist the array. Vaul re-runs its snap effect whenever
snapPointschanges 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:
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.
$ npx wingo-ui@latest add drawerHow 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:
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 (opens in a new tab)), 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, callevent.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 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:
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 uses for its iOS variant. The same insets for tab bars, FABs and toasts are in our Tailwind safe area insets tutorial.
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:
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.
$ npx wingo-ui@latest add action-sheetThe 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.
$ npx wingo-ui@latest add family-drawerIn 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; for touch targets, safe areas and the rest of the phone rules, the mobile-first React components guide and the Mobile UI in React category. The Action Sheet is part of Wingo UI Pro.
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.
- Mobile UI
- Bottom sheet
- Vaul
- React
- Next.js