BottomSheet

A modal surface that rises from the bottom edge; on wide viewports it keeps that presentation, capped in width and centred — the mobile idiom for a task or a set of choices, with a visible close and an optional drag to dismiss.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { BottomSheet } from '@design-schema/react';

Examples

import { useEffect, useState, type ReactElement } from 'react';
import { BottomSheet, type BottomSheetProps, Button, Input, Stack, Text } from '@design-schema/react';

function BottomSheetHarness({ open: openArg, onClose, ...args }: BottomSheetProps): ReactElement {
  const [open, setOpen] = useState(openArg);
  useEffect(() => setOpen(openArg), [openArg]);

  return (
    <>
      <Button label={args.heading} onClick={() => setOpen(true)} />
      <BottomSheet
        {...args}
        open={open}
        onClose={(reason) => {
          onClose?.(reason);
          // A non-dismissible sheet reports Escape; this consumer keeps it open until a footer action.
          if (reason === 'escape' && args.dismissible === false) return;
          setOpen(false);
        }}
      />
    </>
  );
}

<BottomSheetHarness open heading="Filters"><Stack gap="normal">
<Text size="sm">Show items updated in the last:</Text>
<Input label="Days" name="days" defaultValue="30" />
</Stack></BottomSheetHarness>

Props

8 rows
PropTypeDefaultDescriptionPlatforms
open requiredboolean—

Controlled visibility, as in Dialog. Controlled only — there is no uncontrolled mode; the consumer owns `open` and the sheet requests changes through `onClose`, never changing `open` itself.

web, lit, rn, swiftui
heading requiredstring—

The sheet's title and accessible name. May be visually hidden with `hideHeading` when the content is self-explanatory (a share sheet).

web, lit, rn, swiftui
hideHeadingbooleanfalse

Keep the heading for assistive technology but do not render it.

Accessibility: The accessible name is required regardless; visually hidden is fine, absent is not.

web, lit, rn, swiftui
children requiredcontent—

The body. Scrolls inside the sheet when taller than the sheet's height.

web, lit, rn, swiftui
footercontent—

Action row, pinned to the bottom of the sheet above the safe area.

web, lit, rn, swiftui
heightcontent | half | fullcontent

`content` sizes to the body up to `contentCap` of the viewport; `half` is a fixed half-height; `full` is a near-full-screen sheet with the top gutter visible so the scrim still shows. A fixed height (`half`, `full`) leaves slack in the column, which is what lets the footer pin to the bottom; at `content` the column is intrinsic, so "pinned" means only that the footer is last.

web, lit, rn, swiftui
dismissiblebooleantrue

Escape, the close button, a scrim tap and the drag gesture all request close. When false, only the footer actions close it, as in Dialog: the close button and the drag handle are not rendered, a scrim tap and a drag do nothing, and Escape still reports with reason `escape`.

web, lit, rn, swiftui
dragToDismissbooleantrue

Drag the handle (or the header) downward to dismiss: release past 25% of the sheet height, or faster than 1.5 px/ms, dismisses; otherwise the sheet springs back. The dismiss then plays the normal exit transition (no momentum physics). The body ScrollView does not start the gesture; only the handle and header do, whatever the body's scroll position, and only once the pointer has moved `dragSlop` downward, so a tap on the close button still activates it. The drag offset is measured from where the slop was crossed, so the surface does not jump. After a dismissing release the surface holds the released offset until the consumer's next render — web and rn: the render that follows the `onClose` call (a `setState` in the handler is batched into it); Lit: `updateComplete` after the `close` dispatch plus one animation frame. If `open` is still true then, the sheet springs back (motion.easing.standard over the `exit` duration) and a later `open` false plays the normal exit from rest; a spring-back also finishes an interrupted enter animation. Purely additive: Escape always exists, and the close button exists whenever the gesture does (both need `dismissible`). The handle is rendered only when `dragToDismiss` and `dismissible` are both true, so there is no drag affordance where dragging does nothing.

Accessibility: A gesture is never the only way to dismiss (WCAG 2.5.1); the handle is not a focus stop.

web, lit, rn, swiftui

Events

2 rows
EventTypeDescriptionPlatforms
onCloseonClose / close

Requested close with reason: `escape`, `close-button`, `scrim`, `drag`, or `action`.

web, lit, rn, swiftui
onDragDismissonDragDismiss / drag-dismiss

The user dragged the sheet past the dismiss threshold. Fired before `onClose` with reason drag; provided so analytics can distinguish gestures. It carries no payload — the distance and velocity that triggered it are not part of the contract.

Gesture event — the schema requires a pointer and keyboard alternative.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

If you wire up the gesture events, provide the pointer and keyboard path to the same outcome.

Implicit ARIA role: dialog.

Anatomy

scrimsurfacefocusScopehandleheaderheadingbodyfootercloseButton

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Focus trap — While it is open, Tab cycles inside it; focus cannot reach the page behind.

Focus restore — On close, focus returns to whatever opened it.

Escape dismisses — Escape closes it from anywhere inside.

Inert background — While it is open, the rest of the page is inert to the pointer and to assistive technology.

Scroll lock — While it is open, the page behind it does not scroll.

Gesture alternative — Every gesture — swipe, drag, long-press — has a single-pointer and a keyboard equivalent.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.

Target size — Interactive targets are at least 44×44 CSS pixels — the touch-first size, not the minimum.

BottomSheet keyboard support

3 rows
KeyAction
EscapeRequests close with reason escape.
TabFrom the last element wraps to the first; the handle is never a stop.
Shift+TabFrom the first element wraps to the last.

Contrast pairs

3 rows
ForegroundBackgroundLevel
color.foregroundcolor.overlay.surfaceAA
color.foreground.mutedcolor.overlay.surfaceAA
color.foreground.mutedcolor.overlay.surfaceAA (large text)

Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.