Skip to content

Containment

Bottom sheets

A surface anchored to the bottom of the window, standard or modal, that people drag between heights.

Examples

Modal bottom sheet

Over a scrim: drag it down, press Escape or the scrim to hide it.

The live demo runs with JavaScript. Its code is below.

React HTML
import { useState } from 'react';
import { Button, Icon, List, ListItem, ModalBottomSheet } from '@kyralabs/m3exp-web/react';

export default function Modal() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button
        variant="tonal"
        onPress={() => {
          setOpen(true);
        }}
      >
        Share
      </Button>
      <ModalBottomSheet isOpen={open} onOpenChange={setOpen} aria-label="Share">
        <List
          aria-label="Share to"
          onAction={() => {
            setOpen(false);
          }}
        >
          <ListItem id="mail" leadingContent={<Icon name="mail" />}>
            Email
          </ListItem>
          <ListItem id="link" leadingContent={<Icon name="link" />}>
            Copy link
          </ListItem>
          <ListItem id="chat" leadingContent={<Icon name="chat" />}>
            Message
          </ListItem>
        </List>
      </ModalBottomSheet>
    </>
  );
}
<button type="button" class="m3e-button m3e-button--tonal" id="open-share">Share</button>
<m3e-bottom-sheet id="share" variant="modal" label="Share">
  <m3e-list label="Share to">
    <div class="m3e-list-item" data-value="mail">
      <span class="m3e-list-item__leading"
        ><span class="m3e-icon" aria-hidden="true">mail</span></span
      >
      <span class="m3e-list-item__content"><span class="m3e-list-item__headline">Email</span></span>
    </div>
    <div class="m3e-list-item" data-value="link">
      <span class="m3e-list-item__leading"
        ><span class="m3e-icon" aria-hidden="true">link</span></span
      >
      <span class="m3e-list-item__content"
        ><span class="m3e-list-item__headline">Copy link</span></span
      >
    </div>
  </m3e-list>
</m3e-bottom-sheet>

<script type="module">
  const sheet = document.getElementById('share');
  document.getElementById('open-share').addEventListener('click', () => sheet.show());
  sheet.addEventListener('action', () => sheet.hide());
</script>

Run the HTML version (custom elements, no framework).

Standard bottom sheet

Fixed to the bottom of its positioned container, here with a peek height and no hidden state.

The live demo runs with JavaScript. Its code is below.

React HTML
import { BottomSheet } from '@kyralabs/m3exp-web/react';

export default function Standard() {
  return (
    <div
      style={{
        position: 'relative',
        inlineSize: '100%',
        blockSize: 'var(--m3e-sys-window-height-medium)',
        overflow: 'hidden',
      }}
    >
      <p>Drag the handle, or focus it and press the arrow keys.</p>
      <BottomSheet
        aria-label="Details"
        defaultValue="partially-expanded"
        peekHeight
        skipHiddenState
      >
        <p style={{ paddingInline: 'var(--m3e-sys-space-300)' }}>
          A standard sheet stays in the layout: the page behind it is still usable.
        </p>
      </BottomSheet>
    </div>
  );
}
<div
  style="
    position: relative;
    inline-size: 100%;
    block-size: var(--m3e-sys-window-height-medium);
    overflow: hidden;
  "
>
  <p>Drag the handle, or focus it and press the arrow keys.</p>
  <m3e-bottom-sheet value="partially-expanded" peek-height skip-hidden-state label="Details">
    <p style="padding-inline: var(--m3e-sys-space-300)">
      A standard sheet stays in the layout: the page behind it is still usable.
    </p>
  </m3e-bottom-sheet>
</div>

Run the HTML version (custom elements, no framework).

API

React

<BottomSheet>

