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 nested content that can scroll independently when the sheet is fully expanded.

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.

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.

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 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
--sheet-vh100dvhReference viewport height used by the sheet. It is calculated dynamically on mobile to avoid browser UI resizing issues.
--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
isOpenbooleanRequired. Controlled open state.
onClose() => voidRequired. Called when the sheet should close.
maxHeightstring"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.
hideHandlebooleanfalseHides the default drag handle visual indicator.
headerReactNodeFixed content rendered above the scrollable body.
footerReactNodeFixed 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 | numberInitial active snap point when the sheet opens.
onSnapChange(snapPoint: string | number) => voidCalled when the sheet reaches a new snap point.
gesturesEnabledbooleantrueEnables or disables drag and swipe gestures for the sheet.
containerColorstringOverrides 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.
classNamestringCustom Tailwind classes applied to the sheet container.
aria-labelledbystringAssociates the sheet with a visible title element.
aria-labelstringAccessible label used when no visible title is present.