Skip to content

Overlay

Modal

Beta

A centered, dismissable overlay panel for confirmations, forms, and focused tasks.

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>

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>

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 actions render as a fixed two-group layout, independent of alignment:

  • Row (default): two visual groups spread across the row — tertiary and footerContent on the left, secondary and primary on the right, with primary rightmost. Tab/DOM order is always primary → secondary → tertiary → footerContent, regardless of alignment.
  • Stack (footerDirection="column"): the visual top-to-bottom order is footerContent → secondary → primary → tertiary, while Tab/DOM order is primary → secondary → tertiary → footerContent, so the primary action stays reachable first. The two deliberately differ, using CSS order (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 BottomSheet instead — 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 Backdrop directly.
  • 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 / AreaStatus
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.