BottomSheet
A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows.
Installation
npm install @astryxdesign/core@0.7.1 @astryxdesign/theme-neutral@0.7.1 @stylexjs/stylex@0.19.0
Requires React 19+, base CSS and a Theme provider. See Setup.
Bottom Sheet
A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows.
| Prop | Type | Default | Description |
|---|---|---|---|
| isOpen | boolean | Whether a standalone sheet is open. Fully controlled; pair with onOpenChange. Omit inside BottomSheetSwitcher. | |
| onOpenChange | (isOpen: boolean) => void | For a standalone sheet, called when it requests an open-state change. Automatic calls follow purpose: info dismisses on Escape, scrim click, or swipe; form dismisses on Escape only; required never dismisses implicitly. Omit inside BottomSheetSwitcher. | |
| finalFocusRef | RefObject<HTMLElement | null> | Optional explicit focus-return target for a standalone sheet. Use when the opener can remount or the active element is not a reliable trigger, such as an adaptive presentation switch. Omit inside BottomSheetSwitcher. | |
| purpose | 'required' | 'form' | 'info' | 'info' | Controls implicit dismissal behavior, matching Dialog. info allows Escape, scrim click, and swipe-to-dismiss. form protects entered data by blocking scrim click and swipe while allowing Escape. required blocks every implicit dismissal path and uses role='alertdialog'. Explicit controls may still update the controlled state. Works for standalone and BottomSheetSwitcher-managed sheets. |
| sheetId | string | Unique ID for this sheet inside BottomSheetSwitcher. The switcher opens it when activeSheet matches. Omit isOpen and onOpenChange when sheetId is used. | |
| label * | string | Accessible label for the sheet. Required; the sheet has no built-in heading to derive a name from. | |
| children * | ReactNode | Sheet content in a scrollable area. The named body is keyboard reachable while overflowing. Forward Tab entry may move directly to the first native link or button; input controls, composite widgets, and nested scroll areas retain the body stop. Shift+Tab from a delegated first child skips the body. Fitting content adds no body stop. The internal observed content box preserves block flow and percentage heights. If it includes a text-entry control that can bring up the mobile keyboard, use height='tall' and keep the sheet fully expanded while editing. | |
| padding | 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | Internal padding of the sheet content using the spacing scale, matching Dialog. When omitted, uses the theme default for bottom sheets (--spacing-4 unless the theme sets padding on bottom-sheet). A lone Section child escapes the padding, and bleed children such as Table and Divider compensate against it. Use 0 for edge-to-edge content that supplies its own inset. | |
| height | 'hug' | 'capped' | 'tall' | number | string | 'capped' | How tall the sheet is. Named budgets: 'hug' fits its content up to 92% of the viewport, 'capped' is a scrolling mid-height panel (~62%), and 'tall' is a pinned near-full panel (~92%) for content that streams in. Or pass a number (px) / CSS length for a custom budget. Give snapPoints to let the user drag between heights. On shorter viewports the sheet fills the available height. Only a fully expanded 'tall' sheet provides mobile-keyboard accommodation: it stays put and scrolls each focused control above the keyboard. Hug, Capped, numeric and CSS-length heights never do, and a Tall sheet stops doing it the moment the user drags it to a shorter detent, resuming when they drag it back. Outside that state the sheet neither moves nor adds keyboard scroll space, and the browser's own focus reveal is left in place; on iOS that reveal can shift the whole page. |
| snapPoints | ReadonlyArray<number | string> | Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim. | |
| hasScrim | boolean | true | For a standalone BottomSheet, whether to render a scrim, the semi-transparent overlay that covers and blocks the background. true (default) uses showModal(): top layer, focus trap, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss when purpose='info', with the background inert. false uses show() with no scrim, leaving the page behind interactive and scrollable. For a multi-step flow, configure hasScrim on BottomSheetSwitcher instead; it owns one shared dialog across every child. |
Usage guidance
- Use for mobile-first surfaces (filters, share sheets, quick actions) where the content should rise from the bottom edge.
- Pick the starting height that fits the content: 'hug' for short bounded content, 'capped' for lists, and 'tall' for forms or streaming/resizing content.
- Use purpose='form' to protect entered data from scrim clicks and swipes while keeping Escape available; reserve purpose='required' for flows that must end through an explicit action.
- Avoid: Don't make the sheet content overly long. Consider breaking it into steps and using Bottom Sheet Switcher.
Basic
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Filters">
<FilterControls />
</BottomSheet>
Tall sheet (a list)
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Nearby places"
height="tall">
<PlaceList />
</BottomSheet>
Collapsible to half the screen
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Nearby places"
height="tall"
snapPoints={[0.5]}>
<PlaceList />
</BottomSheet>
A peek, a working height, and full
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Route"
height="tall"
snapPoints={['96px', '50%']}>
<RouteDetails />
</BottomSheet>
Hug height (fits content)
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Share"
height="hug">
<ShareActions />
</BottomSheet>
Long scrolling content
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Release notes"
height="hug">
<ReleaseNotes />
</BottomSheet>
Multi-step flow (one sheet at a time)
const [activeSheet, setActiveSheet] = useState(null);
<>
<Button label="Start" onClick={() => setActiveSheet('details')} />
<BottomSheetSwitcher
activeSheet={activeSheet}
onActiveSheetChange={setActiveSheet}>
<BottomSheet sheetId="details" label="Details">
<Button label="Continue" onClick={() => setActiveSheet('confirm')} />
</BottomSheet>
<BottomSheet sheetId="confirm" label="Confirm">
<Button label="Back" onClick={() => setActiveSheet('details')} />
</BottomSheet>
</BottomSheetSwitcher>
</>
Mobile keyboard
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Add a comment"
height="tall">
<LongCommentForm />
</BottomSheet>
No scrim
const [isOpen, setIsOpen] = useState(true);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
label="Nearby places"
hasScrim={false}>
<PlaceList />
</BottomSheet>
Protect form input
const [isOpen, setIsOpen] = useState(false);
<BottomSheet
isOpen={isOpen}
onOpenChange={setIsOpen}
purpose="form"
label="Edit profile">
<ProfileForm onSave={() => setIsOpen(false)} />
</BottomSheet>
Theming API
{
"container": true,
"targets": [
{
"className": "astryx-bottom-sheet",
"visualProps": []
}
],
"derived": [
{
"property": "padding",
"expand": "container"
}
]
}
Anatomy
- Sheet panel: Painted surface that rises from the bottom edge and contains the sheet.
- Content area: Padded, scrollable container that presents the caller-provided sheet content.
- Handle: Decorative grab affordance and drag region at the top of the panel.
- Scrim: Backdrop that dims and blocks the page in a scrim-backed presentation.
Bottom Sheet Switcher
Coordinates a multi-step bottom-sheet flow in one shared dialog; set activeSheet to a nested BottomSheet's sheetId to open or switch steps, and to null to close.
| Prop | Type | Default | Description |
|---|---|---|---|
| ref | Ref<HTMLDialogElement> | Ref forwarded to the one shared native dialog. | |
| onCancel | (event: SyntheticEvent<HTMLDialogElement>) => void | Called before the switcher handles a native dialog cancel request. Calling preventDefault() keeps the controlled flow open. | |
| activeSheet * | string | null | ID of the interactive BottomSheet, or null when the flow should close. Match a nested BottomSheet's unique sheetId; the previous sheet may remain visually present and inert while the new sheet enters, simultaneously align downward behind a shorter step, then fade away. | |
| onActiveSheetChange * | (activeSheet: string | null) => void | Called with null when the active sheet dismisses according to its purpose. Child BottomSheets may use purpose='form' or purpose='required' to limit implicit dismissal while flow controls can still use the same state setter to switch sheets or close the flow. | |
| hasScrim | boolean | true | Whether the shared dialog is modal. true uses showModal() once for one native ::backdrop, focus trap, scroll lock, and click-to-dismiss when the active BottomSheet has purpose='info'. false uses show() with no backdrop and leaves the page interactive; avoid transformed, contained, or clipping ancestors because the non-modal dialog remains in its containing context. |
| children * | ReactNode | BottomSheets identified by unique sheetId values. |
Usage guidance
- Use when each step depends on the previous one and only one step needs attention at a time.
- Give every child a unique sheetId and non-empty label, choose its purpose to match dismissal requirements, and follow the WAI-ARIA Dialog (Modal) pattern for scrim-backed flows: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/.
- Avoid: Don't split information across sheets when people need to compare it; use a full-page layout that keeps the relevant content visible together instead.
- Avoid: Don't use the switcher when multiple panels must stay interactive or visible together; activeSheet intentionally selects one interactive step.
Three-step flow
const [activeSheet, setActiveSheet] = useState(null);
<>
<Button label="Start" onClick={() => setActiveSheet('details')} />
<BottomSheetSwitcher
activeSheet={activeSheet}
onActiveSheetChange={setActiveSheet}>
<BottomSheet sheetId="details" label="Details" height="hug">
<SetupDetails />
<Button label="Continue" onClick={() => setActiveSheet('preferences')} />
</BottomSheet>
<BottomSheet sheetId="preferences" label="Preferences" height="hug">
<Preferences />
<Button label="Back" onClick={() => setActiveSheet('details')} />
<Button label="Continue" onClick={() => setActiveSheet('confirm')} />
</BottomSheet>
<BottomSheet sheetId="confirm" label="Confirm" height="hug">
<Confirmation />
<Button label="Back" onClick={() => setActiveSheet('preferences')} />
<Button label="Done" onClick={() => setActiveSheet(null)} />
</BottomSheet>
</BottomSheetSwitcher>
</>
Anatomy
- Shared dialog: One native dialog that owns modality, focus, dismissal, and lifecycle for the complete flow.
- Sheet panels: Direct BottomSheet children; exactly one is interactive while a previous panel may remain visible and inert during a handoff.
- Scrim: Native dialog backdrop shown by the default scrim-backed modal presentation.
Examples
- Bottom Sheet: A mobile filter surface that rises from the bottom edge.
- Bottom Sheet Switcher: A three-step flow that transitions between content-hugging sheets of different heights inside one shared dialog.
- Bottom Sheet — Height variants: Compares hug, capped, and tall starting heights for different amounts of content.
- Bottom Sheet — Mobile keyboard: Uses a tall, scrollable form that keeps focused controls visible above the mobile keyboard.
- Bottom Sheet — No scrim: Keeps the page visible and interactive behind a non-modal bottom sheet.
- Bottom Sheet — Snap points: Drag-to-resize stops: a half-height working surface, and a peek that slides away and thins the scrim.
- Bottom Sheet Switcher — Review flow: A two-step form flow that lets a person review settings, confirm them, or move back without replacing the shared dialog.
Complete example dependencies
Some examples import optional icons. The per-example install command in the examples list identifies exactly what that example needs. For all examples on this page:
npm install @heroicons/react@2.2.0