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| Prop | Type | Default | Description |
|---|---|---|---|
| open required | boolean | — | 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. |
| heading required | string | — | The sheet's title and accessible name. May be visually hidden with `hideHeading` when the content is self-explanatory (a share sheet). |
| hideHeading | boolean | false | 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. |
| children required | content | — | The body. Scrolls inside the sheet when taller than the sheet's height. |
| footer | content | — | Action row, pinned to the bottom of the sheet above the safe area. |
| height | content | half | full | content | `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. |
| dismissible | boolean | true | 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`. |
| dragToDismiss | boolean | true | 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. |
Events
2 rows| Event | Type | Description |
|---|---|---|
| onClose | onClose / close | Requested close with reason: `escape`, `close-button`, `scrim`, `drag`, or `action`. |
| onDragDismiss | onDragDismiss / 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. |
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
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.
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.