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 an interactive wavy Slider scrubber. The waveform moves only while playback runs; pause keeps the seek marker and progress position while smoothly flattening the rail, and resume morphs it back into the wave.
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. Their portal-backed scrim sits above the library's fixed app bars and toolbars, dims the entire viewport, and applies a light backdrop blur. Do not lower your app bar with page-level z-index overrides or target the scrim with DOM selectors.
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.
Bottom sheets also add the document-level bottom safe-area inset to the final scrollable edge or fixed footer. Enable m3-safe-areas on html or body for full-bleed mobile apps; do not add a second pb-safe-* utility to the same sheet edge.
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, type BottomSheetHandle, BottomSheetModal } from "@bug-on/m3-expressive/overlays";
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 } from "@bug-on/m3-expressive/overlays";
import { ScrollArea } from "@bug-on/m3-expressive/layout";
<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 |
|---|---|---|
--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. |
motion | "auto" | "always" | "off" | "auto" | Controls sheet entry, exit, snap and scrim motion. off settles immediately while preserving drag and dismissal semantics. |
maxHeight | string | "90dvh" | Caps natural/fit sizing. Explicit snap points such as "full" and "70%" may exceed it, up to the safe viewport height. |
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 after the sheet settles at a new snap point. |
skipPartiallyExpanded | boolean | false | Removes the "partiallyExpanded" snap point so a modal opens directly at the next available state. |
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. |
BottomSheetScaffold
Persistent standard bottom sheet scaffold matching Android Jetpack Compose BottomSheetScaffold.
| Prop | Type | Default | Description |
|---|---|---|---|
sheetContent | ReactNode | — | Required. Content rendered inside the bottom sheet panel. |
children | (paddingBottom: string) => ReactNode | — | Required. Page body function receiving paddingBottom to clear the sheet peek height. |
sheetPeekHeight | number | 56 | Visible height of the sheet panel when collapsed. |
sheetMaxWidth | string | number | 640px | Maximum width of the sheet panel on large viewports. |
sheetDragHandle | ReactNode | null | <DragHandle /> | Custom drag handle. Pass null to hide the handle. |
sheetSwipeEnabled | boolean | true | Enables swipe gestures to expand and collapse the sheet. |
elevation | 0 | 1 | 2 | 3 | 4 | 5 | 1 | Shadow elevation level (Level 1 is MD3 spec default for standard bottom sheet). |
disableElevation | boolean | false | When true, disables the sheet panel drop shadow (elevation-0). |
topBar | ReactNode | — | Optional top app bar slot rendered above the body content. |
sheetContainerColor | string | — | Background color of the sheet panel (defaults to surfaceContainerLow). |
initialSheetState | 'partiallyExpanded' | 'expanded' | 'partiallyExpanded' | Initial layout state of the sheet. |
onSheetStateChange | (state: 'partiallyExpanded' | 'expanded') => void | — | Callback when the sheet transitions between expanded and collapsed states. |
className | string | — | Additional CSS class for the outer scaffold container. |