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
Navigation
Each button previews a different snapHeight. Tap the backdrop or Escape to dismiss.
Snap heights
| Value | Height | Best for |
|---|---|---|
| "auto" | max-h: 85vh — fits to content | Navigation lists, action menus, short forms |
| "half" | h: 50vh — fixed half screen | Filter panels, media pickers, quick views |
| "full" | h: 90vh — near full screen | Long 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
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controls visibility. Sheet unmounts when false. |
onClose | () => void | — | Called on backdrop tap, Escape key, or close button. |
title | string | — | Optional header title. Renders a title + close button row when provided. |
children | ReactNode | — | Content rendered in the scrollable body. |
snapHeight | "auto" | "half" | "full" | "auto" | Sheet height. auto = max 85vh; half = 50vh; full = 90vh. |
className | string | — | Extra classes on the sheet panel. |
Mobile
| Scenario | Guidance |
|---|---|
| Drag to dismiss | Add 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 keyboard | When 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 insets | Add 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 chaining | overscroll-behavior: contain on the sheet body prevents scroll from propagating to the page behind the backdrop while the sheet is open. |
| Nested navigation | For 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.