WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React Bottom Sheet for the Web: Snap Points, Drag, Keyboard
  1. Blog
  2. Mobile UI in React
  3. React Bottom Sheet for the Web: Snap Points, Drag, Keyboard
Mobile UI in React
Mobile UI in React

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

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 9 min read

On this page

0%
  1. Which React bottom sheet library works on the web?
  2. How do vaul drawer snap points work?
  3. How does drag to dismiss decide when to close?
  4. How do you nest bottom sheets?
  5. What happens when the keyboard opens inside a bottom sheet?
  6. How do you keep a bottom sheet clear of the home indicator?
  7. Which bottom sheet should you use for each job?
  8. What should you do next?
  9. FAQ
    1. Can I use @gorhom/bottom-sheet in a React web app?
    2. Is vaul still maintained?
    3. Why do my vaul snap points not work with values like 50?
    4. How do I stop a vaul drawer from dragging when I swipe a carousel inside it?
    5. Is the Wingo UI Drawer free?

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.

Published Oct 9, 2026

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:

LibraryBuilt onSnap pointsNested sheetsLatest release
vaul (opens in a new tab)Radix DialogFractions or px stringsDrawer.NestedRoot1.1.2, Dec 14, 2024
Base UI Drawer (opens in a new tab)Base UI DialogFractions, px or remYes, styled with CSS variables@base-ui/react 1.8.0, Sep 4, 2026
react-modal-sheet (opens in a new tab)MotionFractions or px, including 0 (closed) and 1Not documented5.6.0, Mar 27, 2026
react-spring-bottom-sheet (opens in a new tab)react-spring, react-use-gestureA function of the content's min and max heightNot documented3.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 (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:

  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.

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.
$ npx wingo-ui@latest add drawer
FreeDrawer docs

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 (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, 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 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 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:

ComponentUse it forBuilt onAccess
DrawerForms, filters, pickers, results over a map; a side panel or a dialog from 768px (desktop)vaulFree
Action SheetA short list of actions about one thing, with a separate Cancel and await show()DrawerPro
Family DrawerA floating sheet that morphs between views, such as a menu and a confirmationvaulFree
SheetA side panel on desktop that becomes a bottom sheet on phones (mobileSide="bottom")Radix Dialog and motionFree

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.

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.
$ npx wingo-ui@latest add action-sheet
ProAction Sheet docs

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.

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.
$ npx wingo-ui@latest add family-drawer
FreeFamily Drawer docs

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

Components in this post

  • Drawer

    The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free
  • Family Drawer

    A floating bottom drawer that morphs smoothly between stacked views, inspired by the Family app.

    Free
  • Action Sheet

    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().

    Pro
  • Sheet

    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.

    Free

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones

Build a shadcn responsive dialog once: a centered card above 768px, a draggable bottom sheet below it, one copy of the content and no hydration flash.

SRSerban Rusu·Oct 9, 2026·10 min read
Next.js and Tailwind CSS

React Use Mobile Hook: SSR-Safe, No Hydration Mismatch

Why a React use mobile hook is false on the server, when CSS breakpoints beat it, and an SSR-safe useSyncExternalStore version with no hydration mismatch.

SRSerban Rusu·Oct 9, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI