JGuang

BottomSheet

BottomSheet · Astryx

Preview
npm install @astryxdesign/core@0.7.1 @astryxdesign/theme-neutral@0.7.1 @stylexjs/stylex@0.19.0

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.

PropTypeDefaultDescription
isOpenbooleanWhether a standalone sheet is open. Fully controlled; pair with onOpenChange. Omit inside BottomSheetSwitcher.
onOpenChange(isOpen: boolean) => voidFor 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.
finalFocusRefRefObject<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.
sheetIdstringUnique ID for this sheet inside BottomSheetSwitcher. The switcher opens it when activeSheet matches. Omit isOpen and onOpenChange when sheetId is used.
label *stringAccessible label for the sheet. Required; the sheet has no built-in heading to derive a name from.
children *ReactNodeSheet 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.
padding0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10Internal 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.
snapPointsReadonlyArray<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.
hasScrimbooleantrueFor 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.

PropTypeDefaultDescription
refRef<HTMLDialogElement>Ref forwarded to the one shared native dialog.
onCancel(event: SyntheticEvent<HTMLDialogElement>) => voidCalled before the switcher handles a native dialog cancel request. Calling preventDefault() keeps the controlled flow open.
activeSheet *string | nullID 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) => voidCalled 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.
hasScrimbooleantrueWhether 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 *ReactNodeBottomSheets 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