@xsolla/xui-modal
v0.173.2
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.
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. |
| 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>
);
}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". 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.
