MD3
Expressive
MATERIAL DESIGN 3 EXPRESSIVE

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.

Loading demo...

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.

Loading demo...

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.

Loading demo...

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

tsx
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.

tsx
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 snapPoints and defaultSnapPoint when a sheet needs multiple heights.
  • Use ScrollArea for focused nested scrolling regions inside the sheet body.
  • Keep nested content width bounded with w-full, min-w-0, and overflow-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

TokenValueApplied To
--md-sys-shape-corner-extra-large28pxTop-left and top-right container corners

Motion

AnimationSpring TokenTier
Slide Up / DownDEFAULT_SPATIAL_SPRINGDefault

CSS Customization

You can customize the layout and responsiveness of the bottom sheet using global CSS variables:

VariableDefaultDescription
--m3-bottom-sheet-max-width640pxMaximum 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 Escape triggers onClose.
  • Screen readers: modal sheets use role="dialog" and aria-modal="true", while standard sheets use aria-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

PropTypeDefaultDescription
isOpenboolean—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.
maxHeightstring"90dvh"Caps natural/fit sizing. Explicit snap points such as "full" and "70%" may exceed it, up to the safe viewport height.
hideHandlebooleanfalseHides the default drag handle visual indicator.
headerReactNode—Fixed content rendered above the scrollable body.
footerReactNode—Fixed content rendered below the scrollable body.
dividerboolean | { header?: boolean; footer?: boolean }trueControls 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.
skipPartiallyExpandedbooleanfalseRemoves the "partiallyExpanded" snap point so a modal opens directly at the next available state.
gesturesEnabledbooleantrueEnables or disables drag and swipe gestures for the sheet.
containerColorstring—Overrides the sheet background color.
sheetMaxWidthstring | number640pxMaximum width of the sheet on large screens.
scrollAreaPropsOmit<ScrollAreaProps, "children">—Props forwarded to the internal Radix ScrollArea, including root and viewport class names, viewport props, and scrollbar behavior.
classNamestring—Custom Tailwind classes applied to the sheet container.
aria-labelledbystring—Associates the sheet with a visible title element.
aria-labelstring—Accessible label used when no visible title is present.

BottomSheetScaffold

Persistent standard bottom sheet scaffold matching Android Jetpack Compose BottomSheetScaffold.

PropTypeDefaultDescription
sheetContentReactNode—Required. Content rendered inside the bottom sheet panel.
children(paddingBottom: string) => ReactNode—Required. Page body function receiving paddingBottom to clear the sheet peek height.
sheetPeekHeightnumber56Visible height of the sheet panel when collapsed.
sheetMaxWidthstring | number640pxMaximum width of the sheet panel on large viewports.
sheetDragHandleReactNode | null<DragHandle />Custom drag handle. Pass null to hide the handle.
sheetSwipeEnabledbooleantrueEnables swipe gestures to expand and collapse the sheet.
elevation0 | 1 | 2 | 3 | 4 | 51Shadow elevation level (Level 1 is MD3 spec default for standard bottom sheet).
disableElevationbooleanfalseWhen true, disables the sheet panel drop shadow (elevation-0).
topBarReactNode—Optional top app bar slot rendered above the body content.
sheetContainerColorstring—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.
classNamestring—Additional CSS class for the outer scaffold container.