Centre alignment
Centre
Centered header text — use for confirmations and destructive-action prompts. Footer buttons always render in the same fixed two-group layout, regardless of alignment.
<Modal
open={open}
onClose={() => setOpen(false)}
alignment="centre"
title="Delete item?"
subtitle="This can't be undone."
primaryActionLabel="Delete"
primaryActionOnClick={handleDelete}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<p>Deleting this item will remove it permanently.</p>
</Modal>Left alignment, large width
Left, maxWidth=lg
Left-aligned header text, in a wider panel — use for forms and content-heavy tasks. Footer buttons always render in the same fixed two-group layout, regardless of alignment.
<Modal
open={open}
onClose={() => setOpen(false)}
alignment="left"
maxWidth="lg"
title="Delete item?"
subtitle="This can't be undone."
primaryActionLabel="Delete"
primaryActionOnClick={handleDelete}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<p>Deleting this item will remove it permanently.</p>
</Modal>Header content
headerContent
Renders whenever provided, independent of maxWidth/alignment — use for a status icon or badge alongside the title.
<Modal
open={open}
onClose={() => setOpen(false)}
title="Delete item?"
subtitle="This can't be undone."
headerContent={<span>🗑️</span>}
primaryActionLabel="Delete"
primaryActionOnClick={handleDelete}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<p>Deleting this item will remove it permanently.</p>
</Modal>Stacked footer
footerDirection=column
Stacks all present footer actions full-width, in the order footerContent, secondary, primary, tertiary (top to bottom) — useful when action labels are long or there are three actions on a narrow modal.
<Modal
open={open}
onClose={() => setOpen(false)}
footerDirection="column"
title="Cancel your booking?"
subtitle="Your tradesperson will be notified straight away."
primaryActionLabel="Cancel booking"
primaryActionOnClick={() => setOpen(false)}
secondaryActionLabel="Message tradesperson"
secondaryActionOnClick={() => setOpen(false)}
tertiaryActionLabel="Keep booking"
tertiaryActionOnClick={() => setOpen(false)}
>
<p>Your tradesperson will be notified straight away.</p>
</Modal>Footer content
footerContent
Generic content rendered in the footer alongside the action buttons — e.g. a link, checkbox, or disclaimer text — distinct from the locked primary/secondary/tertiary Button variants.
<Modal
open={open}
onClose={() => setOpen(false)}
alignment="left"
title="Update your details"
footerContent={<Link onClick={() => {}}>Privacy policy</Link>}
primaryActionLabel="Save"
primaryActionOnClick={() => setOpen(false)}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<p>Update your details and we'll keep your booking in sync.</p>
</Modal>When to use
Use Modal for confirmations, short forms, and focused tasks that need to interrupt the current flow. It is a centered overlay panel, portal-mounted via Backdrop, with a scrim and built-in dismiss handling (backdrop click, Escape, close button).
Alignment
- Left (default): left-aligned header text. Use for forms and content-heavier tasks.
- Centre: centered header text. Use for short confirmations — “Delete item?”, “Log out?”.
alignment only affects the header title/subtitle text alignment — the panel itself always renders centered on screen (it is not a screen-position axis), and it no longer affects footer layout at all. The footer always renders as a fixed two-group row layout (or a fixed stacked order via footerDirection="column") — see “Footer layout” below.
Footer layout
Footer actions render as a fixed two-group layout, independent of alignment:
- Row (default): two visual groups spread across the row — tertiary and
footerContenton the left, secondary and primary on the right, with primary rightmost. Tab/DOM order is always primary → secondary → tertiary →footerContent, regardless ofalignment. - Stack (
footerDirection="column"): the visual top-to-bottom order isfooterContent→ secondary → primary → tertiary, while Tab/DOM order is primary → secondary → tertiary →footerContent, so the primary action stays reachable first. The two deliberately differ, using CSSorder(which never affects tab order) — the same technique the row layout uses.
This replaces the previous alignment-driven layout (equal-width centre-fill row, right-aligned auto-width row, primary-first stack) entirely.
Width
maxWidth ranges from sm to 2xl, plus a full value that is a fixed 896px panel — not literal 100% viewport width — and a fit-content value that sizes the panel to its own content width, capped at that same 896px. Default is fit-content.
Scrollable content (native only)
On native, the panel is capped at 90% of the app window’s height (resolved via useWindowDimensions, so it stays correct under Android split-screen/multi-window and on foldables). By default (scrollable omitted or true), content taller than that scrolls inside the panel instead of overflowing past the screen edge. Set scrollable={false} when children renders its own FlatList, SectionList, or another ScrollView — a ScrollView parent gives its children unbounded height, which breaks list virtualisation (worst on Android). There’s no web equivalent yet — scrollable isn’t a prop on the web Modal.
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, with no warning. 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 Modal wraps children in already comes from gesture-handler.
Keyboard avoidance (native)
Automatic, with no prop to configure or disable it, and no consumer setup beyond the Unistyles 3 peer dependency Modal already requires. Focusing a TextInput inside the panel shifts it above the on-screen keyboard, and the panel is also bounded to the space the keyboard leaves — so the shift can never push the panel’s own top edge off-screen. Content that no longer fits absorbs the difference and scrolls, keeping the header and close button reachable. BottomSheet shares this behaviour via ModalSurface.
When not to use
- A bottom-anchored panel on mobile-sized viewports: use
BottomSheetinstead — it shares the same underlying anatomy (ModalSurface) but anchors to the bottom of the screen. - A non-blocking, dismissable overlay that doesn’t need header/footer chrome: compose
Backdropdirectly. - A destructive action that doesn’t need a confirmation step: don’t add a Modal just for the sake of it — reserve it for genuinely interruptive tasks.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | boolean | - | Controlled open state. |
| onClose * | () => void | - | Fired by backdrop click, Escape, and the header close button. |
| alignment | 'left' | 'centre' | 'left' | Content-layout axis only, not a screen-position axis — Modal always renders centered on screen. Controls header title/subtitle text alignment only (centre centers it, left left-aligns it); it no longer affects footer layout, which always renders as a fixed two-group layout regardless of alignment. |
| maxWidth | 'fit-content' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | 'fit-content' | 'fit-content' sizes the panel to its own content width, capped at the same 896px used by 'full'. 'full' 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. |
| 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. |
| footerDirection | 'row' | 'column' | 'row' | 'column' stacks all present footer actions full-width, in the order footerContent, secondary, primary, tertiary (top to bottom) — the same layout BottomSheet applies automatically below its breakpoint. Not available on BottomSheet, whose stacking is already automatic. |
| footerButtonSize | 'sm' | 'md' | 'lg' | 'sm' | Passed straight through to the footer action Buttons' own size prop. |
| footerContent | ReactNode | - | Generic content rendered in the footer alongside the action buttons — e.g. a checkbox, disclaimer text, or a link. Only buttons render as the locked primary/secondary/tertiary Button variants; this is the escape hatch for anything else. |
| children | ReactNode | - | Body content. |
| scrollable | boolean | true | Native only. When true (default), children render inside a ScrollView so content taller than the 90%-of-window-height panel cap 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. |
| data-testid | string | - | Web only. Native uses testID. |
Import
Web
import { Modal } from '@checkatrade/components-web';Native
import { Modal } from '@checkatrade/components-native';Basic usage
const [open, setOpen] = useState(false);
<Modal
open={open}
onClose={() => setOpen(false)}
title="Delete item?"
subtitle="This can't be undone."
primaryActionLabel="Delete"
primaryActionOnClick={handleDelete}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<p>Deleting this item will remove it permanently.</p>
</Modal>;Left alignment, larger width
<Modal
open={open}
onClose={() => setOpen(false)}
alignment="left"
maxWidth="lg"
title="Update your details"
primaryActionLabel="Save"
primaryActionOnClick={handleSave}
secondaryActionLabel="Cancel"
secondaryActionOnClick={() => setOpen(false)}
>
<MyForm />
</Modal> 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.
Focus trap and scroll-lock are not yet implemented — this is a pre-existing gap carried over from the original proof-of-concept and tracked as a follow-up, 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.
No releases yet.