Responsive layout
BottomSheet
Resize your browser below 768px to see the sheet layout — bottom-anchored, column-stacked full-width footer. At or above 768px it behaves exactly like Modal.
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title="Choose an option"
subtitle="Resize below 768px to see the sheet layout."
primaryActionLabel="Continue"
primaryActionOnClick={handleContinue}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<p>Sheet content.</p>
</BottomSheet>When to use
Use BottomSheet for the same confirmations, short forms, and focused tasks as Modal, when you also want a bottom-anchored presentation on narrow viewports (with native drag-to-dismiss, and optional resizing between stops via snapPoints). It shares Modal’s header/content/footer anatomy (ModalSurface) and switches layout automatically based on viewport width — there is no renderMode or footerDirection prop to set manually.
Responsive behaviour
- ≥768px: renders exactly like
Modal— centered panel, fixed two-group row-aligned footer. - Below 768px: renders bottom-anchored, with a footer stacked full-width in the order
footerContent, secondary, primary, tertiary (top to bottom).
Both behaviours are computed internally from viewport width — on native, from useWindowDimensions; on web, from CSS breakpoints (max-md:/md: Tailwind variants). Neither platform exposes a prop to force one layout or the other. alignment never affects the footer layout at either breakpoint — it only controls header title/subtitle text alignment.
Drag-to-dismiss (and resize, native only)
Below the 768px breakpoint, both platforms show a grabber handle and support drag-to-dismiss: dragging past 120px, or releasing with a downward velocity above 800px/s, dismisses the sheet; a smaller drag springs back. Native implements this via react-native-gesture-handler; web implements the same behaviour independently via the DOM Pointer Events API — same thresholds, no shared code. At/above the breakpoint, neither platform shows a grabber or responds to drag. This dismiss-only behaviour is what happens when snapPoints isn’t set.
Native only, when snapPoints is set: dragging the grabber/header instead resizes the sheet between the resolved snap stops, landing on the nearest one based on release position/velocity, instead of the plain dismiss-or-spring-back above — see “Scrollable content and snapPoints” below. snapPoints has no web equivalent.
Scrollable content and snapPoints (native only)
By default (scrollable omitted or true), content scrolls instead of overflowing, same as Modal. Set scrollable={false} when children renders its own FlatList, SectionList, or another ScrollView — this applies on the snapPoints drag path too, so a FlatList child still gets a real height ceiling to virtualise against regardless of whether the sheet’s height comes from the 90% cap or a snapPoints stop.
Scrollable content you own must be imported from react-native-gesture-handler, not react-native. This is the one setup mistake here that fails silently: gesture-handler installs its own recognisers at the GestureHandlerRootView level, so a plain react-native ScrollView — or a FlatList/SectionList, which wrap one — never claims the pan inside these components. It renders, it is correctly height-bounded, and it simply will not scroll. There is no warning; the only symptoms are that dragging does nothing, or drags the sheet instead. Use the drop-in replacements, which take the same props:
import { FlatList, ScrollView } from 'react-native-gesture-handler';This applies whenever your children own the scrolling — i.e. with scrollable={false}. The default path needs nothing from you, because the ScrollView these components wrap children in already comes from gesture-handler.
snapPoints lets the sheet be dragged between multiple resting heights instead of only fit-content-or-dismiss: an array of percentage-of-window-height strings (e.g. ['40%', '90%']), in any order — resolved ascending automatically. Omitted, BottomSheet behaves exactly as it does without the prop (fit-content height up to the default 90% cap, drag-to-dismiss only). The sheet opens at snapIndex ?? 0 — by default, the lowest resolved snap height. Changing snapPoints’s own value while the sheet is already open is not supported. There’s no web equivalent — scrollable, snapPoints, snapIndex, and onSnapIndexChange aren’t props on the web BottomSheet.
Keyboard avoidance and safe-area insets (native)
Both are automatic, need no props, and need no consumer setup beyond the Unistyles 3 peer dependency these components already require.
- Keyboard: focusing a field inside the sheet bounds the panel to the space the keyboard leaves and shifts it above the keyboard, so the header, grabber, close button and footer all stay reachable. A
snapPointsheight yields to this while the keyboard is up — a 90% sheet cannot coexist with a keyboard, and an unreachable close button is worse than a temporarily shorter sheet. The content area absorbs the difference and scrolls, so fields below the fold need no special handling. Shared withModalviaModalSurface. - Safe area: unlike
Modal, which is vertically centred and never touches a screen edge,BottomSheetsits flush to the bottom — so its panel pads its bottom edge by the device’s bottom safe-area inset (the iOS home-indicator strip, or Android’s gesture-nav bar), keeping content and footer buttons clear of that system UI. Additive to the panel’s existing bottom padding, not a replacement. Horizontal insets are applied too, so content clears the notch in landscape.BottomSheet-only.
When not to use
- A panel that should always stay centered regardless of viewport: use
Modalinstead. - A non-blocking, dismissable overlay that doesn’t need header/footer chrome: compose
Backdropdirectly.
Props
Shares Modal’s props, with no renderMode or footerDirection prop (both are handled internally from viewport width) — plus four native-only additions for resizing and scrollable content: scrollable, snapPoints, snapIndex, and onSnapIndexChange. The web BottomSheet has none of these four; see each prop’s description below.
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | boolean | - | Controlled open state. |
| onClose * | () => void | - | Fired by backdrop click, Escape, the header close button, and (native, below the breakpoint) drag-to-dismiss. |
| alignment | 'left' | 'centre' | 'left' | Content-layout axis only — controls header title/subtitle text alignment. Never affects footer layout, at either breakpoint (same as Modal). |
| maxWidth | 'fit-content' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | 'fit-content' | Applies at >=768px, same as Modal. 'fit-content' sizes the panel to its own content width, capped at the same 896px used by 'full', which is a fixed 896px panel, not literal 100% viewport width. |
| showDismissButton | boolean | true | Shows/hides the header close button. Button visibility only — use dismissable to control whether the overlay can be dismissed at all. |
| dismissable | boolean | true | Whether the user can dismiss without choosing an action. false disables the scrim click, Escape, Android hardware back and drag-to-dismiss, so onClose fires only from your own handlers. Pair with showDismissButton={false} or the close button remains a way out. |
| title * | string | - | Always rendered. Also used as the aria-label for the dialog. |
| subtitle | string | - | Optional supporting text below the title. |
| headerContent | ReactNode | - | Renders whenever provided, independent of maxWidth/alignment. |
| headerContentFlow | 'row' | 'column' | 'row' | 'row' (default) places headerContent beside the title/subtitle column; 'column' stacks it full-width below that column instead. Same as Modal. |
| primaryActionLabel | string | - | Renders a primary (filled) footer Button with this label when provided. |
| primaryActionOnClick | () => void | - | Click handler for the primary footer action. |
| secondaryActionLabel | string | - | Renders a secondary footer Button with this label when provided. |
| secondaryActionOnClick | () => void | - | Click handler for the secondary footer action. |
| tertiaryActionLabel | string | - | Renders a tertiary footer Button with this label when provided. |
| tertiaryActionOnClick | () => void | - | Click handler for the tertiary footer action. |
| footerButtonSize | 'sm' | 'md' | 'lg' | 'sm' | Passed straight through to the footer action Buttons' own size prop. Same as Modal. |
| children | ReactNode | - | Body content. |
| scrollable | boolean | true | Native only. When true (default), children render inside a ScrollView so content taller than the available space scrolls instead of overflowing. Set false when children renders its own FlatList/SectionList/ScrollView — a ScrollView parent gives children unbounded height, which breaks list virtualisation. Applies on the snapPoints drag path too. |
| snapPoints | string[] | - | Native only. Percentage-of-window-height strings (e.g. ['40%', '90%']), resolved ascending regardless of input order. When set, drag the grabber/header to resize between stops instead of the plain drag-to-dismiss. Omitted: behaves exactly as without this prop. Changing this prop's own value while the sheet is open is unsupported. |
| snapIndex | number | - | Native only. Controls which snapPoints stop the sheet rests at (uncontrolled if omitted, opening at 0). Has no effect without snapPoints. |
| onSnapIndexChange | (index: number) => void | - | Native only. Fires when the resting snapPoints stop changes, from a drag release or an accessibility action, in both controlled and uncontrolled modes. Never fires for a dismiss — that stays onClose's job. |
| data-testid | string | - | Web only. Native uses testID. |
Import
Web
import { BottomSheet } from '@checkatrade/components-web';Native
import { BottomSheet } from '@checkatrade/components-native';Basic usage
const [open, setOpen] = useState(false);
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title="Choose an option"
primaryActionLabel="Continue"
primaryActionOnClick={handleContinue}
>
<p>Sheet content.</p>
</BottomSheet>;Known token gaps
- 768px breakpoint (raw placeholder, matches Tailwind’s default
md) — no@checkatrade/tokensbreakpoint token exists yet; swap once available. This applies on both web and native. - Grabber colour (
bg-black/40on web,rgba(0, 0, 0, 0.4)on native) — confirmed against Figma’s.grabbernode, but still no bound@checkatrade/tokenscolour for it, on either platform. - Panel drop-shadow — confirmed against Figma’s root node, no bound shadow/elevation token exists yet, on either platform.
Platform status
| Platform / Area | Status |
|---|---|
| Design (Figma) | Beta |
| Web (React) | Beta |
| Native (React Native) | Beta |
| iOS (Swift) | Planned |
| Android (Kotlin) | Planned |
| Accessibility audit | Planned |
Accessibility
Renders role="dialog" and aria-modal="true", with aria-label set to the title prop — same as Modal.
Focus trap and scroll-lock are not yet implemented — this is a pre-existing gap carried over from Modal/the original proof-of-concept, not a regression introduced by this component.
Screen-reader containment is available via BackdropAccessibilityBoundary (native, opt-in): wrap your app content in it, as a sibling of the PortalHost, and assistive technology cannot reach the app behind an open overlay. This is needed because accessibilityViewIsModal is iOS-only in React Native — on Android it is a silent no-op, and TalkBack would otherwise walk out of the panel into the content behind it. It hides content from screen readers; it is not a focus trap, so on web Tab can still leave the panel.
When snapPoints is set (native only), the grabber row carries accessibilityRole="adjustable" with increment/decrement actions, letting a screen-reader user move between snap stops without performing the drag gesture itself — decrementing at the shortest stop does not dismiss the sheet; dismissal stays the close button’s job. Note this is the grabber row specifically, not the whole grabber-plus-header region the drag gesture covers: the title, subtitle, and any headerContent you pass stay separate, individually reachable elements rather than being folded into the resize control. Verified on Android, where the row is exposed as a SeekBar node and TalkBack’s swipe-up adjust gesture moves the sheet between stops. This reaches screen readers on native only: react-native-web does not map accessibilityActions to any keyboard event, so a keyboard-only user gets nothing from it there. snapIndex/onSnapIndexChange is the escape hatch for that case — build a visible, keyboard-operable Expand/Collapse control wired to those props.
Native gesture details
Drag-to-dismiss (below the 768px breakpoint only, when snapPoints isn’t set): dragging the sheet down past 120px, or releasing with a downward velocity above 800px/s, dismisses it (fires onClose); a smaller drag springs back. At or above the breakpoint, BottomSheet has no drag response and behaves exactly like Modal.
When snapPoints is set, the same drag gesture resizes the sheet between the resolved stops instead: releasing lands on the nearest stop based on position and velocity, rather than dismissing or springing back.
No releases yet.