@xsolla/xui-modal
v0.201.3
Published
A cross-platform React modal dialog system. A provider/context/hook trio drives a stack of dialogs supporting three presentation types (`popup`, `bottom-sheet`, `full-screen`), structured headers, focus trapping, and programmatic open/close on both web an
Readme
Modal
A cross-platform React modal dialog system. A provider/context/hook trio drives a stack of dialogs supporting three presentation types (popup, bottom-sheet, full-screen), structured headers, focus trapping, and programmatic open/close on both web and React Native.
A layered overlay component that interrupts the current flow to present focused content requiring the user's attention or a decision. Composed of a header, text block, content area, and footer. Supports three presentation types — Popup, Bottom sheet, and Full screen — covering desktop and mobile contexts.
When to use
- To confirm a destructive or irreversible action — delete, remove, revoke
- To collect a focused set of inputs without navigating to a new page — a short form, a settings change, a payment step
- To present critical information the user must acknowledge before continuing
- To display contextual content (a preview, a detail view) without losing the current page context
- When the interaction requires the user's undivided attention and the rest of the UI should be blocked
When not to use
- For non-critical notifications or status updates — use a Toast
- When the information is supplementary and the user can ignore it — use a Tooltip or inline helper text
- When navigation to a dedicated page is more appropriate — open a new route instead
- For complex multi-step workflows that would make the modal very tall — break into a dedicated page or a stepper flow
- For simple one-line confirmations where a native browser confirm() or a small inline confirmation would suffice
Content guidelines
- Header title — short and specific: "Delete project", "Add team member", "Confirm payment". Use sentence case. Do not end with a period.
- Body title — only needed when the header title is insufficient to explain the content. Example: header is "Confirm", body title is "You are about to permanently delete 12 files."
- Description — one or two sentences. Explain the consequence or what the user is being asked to do. Example: "This action cannot be undone. All associated data will be removed."
- Primary button label — imperative verb matching the action: "Delete", "Save changes", "Confirm", "Continue", "Add". For destructive actions, use Tone=Alert on the button.
- Cancel / secondary button label — "Cancel", "Go back", "Not now", "Keep" (for "Keep / Delete" confirmations).
- Avoid vague labels — do not use "OK", "Yes", or "No" as button labels. The label must describe the action being taken.
- Modal length — if the content requires scrolling more than one screen length, consider navigating to a dedicated page instead.
Behaviour guidelines
- Opening — the modal opens programmatically in response to a user action (button click, link click, confirmation trigger). It must never open on its own without user intent. The opening should be animated (fade + scale for Popup; slide-up for Bottom sheet).
- Backdrop — a semi-transparent backdrop covers the page behind Popup and Bottom sheet. Clicking the backdrop closes the modal unless the action is destructive or critical (in which case, force explicit button dismissal).
- Closing — the modal closes on: clicking the close button (✕), clicking the backdrop (if allowed), pressing Escape, completing the primary action, or clicking Cancel. On close, the modal is removed from the DOM (not just hidden).
- Focus management — when the modal opens, focus must move into the modal (to the first interactive element — typically the primary button or the first input). When the modal closes, focus must return to the element that triggered it.
- Focus trap — while the modal is open, keyboard focus must be trapped inside it. Tab and Shift+Tab must cycle through focusable elements within the modal only. Focus must not escape to the background page.
- Scroll — if the content area overflows, the content area scrolls internally; the header and footer remain fixed (sticky) at the top and bottom of the modal. The page behind the modal must not scroll while the modal is open (body { overflow: hidden }).
- Stacking — avoid opening a second modal on top of the first. If a second modal is unavoidable (e.g. a confirmation within a form modal), ensure both have correct z-index ordering and the focus trap applies to the topmost modal only.
- Animation — Popup: fade in + scale from 95% to 100%. Bottom sheet: slide up from the bottom. Full screen: fade or slide based on product design. Keep animations under 300ms. Respect prefers-reduced-motion — use instant transitions for users who prefer reduced motion.
Accessibility
- The modal container must have role="dialog" and aria-modal="true".
- The modal must have an accessible name via aria-labelledby pointing to the header title element's id, or aria-label if the title is not a visible text element.
- If the modal has a description, associate it via aria-describedby pointing to the description text's id.
- When the modal opens, move focus to the first interactive element inside the modal — typically the primary button, the close button, or the first form field.
- Implement a focus trap: Tab and Shift+Tab must cycle only within the modal's focusable elements.
- The close button (✕) must have aria-label="Close" since it contains only an icon.
- The Button left back button must have aria-label="Go back" or equivalent.
- When the modal closes, return focus to the element that triggered it.
- Announce the modal opening to screen readers — role="dialog" with aria-labelledby handles this natively in most assistive technologies.
- Prevent background scroll and interaction while the modal is open: use aria-hidden="true" on the content behind the modal so screen readers do not read it.
Installation
npm install @xsolla/xui-modalImports
import {
Modal,
ModalProvider,
ModalContext,
WorkArea,
useModal,
useModalId,
type ModalProps,
type ModalSize,
type ModalVariant,
type WorkAreaProps,
type WorkAreaSize,
} from "@xsolla/xui-modal";useModalId is re-exported from @xsolla/xui-core and is consumed by portaled descendants (e.g. Select, Dropdown) so click-outside detection ignores content that logically belongs to the modal.
Quick start
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";
function Trigger() {
const [open, close] = useModal(() => (
<Modal onClose={close} title="Confirm action">
<p>Are you sure you want to proceed?</p>
<Button onPress={close}>Close</Button>
</Modal>
));
return <Button onPress={open}>Open modal</Button>;
}
export default function QuickStart() {
return (
<ModalProvider>
<Trigger />
</ModalProvider>
);
}API Reference
<Modal>
The dialog component with header, body, and footer zones. Accepts a ref forwarded to the inner WorkArea. Unknown props are spread onto the root Box. size is a typed ModalProps field but is never consumed by Modal's internal layout; it passes through via ...rest to the root Box with no built-in styling effect.
| Prop | Type | Default | Description |
| ------------------- | -------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| testID | string | — | Test ID for testing frameworks. On web this renders as data-testid; on React Native it renders as testID. |
| closeButtonTestId | string | "modal-close-button" | Test ID applied to the close (X) button. On web this renders as data-testid on the button element. The back button uses "modal-back-button". |
| closeButtonSize | "xs" \| "sm" \| "md" \| "lg" \| "xl" | "xl" | Size of the default header buttons. Maps to FlexButton's size scale (icon sizes 12/14/16/18/20px, hit areas 20/22/28/32/36px). Applies to the back button too, so the pair stays visually matched. |
| children | ReactNode | — | Required. Modal body content. |
| type | "popup" \| "bottom-sheet" \| "full-screen" | "popup" | Presentation type. Drives positioning, border-radius, and shadow. |
| align | "left" \| "center" | "left" | Content alignment within the body. |
| onClose | () => void | — | Close callback. When provided, renders a close (X) button and enables Escape and click-outside dismissal. |
| onBack | () => void | — | Back callback. Renders a chevron-left button on the left of the header. |
| header | ReactNode | — | Custom header — replaces the default back/close row. |
| footer | ReactNode | — | Footer content rendered below children. |
| openContent | boolean | false | Removes inner padding so children fill edge-to-edge. |
| closeOutside | boolean | true | Whether clicking outside closes the modal (requires onClose). |
| maxWidth | string \| number | 680 | Forced to 100% for bottom-sheet and full-screen. |
| minHeight | string \| number | — | Minimum height of the modal. |
| styled | CSSProperties | — | Inline overrides on the modal container. |
| title | string | — | Visually hidden accessible title — drives aria-labelledby. For a visible heading, render it inside children or pass a custom header. |
| aria-label | string | — | Alternative accessible name when title is not used. |
| aria-describedby | string | — | ID of an element describing modal content. |
| initialFocusRef | RefObject<HTMLElement> | — | Element to focus on open. Falls back to the close button, then the first focusable child. |
Inherits ThemeOverrideProps (themeMode, themeProductContext).
Modal types
| Type | Positioning | Border radius | Shadow | Max width |
| ----------------- | ---------------------- | ---------------- | ------ | ------------------- |
| popup (default) | Centred in the overlay | All corners | Yes | 680px (or custom) |
| bottom-sheet | Anchored to the bottom | Top corners only | Yes | 100% (forced) |
| full-screen | Fills the viewport | 0 | None | 100% (forced) |
<ModalProvider>
Wraps your app, manages the open-modal stack, and renders ModalRoot.
| Prop | Type | Description |
| ---------- | ----------- | ---------------------------------- |
| children | ReactNode | Required. Application content. |
<WorkArea>
Internal chrome used by Modal. Can be rendered standalone for custom layouts.
| Prop | Type | Default | Description |
| ------------- | -------------------------------------------- | --------- | --------------------------------------------------- |
| children | ReactNode | — | Required. Content. |
| type | "popup" \| "bottom-sheet" \| "full-screen" | "popup" | Affects border-radius and shadow. |
| align | "left" \| "center" | — | Content alignment. |
| indent | "sm" \| "md" \| "lg" | — | Padding hint passed alongside the work-area sizing. |
| stretched | boolean | false | Stretch to full height. |
| openContent | boolean | false | Edge-to-edge mode. |
| fetching | boolean | false | Renders a loading placeholder when true. |
Inherits ThemeOverrideProps (themeMode, themeProductContext).
useModal(renderFn)
Returns [open, close] for a single modal. Must be called inside a ModalProvider.
function useModal(modal: (props: any) => ReactNode): [() => void, () => void];The hook generates a stable random key per component instance and keeps a ref to the latest render function — your closure values stay current without a dependency array.
useModalId()
Returns the current modal's data-modal-id. Portaled descendants should set this attribute on their root so the modal recognises them as in-tree for click-outside checks.
ModalContext
The raw React context. Most apps consume useModal, but advanced cases can call onOpenModal(key, renderFn) and onCloseModal(key) directly.
Exported types
| Type | Members |
| -------------------- | ------------------------------------------------------------ |
| ModalProps | Props for <Modal>. |
| ModalSize | "sm" \| "md" \| "lg" |
| ModalVariant | "popup" \| "bottom-sheet" \| "full-screen" |
| ModalType | (props: any) => ReactNode — the render function signature. |
| ModalContextType | { onOpenModal, onCloseModal } |
| ModalProviderProps | Props for <ModalProvider>. |
| ModalRootProps | Props for the internal root. |
| WorkAreaProps | Props for <WorkArea>. |
| WorkAreaSize | "sm" \| "md" \| "lg" |
Examples
Modal types
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";
function Demo() {
const [openPopup, closePopup] = useModal(() => (
<Modal type="popup" onClose={closePopup} title="Popup">
<p>Centred with border-radius and shadow.</p>
</Modal>
));
const [openSheet, closeSheet] = useModal(() => (
<Modal type="bottom-sheet" onClose={closeSheet} title="Bottom sheet">
<p>Anchored to the bottom.</p>
</Modal>
));
const [openFull, closeFull] = useModal(() => (
<Modal type="full-screen" onClose={closeFull} title="Full screen">
<p>Fills the viewport.</p>
</Modal>
));
return (
<div style={{ display: "flex", gap: 12 }}>
<Button onPress={openPopup}>Popup</Button>
<Button onPress={openSheet}>Bottom sheet</Button>
<Button onPress={openFull}>Full screen</Button>
</div>
);
}
export default function Example() {
return (
<ModalProvider>
<Demo />
</ModalProvider>
);
}Confirmation dialog
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button, ButtonGroup } from "@xsolla/xui-button";
function ConfirmTrigger() {
const [confirmed, setConfirmed] = React.useState(false);
const [open, close] = useModal(() => (
<Modal
onClose={close}
closeOutside={false}
maxWidth={400}
title="Delete item?"
footer={
<ButtonGroup orientation="horizontal" size="xl">
<Button variant="secondary" tone="mono" onPress={close}>
Cancel
</Button>
<Button
tone="alert"
onPress={() => {
setConfirmed(true);
close();
}}
>
Delete
</Button>
</ButtonGroup>
}
>
<p>This action cannot be undone.</p>
</Modal>
));
return (
<div>
<Button tone="alert" onPress={open}>
Delete
</Button>
{confirmed && <p>Deleted.</p>}
</div>
);
}
export default function ConfirmDialog() {
return (
<ModalProvider>
<ConfirmTrigger />
</ModalProvider>
);
}Multi-step modal
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";
function MultiStep() {
const [step, setStep] = React.useState(1);
const [open, close] = useModal(() => (
<Modal
onClose={() => {
setStep(1);
close();
}}
onBack={step > 1 ? () => setStep((s) => s - 1) : undefined}
title={`Step ${step} of 3`}
>
<p>Step {step} content.</p>
{step < 3 ? (
<Button onPress={() => setStep((s) => s + 1)}>Next</Button>
) : (
<Button
onPress={() => {
setStep(1);
close();
}}
>
Finish
</Button>
)}
</Modal>
));
return <Button onPress={open}>Open multi-step</Button>;
}
export default function MultiStepExample() {
return (
<ModalProvider>
<MultiStep />
</ModalProvider>
);
}Dropdowns inside modal
import * as React from "react";
import { Button } from "@xsolla/xui-button";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { MultiSelect } from "@xsolla/xui-multi-select";
import { Select } from "@xsolla/xui-select";
const paymentOptions = [
{ value: "abn-amro", label: "ABN AMRO" },
{ value: "asn-bank", label: "ASN Bank" },
{ value: "bancomat-pay", label: "BANCOMAT Pay" },
{ value: "blik", label: "BLIK" },
];
function DropdownModalTrigger() {
const [paymentMethod, setPaymentMethod] = React.useState<string>();
const [regions, setRegions] = React.useState<(string | number)[]>([]);
const [open, close] = useModal(() => (
<Modal onClose={close} title="Payment settings" maxWidth={420}>
<div
style={{
display: "grid",
gap: 16,
minHeight: 260,
alignContent: "end",
}}
>
<Select
label="Payment method"
value={paymentMethod}
options={paymentOptions}
placeholder="Choose payment method"
onChange={setPaymentMethod}
/>
<MultiSelect
label="Available regions"
value={regions}
options={[
{ value: "eu", label: "Europe" },
{ value: "na", label: "North America" },
{ value: "apac", label: "Asia Pacific" },
]}
placeholder="Choose regions"
onChange={setRegions}
/>
</div>
</Modal>
));
return <Button onPress={open}>Open payment settings</Button>;
}
export default function DropdownModalExample() {
return (
<ModalProvider>
<DropdownModalTrigger />
</ModalProvider>
);
}Edge-to-edge content
import * as React from "react";
import { Modal, ModalProvider, useModal } from "@xsolla/xui-modal";
import { Button } from "@xsolla/xui-button";
function HeroTrigger() {
const [open, close] = useModal(() => (
<Modal openContent onClose={close} title="Gallery">
<img
src="/hero-image.jpg"
alt="Hero"
style={{ width: "100%", display: "block" }}
/>
</Modal>
));
return <Button onPress={open}>Open hero</Button>;
}
export default function EdgeToEdge() {
return (
<ModalProvider>
<HeroTrigger />
</ModalProvider>
);
}Platform Support
| Feature | Web | React Native |
| ------------- | ----------------------------------------- | ---------------------------------------------- |
| Provider | ModalProvider (with ModalRoot portal) | ModalProvider (root is a no-op stub) |
| Hook | useModal() → [open, close] | Same signature; provider stack does not render |
| Overlay | Fixed-positioned backdrop | N/A |
| Focus trap | Keyboard Tab trapping | N/A |
| Escape key | Closes modal | N/A |
| Click outside | Dismisses when closeOutside={true} | N/A |
Keyboard Interaction
| Key | Action |
| ------------- | -------------------------------------------------------- |
| Escape | Closes the modal (when onClose is provided). |
| Tab | Moves focus to the next focusable element (trapped). |
| Shift + Tab | Moves focus to the previous focusable element (trapped). |
| Enter | Activates the focused button. |
Accessibility
- Renders with
role="dialog"andaria-modal="true". titlecreates a visually hidden labelled element viaaria-labelledby; otherwisearia-labelis used.- Focus is trapped within the modal; on open it moves to
initialFocusRef, the close button, or the first focusable element. On close, focus returns to the previously active element. - The default close button has
aria-label="Close modal"; the back button hasaria-label="Go back". - Both default header buttons are
FlexButtons (variant="secondary",background), so their hover, press, and focus-ring behaviour comes from the shared button primitive. Size is controlled bycloseButtonSize(default"xl"→ 20px icon in a 36×36 hit area). The 36×36 hit area matches Figma and the bespoke header button it replaces; the glyph itself is 20px, per FEP-836 andFlexButton's shared icon scale. data-modal-idlets click-outside detection ignore portaled content (e.g.Select,Dropdown) belonging to the modal.
Troubleshooting
"useModal must be used within a ModalProvider"
useModal (or direct ModalContext access) ran outside a ModalProvider. Ensure your component tree has a ModalProvider ancestor.
Modal appears behind other content
ModalRoot uses z-index: 1000 via portal into document.body. Toast notifications use z-index: 9999 and will appear above modals by default.
Click-outside not working for portaled dropdowns
Portaled descendants must set data-modal-id (read via useModalId) on their root so the modal recognises them as in-tree.
Bottom-sheet or full-screen modal not filling width
maxWidth is forced to 100% for these types — check that no parent container is restricting width.
Focus not returning after close
The modal saves document.activeElement on mount. If the trigger element is removed from the DOM while the modal is open, focus cannot be restored.
