Containment
Bottom sheets
A surface anchored to the bottom of the window, standard or modal, that people drag between heights.
- COMPONENT_SPEC §30
- CSS · custom elements · React
- Open in the playground
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.
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.
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 namedlabel) ormodal(ModalBottomSheet: a<dialog>namedlabelover a scrim).value(reflected):hidden(default),partially-expanded,expanded.open(reflected): whethervalueis nothidden; setting it shows or hides the sheet.skip-partially-expandeddrops the partially expanded state.peek-height(px; empty: 56, standard only) makes PartiallyExpanded show that much of the sheet (BottomSheetScaffold's sheetPeekHeight);skip-hidden-statethen drops Hidden.no-drag-handle(ComposedragHandle = 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 thetooltip-popup.jsentry when the handle is first rendered (outside this entry's D17 budget). The tooltip repeats the handle's name, so it isaria-hidden(owner decision M9 #2). - Methods
show(),hide(),expand(),partialExpand()(Compose SheetState);ready. Eventsvalue-change(detail: { value }), cancelable when the sheet asks (the handle, a drag, Escape, the scrim, the methods):preventDefault()keeps it (Compose confirmValueChange;hiddenis 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.