Skip to content

Components

BottomSheet

The mobile pattern for content layered over a map, a player, or a feed: a sheet that rests at snap points, drags between them, and dismisses when flung down. Pure CSS transforms — no motion adapter needed, 60fps drags.

Over a map (non-modal)

Pass modal={false} and the page behind stays fully interactive — no backdrop, no scroll lock. Snap points are viewport fractions: the sheet opens at defaultSnap, dragging the grabber moves it between points, and dragging well below the lowest one calls onClose.

Open fullscreen
9:41
app.tsx
import { AppShell, BottomSheet } from "appshell-react";
import { useState } from "react";

export default function App() {
  const [open, setOpen] = useState(true);

  return (
    <AppShell safeArea>
      <MapCanvas />

      <BottomSheet
        open={open}
        onClose={() => setOpen(false)}
        modal={false}                // the map behind stays interactive
        snapPoints={[0.4, 0.85]}     // rest at 40%, drag up to 85%
        onSnapChange={(i) => console.log("snapped to", i)}
        aria-label="Nearby places"
      >
        <PlacesList />               {/* scrolls internally */}
      </BottomSheet>
    </AppShell>
  );
}

As a modal

Left modal (the default), the sheet behaves like a dialog: a dimmed backdrop that closes on tap, body scroll locking, Escape to close, and a focus trap that hands focus back where it came from.

share.tsx
// Modal by default: backdrop, scroll lock, Escape, focus trap.
<BottomSheet open={open} onClose={close} snapPoints={[0.5]}>
  <ShareOptions />
</BottomSheet>
The sheet is portaled and renders nothing during SSR. Its content area pads itself past the home indicator with the platform’s env(safe-area-inset-bottom), and the panel centers at max-w-lg on larger screens so a phone-wide sheet never stretches across a desktop.

Overlay stack

A modal BottomSheet joins the same overlay stack as the Sidebar drawer, SearchModal, and the UserMenu/NotificationsMenu dropdowns — one shared mechanism behind every overlay in the library, so they nest correctly instead of interfering with each other:

  • • Close requests. One listener for the whole stack answers Escape (and, where supported, the platform’s back gesture) by closing only the topmost dismissable layer — open a menu inside this sheet, and one press closes the menu, not both.
  • • Scroll lock. A single ref-counted owner locks body scroll while any modal layer is open, and releases it only once the last one closes, so layering a second modal over the sheet never leaves the page stuck scrollable or stuck locked.
  • • Stacking. z-index follows open order rather than component identity, so whatever you open from inside the sheet always lands above it.
See the nested overlays example for the pattern live — open a Sidebar, a SearchModal, and a BottomSheet on top of each other and close them one at a time.

API

PropTypeDefaultDescription
open*boolean—Whether the sheet is visible.
onClose*() => void—Called on Escape / backdrop tap (modal) and when dragged below the lowest snap.
snapPointsnumber[][0.45, 0.9]Resting heights as viewport fractions, ascending. Dragging moves between them.
defaultSnapnumber0Index into snapPoints the sheet opens at.
onSnapChange(index: number) => void—Fires when a drag settles on a different snap point.
modalbooleantrueModal: backdrop, scroll lock, Escape, focus trap. false keeps the page behind interactive — the map pattern.
classNamestring—Extra classes for the panel.
aria-labelstring"Sheet"Accessible dialog name.
children*ReactNode—Sheet content — scrolls internally below the grabber.