EXEPERTAI LAB

Research alpha

Time Machine
EXEPERTAI LAB
GALLERY / COLLECTION
← Browse Astryx gallery
BottomSheet·component·@astryxdesign/core

Bottom Sheet

A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. A standalone sheet owns a native <dialog>; inside BottomSheetSwitcher it renders a panel in the switcher's shared dialog. In both modes, ref and shared DOM props target the visual panel <div>.

Open in Playground @astryxdesign/core/BottomSheet

Showcases and examples

9 documented examples

Dialog — Adaptive presentation

Opt-in recipe for an AdaptiveDialog wrapper: Dialog remains the default everywhere, while touchPresentation="bottom-sheet" switches only at lg and below when pointer is coarse and hover is unavailable. Includes a deterministic presentation override for tests/unusual environments and notes that BottomSheet purpose controls swipe and scrim dismissal. Usage examples: touchPresentation="dialog" keeps Dialog, "fullscreen" chooses fullscreen Dialog, and "bottom-sheet" chooses BottomSheet only for the touch-oriented range. Keep presentation as the deterministic override. Do not use this by default for AlertDialog or destructive confirmations.

Preview loads on approachPreview loads on approach
Exact source · dialog-adaptive-presentation
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {useState, type ReactNode} from 'react';
import {BottomSheet} from '@astryxdesign/core/BottomSheet';
import {Button} from '@astryxdesign/core/Button';
import {
  Dialog,
  DialogHeader,
  type DialogPurpose,
} from '@astryxdesign/core/Dialog';
import {Heading} from '@astryxdesign/core/Heading';
import {
  HStack,
  Layout,
  LayoutContent,
  LayoutFooter,
  VStack,
} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {TextArea} from '@astryxdesign/core/TextArea';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useMediaQuery} from '@astryxdesign/core/hooks';

const TOUCH_ORIENTED_LG_QUERY =
  '(max-width: 1024px) and (pointer: coarse) and (hover: none)';

type AdaptivePresentation = 'dialog' | 'fullscreen' | 'bottom-sheet';

type AdaptiveDialogProps = {
  isOpen: boolean;
  onOpenChange: (isOpen: boolean) => void;
  title: string;
  children: ReactNode;
  footer?: ReactNode;
  purpose?: DialogPurpose;
  width?: number | string;
  maxHeight?: number | string;
  touchPresentation?: AdaptivePresentation;
  presentation?: AdaptivePresentation;
  bottomSheetHeight?: 'hug' | 'capped' | 'tall' | number | string;
};

function AdaptiveDialog({
  isOpen,
  onOpenChange,
  title,
  children,
  footer,
  purpose = 'info',
  width = 480,
  maxHeight = '75dvh',
  touchPresentation = 'dialog',
  presentation,
  bottomSheetHeight = 'capped',
}: AdaptiveDialogProps) {
  const isTouchOrientedLargeOrBelow = useMediaQuery(TOUCH_ORIENTED_LG_QUERY);
  const resolvedPresentation =
    presentation ??
    (isTouchOrientedLargeOrBelow ? touchPresentation : 'dialog');

  if (resolvedPresentation === 'bottom-sheet') {
    return (
      <BottomSheet
        isOpen={isOpen}
        onOpenChange={onOpenChange}
        label={title}
        purpose={purpose}
        height={bottomSheetHeight}>
        <VStack gap={4} style={{padding: 'var(--spacing-4)'}}>
          <Heading level={3}>{title}</Heading>
          {children}
          {footer}
        </VStack>
      </BottomSheet>
    );
  }

  const dialogContent = (
    <Layout
      header={<DialogHeader title={title} onOpenChange={onOpenChange} />}
      content={<LayoutContent>{children}</LayoutContent>}
      footer={footer ? <LayoutFooter>{footer}</LayoutFooter> : undefined}
    />
  );

  return (
    <Dialog
      isOpen={isOpen}
      onOpenChange={onOpenChange}
      purpose={purpose}
      width={width}
      maxHeight={maxHeight}
      variant={
        resolvedPresentation === 'fullscreen' ? 'fullscreen' : 'standard'
      }>
      {dialogContent}
    </Dialog>
  );
}

export default function DialogAdaptivePresentation() {
  const [isOpen, setIsOpen] = useState(false);
  const [name, setName] = useState('Ruby Cheung');
  const [email, setEmail] = useState('ruby@example.com');
  const [notes, setNotes] = useState('');

  return (
    <>
      <Button label="Edit profile" onClick={() => setIsOpen(true)} />
      {/*
        touchPresentation examples:
        - "dialog" keeps Dialog even in touch-oriented <=lg contexts.
        - "fullscreen" uses fullscreen Dialog there.
        - "bottom-sheet" uses BottomSheet there.
        presentation="dialog" | "fullscreen" | "bottom-sheet" overrides
        the media query for tests and unusual environments.
      */}
      <AdaptiveDialog
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        title="Edit profile"
        purpose="form"
        touchPresentation="bottom-sheet"
        bottomSheetHeight="tall"
        footer={
          <HStack gap={2} hAlign="end" wrap="wrap">
            <Button
              label="Cancel"
              variant="secondary"
              onClick={() => setIsOpen(false)}
            />
            <Button
              label="Save profile"
              variant="primary"
              onClick={() => setIsOpen(false)}
            />
          </HStack>
        }>
        <VStack gap={4}>
          <Text type="supporting" color="secondary">
            Dialog remains the default presentation. This example explicitly
            opts into a Bottom Sheet only at lg and below when the device has a
            coarse pointer and no hover. Pass the presentation prop to make
            tests or unusual environments deterministic.
          </Text>
          <Text type="supporting" color="secondary">
            In Bottom Sheet presentation, purpose="form" blocks scrim clicks and
            swipe dismissal while preserving Escape. Use this opt-in only when
            that contract is acceptable; keep AlertDialog/destructive
            confirmations on Dialog unless a product deliberately chooses
            otherwise.
          </Text>
          <TextInput label="Name" value={name} onChange={setName} />
          <TextInput
            label="Email"
            type="email"
            value={email}
            onChange={setEmail}
          />
          <TextArea label="Notes" rows={6} value={notes} onChange={setNotes} />
        </VStack>
      </AdaptiveDialog>
    </>
  );
}

Usage

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.

  • 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.
  • Don't make the sheet content overly long. Consider breaking it into steps and using Bottom Sheet Switcher.

Typed props

PropType and behavior
isOpenboolean
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.
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' · default '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.
sheetIdstring
Unique ID for this sheet inside BottomSheetSwitcher. The switcher opens it when activeSheet matches. Omit isOpen and onOpenChange when sheetId is used.
labelstring · required
Accessible label for the sheet. Required; the sheet has no built-in heading to derive a name from.
childrenReactNode · required
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.
height'hug' | 'capped' | 'tall' | number | string · default '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.
hasScrimboolean · default 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.

Anatomy

Sheet panel · required

Painted surface that rises from the bottom edge and contains the sheet.

Content area · required

Scrollable area that presents the caller-provided sheet content.

Handle · required

Decorative grab affordance and drag region at the top of the panel.

Scrim · optional

Backdrop that dims and blocks the page in a scrim-backed presentation.

Theming

Targets

astryx-bottom-sheet