Standard bottom sheet (COMPONENT_SPEC §30; Compose BottomSheet, owner decision M9 #13): fixed to the bottom of its positioned container (position: relative), at most 640dp wide and centered, a region named "Bottom Sheet". Driven by m3e-elements' BottomSheetLayout (the code <m3e-bottom-sheet> runs): Compose's anchors and springs, drags with its thresholds, the drag handle (a click and the arrow keys move between the values; its built-in tooltip). Other props go to the root.

Prop Type Default Description
value "hidden" | "partially-expanded" | "expanded" The value (controlled): hidden, partially-expanded or expanded.
defaultValue "hidden" | "partially-expanded" | "expanded" The initial value (uncontrolled; default hidden, Compose's rememberBottomSheetState).
peekHeight number | boolean BottomSheetScaffold's sheetPeekHeight, px (true: 56dp, the default): PartiallyExpanded shows this much of the sheet (owner decision M9 #13).
skipHiddenState boolean With a peek height: no Hidden state (BottomSheetScaffold's default).
skipPartiallyExpanded boolean No PartiallyExpanded state (Compose enabledValues without it).
dragHandle boolean Render the drag handle (Compose dragHandle; default true).
dragHandleLabel string The drag handle's name and tooltip (default "Drag handle", Compose's description).
gesturesEnabled boolean Drags and the handle's arrow keys move the sheet (Compose gesturesEnabled; default true).
aria-label string The sheet's name (default "Bottom Sheet", Compose's pane title).
children ReactNode The content (Compose content).
onValueChange (value: SheetValue) => void The sheet asks for a value: the drag handle, a drag, Escape (Compose confirmValueChange; hidden is onDismissRequest). Controlled sheets move when value does.

Also accepts HTML attributes not listed here.

<ModalBottomSheet>

Modal bottom sheet (COMPONENT_SPEC §30; Compose ModalBottomSheet): a native <dialog> named "Bottom Sheet" over a scrim, opened with showModal() (the page inert, scroll locked, Tab kept inside, focus on the drag handle and back to the opener), entering partially expanded (or expanded with skipPartiallyExpanded); Escape partially expands an expanded sheet, else hides it, as the scrim does. Same drags and handle as <BottomSheet>. Other props go to the <dialog>.

Prop Type Default Description
isOpen boolean Shown (controlled).
defaultOpen boolean Initially shown (uncontrolled).
shouldDismissOnClickOutside boolean A press on the scrim hides it (ModalBottomSheetProperties.shouldDismissOnClickOutside).
skipPartiallyExpanded boolean No PartiallyExpanded state (Compose enabledValues without it).
dragHandle boolean Render the drag handle (Compose dragHandle; default true).
dragHandleLabel string The drag handle's name and tooltip (default "Drag handle", Compose's description).
gesturesEnabled boolean Drags and the handle's arrow keys move the sheet (Compose gesturesEnabled; default true).
aria-label string The sheet's name (default "Bottom Sheet", Compose's pane title).
children ReactNode The content (Compose content).
onOpenChange (isOpen: boolean) => void Called with false when the sheet asks to hide (the handle of an expanded sheet, a drag, Escape, the scrim: Compose's onDismissRequest). Controlled sheets hide when isOpen does.
onValueChange (value: SheetValue) => void The value changed (expanded / partially expanded / hidden), e.g. through the handle.

Also accepts HTML attributes not listed here.

Custom elements

<m3e-bottom-sheet>

import '@kyralabs/m3e-elements/bottom-sheet.js';

<m3e-bottom-sheet variant="modal" value="partially-expanded">…</m3e-bottom-sheet> (COMPONENT_SPEC §30; Compose BottomSheet / ModalBottomSheet). Renders the m3e-css .m3e-bottom-sheet contract inside itself (the host is display: contents; m3e-css places the sheet at rest from its value) and runs BottomSheetLayout (shared with m3e-react): the anchors and springs, drags, the drag handle, and for the modal sheet the dialog window. The layout comes from the bottom-sheet-layout.js entry, imported when the element first renders (D17: the two are measured apart; ready resolves once it runs); a request made before (a method) still asks and sets value, which the layout then starts from.

  • variant: standard (default; Compose BottomSheet: fixed to the bottom of its positioned container, a region named label) or modal (ModalBottomSheet: a <dialog> named label over a scrim).
  • value (reflected): hidden (default), partially-expanded, expanded. open (reflected): whether value is not hidden; setting it shows or hides the sheet. skip-partially-expanded drops the partially expanded state. peek-height (px; empty: 56, standard only) makes PartiallyExpanded show that much of the sheet (BottomSheetScaffold's sheetPeekHeight); skip-hidden-state then drops Hidden.
  • no-drag-handle (Compose dragHandle = null), drag-handle-label (default "Drag handle"), gestures-disabled (no drags or handle arrow keys), label (default "Bottom Sheet").
  • The child nodes are the content. The drag handle carries the plain tooltip Compose builds in (owner decision M9 #30): <m3e-tooltip>'s TooltipController, imported from the tooltip-popup.js entry when the handle is first rendered (outside this entry's D17 budget). The tooltip repeats the handle's name, so it is aria-hidden (owner decision M9 #2).
  • Methods show(), hide(), expand(), partialExpand() (Compose SheetState); ready. Events value-change (detail: { value }), cancelable when the sheet asks (the handle, a drag, Escape, the scrim, the methods): preventDefault() keeps it (Compose confirmValueChange; hidden is onDismissRequest); then, when that shows or hides the sheet, open-change (detail: { open }, as the other modal elements; cancelable the same way).
Attribute Property Type Notes
variant variant string Reflected.
value value string Reflected.
open open boolean Reflected. Whether the sheet shows (reflected): value is not hidden. Setting it shows the sheet (as show() would pick: partially expanded, or expanded when that is skipped) or hides it, like setting value (no events).
skip-partially-expanded skipPartiallyExpanded boolean
peek-height peekHeight string
skip-hidden-state skipHiddenState boolean
no-drag-handle noDragHandle boolean
drag-handle-label dragHandleLabel string
gestures-disabled gesturesDisabled boolean
label label string
Event Detail Description
value-change SheetValueChangeDetail The sheet asks for a value; cancelable (Compose confirmValueChange).
open-change OpenChangeDetail After value-change, when it shows or hides the sheet; cancelable.

CSS

bottom-sheet.css

@import '@kyralabs/m3e-css/bottom-sheet.css';

Bottom sheets (COMPONENT_SPEC §30): the standard sheet (Compose BottomSheet, fixed to the bottom of its container; owner decision M9 #13) and the modal sheet (Compose ModalBottomSheet, a dialog over a scrim). CSS places a sheet at rest; the m3e-elements bottom sheet layout (<m3e-bottom-sheet>, m3e-react's <BottomSheet> / <ModalBottomSheet>) measures it, springs it between its anchors, follows drags and runs the drag handle.

<div class="m3e-bottom-sheet m3e-bottom-sheet--partially-expanded">   (standard)
  <div class="m3e-bottom-sheet__sheet" role="region" aria-label="Bottom Sheet">
    <button class="m3e-bottom-sheet__handle" type="button" aria-label="Drag handle">
      <span class="m3e-bottom-sheet__handle-bar"></span></button>
    <div class="m3e-tooltip" role="tooltip" popover="manual">Drag handle</div>  (optional)
    <div class="m3e-bottom-sheet__content">…</div>
  </div>
</div>

<dialog class="m3e-bottom-sheet m3e-bottom-sheet--modal m3e-bottom-sheet--expanded"
  aria-label="Bottom Sheet">                                                   (modal)
  <div class="m3e-bottom-sheet__scrim" aria-hidden="true"></div>
  <div class="m3e-bottom-sheet__sheet">…</div>
</dialog>
  • The value (SheetValue): --expanded (the whole sheet, at most its container), --partially-
expanded (half the container, or the whole sheet when it is shorter), neither: hidden. With
--peek, PartiallyExpanded shows --m3e-comp-bottom-sheet-peek-height (56dp; BottomSheetScaffold's
sheetPeekHeight, the standard sheet's peek option).
  • Standard: the root fills its positioned container (position: relative) and clips the sheet;
only the sheet takes the pointer. Modal: a native <dialog> opened with showModal() (the page
inert), the scrim a child (::backdrop stays transparent).
  • The sheet is at most 640dp wide and centered, flush with the bottom, no side or top margins
(owner decision M9 #12: Compose, not m3.material.io's 72 / 56dp margins).
  • The handle is a 32 × 48dp button (the 32 × 4dp bar and 22dp above and below it) with a 48dp
target; the content scrolls when the sheet reaches its container height.
  • JS hooks: [data-animating] while it moves (the layout writes --_m3e-bs-translate, the offset
from its rest at the bottom, --_m3e-bs-stretch, the overshoot above the Expanded anchor, and,
modal, --_m3e-bs-scrim, the scrim alpha), [data-dragging] during a drag, and [data-scrolls]
while the content overflows an expanded sheet (touch then scrolls it instead of dragging).
16 custom properties it reads
Custom property Default
--m3e-comp-bottom-sheet-drag-handle-padding-vertical 22px
--m3e-comp-bottom-sheet-drag-handle-shape var(--m3e-sys-shape-corner-extra-large)
--m3e-comp-bottom-sheet-max-width 640px
--m3e-comp-bottom-sheet-peek-height 56px
--m3e-comp-bottom-sheet-scrim-color var(--m3e-sys-color-scrim)
--m3e-comp-bottom-sheet-scrim-opacity 0.32
--m3e-comp-divider-thickness 1px
--m3e-comp-minimum-interactive-component-size 48px
--m3e-comp-sheet-bottom-docked-container-color var(--m3e-sys-color-surface-container-low)
--m3e-comp-sheet-bottom-docked-container-shape-end-end var(--m3e-sys-shape-corner-extra-large-top-end-end)
--m3e-comp-sheet-bottom-docked-container-shape-end-start var(--m3e-sys-shape-corner-extra-large-top-end-start)
--m3e-comp-sheet-bottom-docked-container-shape-start-end var(--m3e-sys-shape-corner-extra-large-top-start-end)
--m3e-comp-sheet-bottom-docked-container-shape-start-start var(--m3e-sys-shape-corner-extra-large-top-start-start)
--m3e-comp-sheet-bottom-docked-drag-handle-color var(--m3e-sys-color-on-surface-variant)
--m3e-comp-sheet-bottom-docked-drag-handle-height 4px
--m3e-comp-sheet-bottom-docked-drag-handle-width 32px

Accessibility

modal = <dialog> (Compose's dialog() semantics, ModalBottomSheet.android.kt L254) named "Bottom Sheet" (label); standard = role="region" named "Bottom Sheet"; the handle is a button named "Drag handle" (drag-handle-label) cycling the values, with aria-keyshortcuts="ArrowUp ArrowDown" while gestures are enabled (DECISIONS D20 (9)); its tooltip repeats the name, so it is aria-hidden (owner decision M9 #2); the scrim is aria-hidden (Compose's "Close sheet" scrim description is not needed: Escape and the handle dismiss from the keyboard).

Every interactive part has a target of at least 48 × 48 px, a visible focus ring for keyboard focus only, and works under forced-colors and prefers-reduced-motion.

Differences from Compose

no m3.material.io margins (owner decision M9 #12, SOURCES #77); the release velocity is the drawer's trailing-sample average, not VelocityTracker's fit; the overshoot fill is a shadow, not a scale; nested scroll is approximated (touch on an expanded sheet's overflowing content scrolls it, so such a sheet collapses from its handle or once its content is at the top with a mouse or pen; the content keeps the browser's own momentum, which Compose hands back to the sheet at the top); no predictive back; the first Escape on a focused handle hides its tooltip (the shared tooltip's Escape), the next one acts on the sheet; the handle's arrow keys stand in for Compose's accessibility actions.

Sources

Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from m3.material.io, as cited below from COMPONENT_SPEC §30 and the m3e-css sources.

SheetDefaults.kt, BottomSheet.kt, ModalBottomSheet.kt, BottomSheetScaffold.kt, Scrim.kt, ComposeMaterial3Flags.kt, ModalBottomSheet.android.kt, BottomSheetSamples.kt, SheetBottomTokens.kt, ScrimTokens.kt, material3 res strings.xml L79-87, compose/ui res strings.xml L46; foundation gestures/AnchoredDraggable.kt, gestures/snapping/SnapFlingBehavior.kt; the device tests AnchoredDraggableStateTest.kt, BottomSheetTest.kt, ModalBottomSheetTest.kt, SheetStateTest.kt (cases ported as reference tests; all vendored); m3.material.io Bottom sheets (Specs, Accessibility; token DB md.comp.sheet.bottom).

SheetBottomTokens.kt@19991f2c (generated sheet-bottom.css) as read by SheetDefaults.kt / BottomSheet.kt, ScrimTokens.kt (scrim.css), plus components/bottom-sheet-defaults.css (PARITY.md lines):

bottom-sheet.css sources

(compose/material3 @ 19991f2c; values: sheet-bottom.css, bottom-sheet-defaults.css, lines in PARITY.md):

  • BottomSheet.kt BottomSheetImpl (L188-406): a Surface widthIn(max = SheetMaxWidth) in ExpandedShape / ContainerColor with no shadow; anchors Hidden at the container height, PartiallyExpanded at container − min(container / 2, sheet) (the deterministic flag, on), Expanded at container − sheet; verticalScaleUp stretches the surface when a spring overshoots (here a container-colored shadow below it, as the drawer).
  • SheetDefaults.kt BottomSheetDefaults.DragHandle (L571-589) in DragHandleWithTooltip (L594-608): the bar in DockedDragHandleColor and shapes.extraLarge, 22dp vertical padding, centered, a plain tooltip above it (owner decision M9 #30).
  • ModalBottomSheet.kt (L96-188): the Scrim (ScrimTokens color at ContainerOpacity, alpha on DefaultEffects) under the sheet, aligned TopCenter in a dialog window.
  • BottomSheetScaffold.kt StandardBottomSheet (L297-362): PartiallyExpanded at the peek height.