Bottom sheets
Bottom sheets show secondary content anchored to the bottom of the screen.
Bottom sheets are transient surfaces anchored to the bottom edge of the viewport. They work well for supplemental actions, short workflows, media controls, and other content that should stay close to the current screen context.
Introduction
Material Design 3 Expressive bottom sheets slide up from the bottom edge with spring-based motion and a rounded top container. This codebase supports three sheet presentations:
- Standard bottom sheets, which coexist with the main screen content.
- Modal bottom sheets, which block background interaction with a scrim.
- Responsive adaptive sheets, which switch between bottom and side sheet patterns depending on viewport size.
Anatomy
- Container: the main surface that holds the sheet content.
- Drag handle: the visual affordance used to drag, snap, or dismiss the sheet.
- Header: fixed content at the top of the sheet body.
- Scroll area: the internal body viewport used for long content.
- Footer: fixed content at the bottom of the sheet body.
- Scrim: the modal-only overlay that blocks background interaction.
Variants
Standard Bottom Sheet
Standard bottom sheets remain visible alongside the main UI. They are a good fit for persistent controls such as music players, filters, and lightweight contextual panels.
The demo below shows a music player with snap points, drag gestures, a keyboard-accessible handle, and nested content that can scroll independently when the sheet is fully expanded.
Modal Bottom Sheet
Modal bottom sheets appear in front of the app content and require the user to dismiss them or complete the task before returning to the underlying screen.
The demo below presents a share sheet with interactive contact cards, app shortcuts, and a scrim-backed modal layout.
Responsive Adaptive Sheet
Responsive adaptive sheets follow the available viewport. On small screens they render as a bottom sheet modal, and on larger screens they can transition to a side sheet modal.
Features
Expressive Motion
Bottom sheets use spring-based motion for opening and closing so the interaction feels physical rather than linear. The current implementation uses the shared spatial spring token for sheet movement.
Drag Handle and Snap Cycle
The drag handle is the primary control for height changes. Clicking it, pressing Space, or pressing Enter cycles through the configured snap points. Dragging the handle or sheet body moves between snap points, and a downward drag can dismiss the sheet when the current state allows it.
Nested Scroll and ScrollArea
Bottom sheet bodies already render inside the built-in ScrollArea. That means long content can scroll without breaking the sheet layout. When you add a nested scroll region inside the sheet, keep it vertical-only and prevent horizontal overflow. That is the recommended pattern for content like lyrics, long lists, or compact reference blocks.
Virtual Viewport
The sheet adapts to the browser visual viewport so mobile keyboard transitions do not clip content or leave controls out of reach.
Snap Points
Snap points let the sheet move between multiple heights such as fit, 70%, and full. When snap points are active, the sheet expands first and only allows content scrolling after it reaches the highest snap point. This keeps drag gestures predictable and prevents the content area from stealing the sheet gesture too early.
Usage
Basic controlled setup
import {
BottomSheet,
BottomSheetModal,
type BottomSheetHandle,
} from "@bug-on/m3-expressive";
import { useRef, useState } from "react";
export function Example() {
const [sheetOpen, setSheetOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const sheetRef = useRef<BottomSheetHandle | null>(null);
return (
<>
<button type="button" onClick={() => setSheetOpen(true)}>
Open standard sheet
</button>
<button type="button" onClick={() => setModalOpen(true)}>
Open modal sheet
</button>
<BottomSheet
ref={sheetRef}
isOpen={sheetOpen}
onClose={() => setSheetOpen(false)}
aria-labelledby="music-sheet-title"
snapPoints={["fit", "70%", "full"]}
defaultSnapPoint="fit"
gesturesEnabled
sheetMaxWidth={640}
containerColor="var(--color-m3-surface-container-low)"
scrollAreaProps={{
type: "hover",
viewportClassName: "overflow-x-hidden",
}}
header={<h2 id="music-sheet-title">Now playing</h2>}
footer={
<button type="button" onClick={() => sheetRef.current?.snapTo("full")}>
Expand to full
</button>
}
>
<div className="space-y-4">
<p>Sheet body content goes here.</p>
</div>
</BottomSheet>
<BottomSheetModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
aria-label="Share options"
snapPoints={["fit", "full"]}
defaultSnapPoint="fit"
gesturesEnabled
hideHandle={false}
>
<div className="space-y-4">
<p>Modal sheet content goes here.</p>
</div>
</BottomSheetModal>
</>
);
}
Nested vertical scroll
Use a nested ScrollArea when a specific subregion needs its own scrolling, such as a lyrics block. Keep the nested area vertical-only, and avoid transforms or layout tricks that can create horizontal overflow.
import { BottomSheet, ScrollArea } from "@bug-on/m3-expressive";
<BottomSheet isOpen={open} onClose={() => setOpen(false)} snapPoints={["fit", "full"]}>
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wide">Lyrics</h3>
<ScrollArea
orientation="vertical"
type="hover"
className="h-36 w-full min-w-0 overflow-x-hidden"
viewportClassName="overflow-x-hidden pr-3"
aria-label="Lyrics"
>
<div className="min-w-0 w-full space-y-2">
<p className="w-full min-w-0 truncate">First lyric line</p>
<p className="w-full min-w-0 truncate">Second lyric line</p>
<p className="w-full min-w-0 truncate">Third lyric line</p>
</div>
</ScrollArea>
</div>
</BottomSheet>
Best Practices
Do
- Use modal bottom sheets for temporary tasks on mobile.
- Keep the drag handle visible when the sheet supports drag gestures.
- Use
snapPointsanddefaultSnapPointwhen a sheet needs multiple heights. - Use
ScrollAreafor focused nested scrolling regions inside the sheet body. - Keep nested content width bounded with
w-full,min-w-0, andoverflow-x-hidden.
Don't
- Don't stack multiple bottom sheets on top of each other.
- Don't rely on horizontal scrolling inside a bottom sheet.
- Don't use oversized transforms or scaling on text rows if they can expand the content width.
- Don't block desktop navigation with a bottom sheet if a side sheet is the better fit.
Design Tokens
Shape
| Token | Value | Applied To |
|---|---|---|
--md-sys-shape-corner-extra-large | 28px | Top-left and top-right container corners |
Motion
| Animation | Spring Token | Tier |
|---|---|---|
| Slide Up / Down | DEFAULT_SPATIAL_SPRING | Default |
CSS Customization
You can customize the layout and responsiveness of the bottom sheet using global CSS variables:
| Variable | Default | Description |
|---|---|---|
--sheet-vh | 100dvh | Reference viewport height used by the sheet. It is calculated dynamically on mobile to avoid browser UI resizing issues. |
--m3-bottom-sheet-max-width | 640px | Maximum width of the sheet on wide viewports. You can override it globally or with the sheetMaxWidth prop. |
Accessibility
- Keyboard support: modal sheets trap focus, and
EscapetriggersonClose. - Screen readers: modal sheets use
role="dialog"andaria-modal="true", while standard sheets usearia-modal="false". - Scroll locking: modal sheets lock background scrolling so pointer and touch input stay inside the dialog.
- Drag handle accessibility: the handle stays keyboard interactive when it controls snap cycling.
API Reference
BottomSheet / BottomSheetModal
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | — | Required. Controlled open state. |
onClose | () => void | — | Required. Called when the sheet should close. |
maxHeight | string | "90dvh" | Maximum height cap for the sheet container. When snap points are active, the default value expands to the full available viewport height so higher snap states can be revealed. |
hideHandle | boolean | false | Hides the default drag handle visual indicator. |
header | ReactNode | — | Fixed content rendered above the scrollable body. |
footer | ReactNode | — | Fixed content rendered below the scrollable body. |
divider | boolean | { header?: boolean; footer?: boolean } | true | Controls divider visibility between the fixed regions and the body. |
snapPoints | ('fit' | 'max-content' | 'full' | string | number)[] | — | Ordered snap points for the sheet height. |
defaultSnapPoint | 'fit' | 'max-content' | 'full' | string | number | — | Initial active snap point when the sheet opens. |
onSnapChange | (snapPoint: string | number) => void | — | Called when the sheet reaches a new snap point. |
gesturesEnabled | boolean | true | Enables or disables drag and swipe gestures for the sheet. |
containerColor | string | — | Overrides the sheet background color. |
sheetMaxWidth | string | number | 640px | Maximum width of the sheet on large screens. |
scrollAreaProps | Omit<ScrollAreaProps, "children"> | — | Props forwarded to the internal Radix ScrollArea, including root and viewport class names, viewport props, and scrollbar behavior. |
className | string | — | Custom Tailwind classes applied to the sheet container. |
aria-labelledby | string | — | Associates the sheet with a visible title element. |
aria-label | string | — | Accessible label used when no visible title is present. |