BottomSheet
A mobile bottom-sheet dialog with sheet or full-screen chrome, height presets, swipe-to-dismiss, and optional header and body slots.
Implementation previewEvery example. In one place.
Explore every named example, including states, compositions, and theme variants.
Example source
packages/design-system/src/components/overlays/BottomSheet/BottomSheet.stories.tsxExport PlaygroundImplementation preview Local Storybook build 0.2.0-preview.1. Source specification status does not approve this runtime.
Stories + source JSON ↗Local specification source status: preview. Implementation preview pending sign-off.
A mobile bottom-sheet dialog with sheet or full-screen chrome, height presets, swipe-to-dismiss, and optional header and body slots.
Implementation API 4 entry points · source-derived preview
Read the actual callable API and preserve its composition constraints. Each union branch is a complete alternative; props marked forbidden cannot be supplied.
import { BottomSheet } from '@cloakedinc/design-system';BottomSheetBottomSheetBaseProps
packages/design-system/src/components/overlays/BottomSheet/BottomSheet.tsx:104
| Prop | Type | Presence | Notes |
|---|---|---|---|
isOpen | boolean | Required | Drives the sheet. Flipping to `false` starts the exit spring rather than unmounting — the sheet stays in the DOM until that motion settles. |
onDismiss | () => void | Required | Called when the user dismisses (backdrop tap, swipe, or escape). |
size | BottomSheetSize | Required | Height preset — `small`/`medium`/`large` are fixed at 40/60/85vh, `full` at 100vh. |
lowered | number | undefined | Optional | Slides the sheet down by this fraction of its own height (0–1) and fades the backdrop in step,
without dismissing it.Source default: 0 |
scrimOpacity | number | undefined | Optional | Backdrop opacity while the sheet is at full height, clamped to 0-1.Source default: 1 |
presentation | BottomSheetPresentation | undefined | Optional | Chrome treatment. `sheet` is rounded bottom-sheet chrome; `fullScreen` fills the viewport.Source default: 'sheet' |
surface | "page" | "surface" | undefined | Optional | Sheet surface token. Defaults to `surface`.Source default: 'surface' |
showDragHandle | boolean | undefined | Optional | Shows the drag handle. Defaults to `true`; it is also the only grab target for `swipeToDismissArea='handle'`.Source default: true |
disableSwipeToDismiss | boolean | undefined | Optional | Turns off swipe-to-dismiss. Defaults to `false`; backdrop tap and Escape still dismiss.Source default: false |
disableSwipeToDismissRef | React.RefObject<boolean> | undefined | Optional | Synchronous disable check (same tick) for nested swipe surfaces. |
swipeToDismissArea | SwipeToDismissArea | undefined | Optional | Region where a swipe-to-dismiss gesture can begin. Defaults to the whole sheet.Source default: 'sheet' |
ariaLabel | string | undefined | Optional | Names the sheet when it has no `BottomSheet.Title`. A slot is invisible to the compiler, so unlike the old `title` prop this cannot be type-enforced — `@cloakedinc/bottom-sheet-slots` rejects a sheet with neither, and a dev-time check catches what lint cannot read statically. |
portalTarget | HTMLElement | null | undefined | Optional | Mount node for the portal. Defaults to `document.body`. |
onBeforeDismiss | (() => void) | undefined | Optional | Fired right before dismissal completes — host hook for native effects (haptics). |
onExited | (() => void) | undefined | Optional | Fired once the close animation fully settles and the sheet unmounts. Use this to sequence post-dismiss work (e.g. navigation) so it starts only after the sheet has visually left. |
haptic | HapticFeedback | undefined | Optional | Haptic feedback for dismissal. Defaults to `true` (`light`).Source default: true |
className | string | undefined | Optional | |
style | React.CSSProperties | undefined | Optional | |
children | React.ReactNode | Required | |
testId | string | undefined | Optional | `data-testid` for the sheet; the backdrop gets `${testId}-overlay`. |
padding | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Padding on all four sides. |
paddingX | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Horizontal padding (left + right). Overrides `padding` on the X axis. |
paddingY | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Vertical padding (top + bottom). Overrides `padding` on the Y axis. |
paddingTop | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Top padding. Overrides `paddingY` and `padding`. |
paddingRight | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Right padding. Overrides `paddingX` and `padding`. |
paddingBottom | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Bottom padding. Overrides `paddingY` and `padding`. |
paddingLeft | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Left padding. Overrides `paddingX` and `padding`. |
BottomSheet.HeaderBottomSheetHeaderProps
packages/design-system/src/components/overlays/BottomSheet/BottomSheetHeader.tsx:30
| Prop | Type | Presence | Notes |
|---|---|---|---|
onClose | (() => void) | undefined | Optional | Shows a close button when provided. Pressing it only fires this callback; actually closing the sheet is the host's job. |
children | React.ReactNode | Optional | |
className | string | undefined | Optional | |
style | React.CSSProperties | undefined | Optional | |
testId | string | undefined | Optional | `data-testid` for the header; the close button gets `${testId}-close`. |
BottomSheet.TitleBottomSheetTitleProps
packages/design-system/src/components/overlays/BottomSheet/BottomSheetTitle.tsx:24
| Prop | Type | Presence | Notes |
|---|---|---|---|
children | string | Required | Doubles as the sheet's accessible name. |
variant | BottomSheetTitleVariant | undefined | Optional | Type treatment. Defaults to `sans`.Source default: 'sans' |
testId | string | undefined | Optional | `data-testid` for the heading. Rarely needed — the title is not interactable. |
BottomSheet.BodyBottomSheetBodyProps
packages/design-system/src/components/overlays/BottomSheet/BottomSheetBody.tsx:28
| Prop | Type | Presence | Notes |
|---|---|---|---|
children | React.ReactNode | Required | |
scroll | boolean | undefined | Optional | Scrolls within the slot, with overscroll contained so scrolling doesn't chain to ancestors.
Defaults to `true`; `false` defers scrolling to the sheet's own content wrapper.Source default: true |
className | string | undefined | Optional | |
style | React.CSSProperties | undefined | Optional | |
testId | string | undefined | Optional | `data-testid` for the slot element. |
padding | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Padding on all four sides. |
paddingX | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Horizontal padding (left + right). Overrides `padding` on the X axis. |
paddingY | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Vertical padding (top + bottom). Overrides `padding` on the Y axis. |
paddingTop | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Top padding. Overrides `paddingY` and `padding`. |
paddingRight | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Right padding. Overrides `paddingX` and `padding`. |
paddingBottom | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Bottom padding. Overrides `paddingY` and `padding`. |
paddingLeft | 0 | 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80 | 96 | 120 | 240 | undefined | Optional | Left padding. Overrides `paddingX` and `padding`. |
Only explicit destructured parameter initializers are extracted. Other runtime defaults remain in implementation source.
Overview
The mobile bottom-sheet dialog — the standard way to present focused, dismissible
content over a screen. It renders through a portal, so keep isOpen in parent
state and close it from onDismiss.
Use when
size sets the sheet height. Pick it by how much content the sheet carries.
small, large, auto, and full are all in use today; medium is available
but currently unused in the app.
Presentation
presentation defaults to sheet — rounded bottom-sheet chrome with an optional
drag handle and swipe-to-dismiss. Switch to fullScreen to fill the viewport
edge-to-edge with no radius or handle, for immersive flows. A fullScreen sheet
reaches the notch, and BottomSheet.Header is what holds its content clear of
it — so give one a header even when the chrome is only a title.
Composition
The sheet is composed entirely from slots. BottomSheet.Header is the top
chrome, BottomSheet.Title names the sheet from inside it, and
BottomSheet.Body holds the content and owns its own scrolling.
<BottomSheet isOpen={isOpen} onDismiss={close} size='large' testId='privacy-detail-sheet'>
<BottomSheet.Header onClose={close} testId='privacy-detail-sheet-header'>
<BottomSheet.Title>Privacy detail</BottomSheet.Title>
<Text variant='body-small' color='secondary'>
Updated today
</Text>
</BottomSheet.Header>
<BottomSheet.Body>
<Text variant='body' color='secondary' as='p'>
Composed slots keep titles, close actions, and body spacing consistent.
</Text>
</BottomSheet.Body>
</BottomSheet>
BottomSheet.Title must be a direct child of BottomSheet.Header. A fragment
between the two is fine; a wrapper element is not, and
@cloakedinc/bottom-sheet-slots fails the lint if you reach for one. The reason
is the fullScreen presentation: .header is the single element that carries
the sheet's top safe-area inset, and a title that could also sit topmost gives
that inset two possible owners — the padding bug this containment closes.
The title shares a row with the close button, and every other header child
stacks below that row. It draws no divider of its own; if a rule between header
and body is what the design calls for, place a Divider yourself.
variant='serif' swaps the title to the display serif. The default is sans.
Accessible name
The title doubles as the sheet's accessible name, so most sheets need nothing
further. A sheet with no title — a picker whose options speak for themselves —
takes ariaLabel instead. A slot is invisible to the compiler, so this is the
one requirement the types cannot enforce. @cloakedinc/bottom-sheet-slots fails
the lint on a sheet with neither, and a sheet whose title only appears at runtime
logs a dev-time error — pass ariaLabel there, since a Title that does mount
takes the name back.
Required decisions
disableSwipeToDismiss forces a deliberate choice — the sheet closes only via the
backdrop or an in-content action, not a swipe.
Usage by variant
| Choice | Use when | Avoid when |
|---|---|---|
Size: small | Brief content — a short option list, a confirm, an info blurb. The most common size in the app. | Long or scrolling content that will feel cramped. |
Size: medium | A moderate amount of content that needs more room than a small sheet. | |
Size: large | Taller content — a catalog, results, or a detail with several rows. | |
Size: full | Long or scrolling content that should still read as a bottom sheet; reaches the sheet max-height, stopping below the status bar. | |
Size: auto | Content whose height you want the sheet to hug exactly, capped at the sheet max-height. | Content whose height varies enough that a fixed preset reads more predictably. |
Component specification
$schema
../../../specs/component-spec.schema.jsonParts
- Id
- root
- Label
- Root
- Description
- The sheet surface presenting the dialog.
- Required
- true
- Id
- overlay
- Label
- Overlay
- Description
- Backdrop that dims and dismisses on tap.
- Id
- drag-handle
- Label
- Drag handle
- Description
- Optional grab affordance at the top of the sheet.
- Id
- content
- Label
- Content
- Description
- Scrollable content region.
- Id
- header
- Label
- BottomSheet.Header
- Description
- Top chrome slot: an optional BottomSheet.Title, an optional close button, and any content beneath them.
- Id
- title
- Label
- BottomSheet.Title
- Description
- Heading slot that names the sheet. Belongs inside BottomSheet.Header.
- Id
- body
- Label
- BottomSheet.Body
- Description
- Composed body slot with consistent spacing.
Allowed Design Choices
- Variants
- Sheet
- Label
- Sheet
- Description
- Rounded bottom-sheet chrome with an optional drag handle.
- Full screen
- Label
- Full screen
- Description
- The fullScreen presentation fills the viewport with no radius.
- Sizes
- Small
- Label
- Small
- Description
- 40vh height preset.
- Medium
- Label
- Medium
- Description
- 60vh height preset.
- Large
- Label
- Large
- Description
- 85vh height preset.
- Full
- Label
- Full
- Description
- Fills the viewport height.
- Auto
- Label
- Auto
- Description
- Hugs content height, capped at the sheet max-height.
Variants
- Id
- sheet
- Label
- Sheet
- Description
- Rounded bottom-sheet presentation.
- Platforms
- ios
- android
- web
- Id
- full-screen
- Label
- Full screen
- Description
- Edge-to-edge fullScreen presentation.
- Platforms
- ios
- android
- web
Sizes
- Id
- small
- Label
- Small
- Description
- 40vh sheet.
- Id
- medium
- Label
- Medium
- Description
- 60vh sheet.
- Id
- large
- Label
- Large
- Description
- 85vh sheet.
- Id
- full
- Label
- Full
- Description
- Full-viewport sheet.
- Id
- auto
- Label
- Auto
- Description
- Content-height sheet.
Measurements
- Id
- sheet-padding-top
- Label
- Sheet top inset
- Kind
- exact
- Token
- --cds-space-24
- Value
- 24
- Unit
- px
- Applies To
- root
- Id
- sheet-inline-inset
- Label
- Content horizontal gutter
- Kind
- exact
- Token
- --cds-space-20
- Value
- 20
- Unit
- px
- Applies To
- content
- Notes
- Default gutter on the content region; padding props on the sheet override it, so paddingX={0} hosts edge-to-edge content. The built-in title keeps this inset either way.
- Id
- content-gap
- Label
- Content gap
- Kind
- exact
- Token
- --cds-space-12
- Value
- 12
- Unit
- px
- Applies To
- content
- Id
- corner-radius
- Label
- Sheet corner radius
- Kind
- exact
- Token
- --cds-radius-xl
- Value
- 24
- Unit
- px
- Applies To
- root
- Notes
- Top corners only; the sheet presentation rounds its top edge.
Layout
- Alignment
- The sheet anchors to the bottom edge and centers horizontally; content scrolls within it.
- Flow
- A backdrop hosts the sheet, which stacks an optional drag handle, an optional header slot, and scrollable content.
- Notes
- Compose BottomSheet.Header with a BottomSheet.Title inside it; there is no title prop on the root.
- BottomSheet.Title must sit inside BottomSheet.Header. The header is the one element reserving the top safe-area inset, so a title that could also be topmost would need the inset in two places.
- The top safe-area inset is reserved only by the fullScreen presentation, which reads --cds-safe-area-top-chrome; a bottom-anchored sheet never reaches the notch.
- Padding props on the sheet set the content gutter and default to the 20px page inset; paddingX={0} lets a child run edge to edge.
- BottomSheet.Header keeps the 20px gutter whatever the padding props say, so paddingX={0} opens up the body without dragging the title to the screen edge.
Typography And Color
- Typography
- Label
- Title
- Description
- BottomSheet.Title uses the h4 ramp, or the h3-serif ramp with variant="serif".
- Token
- --cds-type-h4-size
- Colors
- Label
- Surface
- Description
- The sheet uses the raised surface by default.
- Token
- --cds-bg-surface
- Label
- Content text
- Description
- Content uses primary text.
- Token
- --cds-text-primary
States
- Id
- open
- Label
- Open
- Description
- Mounted and presented over the backdrop.
- Id
- dismissing
- Label
- Dismissing
- Description
- Sliding out after a dismiss gesture or backdrop tap.
- Id
- lowered
- Label
- Lowered
- Description
- Slid partway down with the scrim faded, revealing the screen behind without dismissing.
- Id
- swipe-locked
- Label
- Swipe locked
- Description
- Swipe-to-dismiss disabled for required decisions.
Behavior
- Label
- Controlled open
- Description
- Keep isOpen in parent state and close from onDismiss.
- Label
- Dismissal
- Description
- Backdrop tap, swipe down, or Escape dismiss the sheet unless swipe is disabled.
- Label
- Lowering
- Description
- A host can set lowered (0-1) to duck the sheet by that fraction of its height and bring it back; the scrim fades in step and the sheet stays mounted and controlled.
- Label
- Scrim opacity
- Description
- scrimOpacity (0-1) caps how dark the backdrop may go, for a host that needs the screen behind the sheet readable while the sheet holds still. It composes with lowered by relief, not by multiplication: the scrim renders at whichever of the two asks for more, so a sheet that only ducks behaves exactly as it did before.
Accessibility
- Label
- Dialog semantics
- Description
- The sheet is role=dialog with aria-modal, named by its BottomSheet.Title when one is present and by ariaLabel otherwise. A slot is invisible to the compiler, so a sheet with neither is caught by a dev-time check rather than a type error.
- Label
- Focus management
- Description
- Focus moves into the sheet once the entrance settles, and is restored to the launcher the moment a close becomes irreversible — a dismiss during the entrance cancels the pending move rather than pulling VoiceOver into a sheet that is already leaving. The sheet suppresses the focus outline: the runtime is touch-only, so a ring on its edge signals nothing.
Content Adaptation
- Label
- Height presets
- Description
- size sets the sheet height; auto hugs content up to the max-height.
- Label
- Keyboard avoidance
- Description
- The sheet insets its bottom padding when the keyboard is shown.
- Label
- Content gutter
- Description
- The content region carries the page gutter by default. Pass paddingX={0} for edge-to-edge content such as a full-bleed list, and inset the sheet's own chrome instead.
Motion Haptics
- Motion
- Label
- Slide and spring
- Description
- The sheet slides up on open and spring-tracks the drag on dismiss.
- Label
- Lower and return
- Description
- Lowering animates over 400ms with --cds-ease-in-out, on a separate property from the dismiss spring so the two never fight.
- Haptics
- Label
- Dismiss haptic
- Description
- Dismissal requests a light haptic before it completes.
Platform Differences
- Platforms
- web
- Description
- Web renders the sheet through a portal to document.body and supports Escape-to-dismiss for hardware keyboards.
- Rationale
- The bottom-sheet interaction is the cross-platform product pattern; the portal and Escape support keep it usable in the web runtime.