Bottom Sheet

A panel that slides up from the bottom of the screen. Used for contextual actions, navigation overflow, and secondary content that doesn't warrant a full page or modal. Portaled to document.body; dismissed by backdrop tap, Escape, or drag.

Preview

9:41

Tap a button to preview snap heights

Each button previews a different snapHeight. Tap the backdrop or Escape to dismiss.

Snap heights

ValueHeightBest for
"auto"max-h: 85vh — fits to contentNavigation lists, action menus, short forms
"half"h: 50vh — fixed half screenFilter panels, media pickers, quick views
"full"h: 90vh — near full screenLong forms, detail views, multi-step flows

Implementation

The sheet is portaled to document.body via createPortal — it is never clipped by overflow: hidden ancestors. Body scroll is locked while open. Dismiss handlers cover backdrop click and Escape key.

BottomSheet.tsx
tsx
import { useState } from "react";
import { BottomSheet } from "@/components/ui/BottomSheet";
import { Button } from "@/components/ui/Button";
import { Home, Settings, HelpCircle, ChevronRight } from "lucide-react";

export function Example() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setOpen(true)}>Open sheet</Button>

      <BottomSheet
        open={open}
        onClose={() => setOpen(false)}
        title="Navigation"
        snapHeight="auto"
      >
        <nav className="space-y-1">
          {[
            { label: "Home",     icon: Home },
            { label: "Settings", icon: Settings },
            { label: "Help",     icon: HelpCircle },
          ].map(({ label, icon: Icon }) => (
            <button
              key={label}
              onClick={() => setOpen(false)}
              className="w-full flex items-center gap-3 px-3 py-3 rounded-xl
                         text-[14px] font-medium text-text-secondary
                         hover:bg-surface hover:text-text-primary transition-colors"
            >
              <Icon className="w-4 h-4" />
              <span className="flex-1">{label}</span>
              <ChevronRight className="w-3.5 h-3.5 text-text-tertiary" />
            </button>
          ))}
        </nav>
      </BottomSheet>
    </>
  );
}

Props

PropTypeDefaultDescription
openbooleanControls visibility. Sheet unmounts when false.
onClose() => voidCalled on backdrop tap, Escape key, or close button.
titlestringOptional header title. Renders a title + close button row when provided.
childrenReactNodeContent rendered in the scrollable body.
snapHeight"auto" | "half" | "full""auto"Sheet height. auto = max 85vh; half = 50vh; full = 90vh.
classNamestringExtra classes on the sheet panel.

Mobile

ScenarioGuidance
Drag to dismissAdd a swipe-down gesture handler that maps drag velocity to a close threshold. Most users expect this pattern from native apps — a drag handle without swipe support feels broken.
Virtual keyboardWhen a sheet contains inputs, pair it with Visual Viewport API or use env(keyboard-inset-height) so the sheet shrinks when the keyboard appears rather than being pushed off-screen.
Safe area insetsAdd padding-bottom: env(safe-area-inset-bottom, 0px) inside the sheet content so items aren't obscured by the home indicator on modern iPhones.
Scroll chainingoverscroll-behavior: contain on the sheet body prevents scroll from propagating to the page behind the backdrop while the sheet is open.
Nested navigationFor multi-step flows inside a sheet, push views horizontally within the sheet — don't stack sheets. Stacked sheets are confusing and unexpected on mobile.

Accessibility

  • The sheet panel has role="dialog" and aria-modal="true". Provide aria-label equal to the title, or use aria-labelledby pointing to the title element.
  • Focus must be trapped inside the sheet while it is open. The first focusable element (or the title) should receive focus on open.
  • Escape key dismisses the sheet and returns focus to the trigger. Implement this even if a close button is present.
  • Background content should be inert (aria-hidden) while the sheet is open so screen reader users cannot navigate behind the overlay.
  • The drag handle is decorative — do not add role or aria to it. Provide a close button as the primary dismiss affordance for keyboard/AT users.