@facilitronworks/react-native-windows-modal
v0.1.0-pre.1
Published
A drop-in replacement for react-native's <Modal> that actually works on react-native-windows new architecture, where the native WindowsModalHostView renders transparent flex content as an empty, unclosable shell. Delegates to the real <Modal> on every oth
Maintainers
Readme
@facilitronworks/react-native-windows-modal
A drop-in replacement for react-native's <Modal> that works on React Native Windows new architecture (Fabric / Composition), where the core <Modal> does not.
JS/TSX only — no native code, no autolinking, no rebuild.
Status: pre-release (
0.1.0-pre.1). Extracted from a shipping production Windows app (RNW 0.83.2) where it replaced every broken modal in the app.What is verified here: see Verification status.
Why this exists
On RNW new-arch, <Modal> is backed by the native WindowsModalHostView (Microsoft.ReactNative/Fabric/Composition/Modal). That component does not render an in-app overlay the way iOS and Android do. It spins up a separate top-level OS window — DesktopPopupSiteBridge + OverlappedPresenter.IsModal(true) — with its own native title bar, border and close (X) button, and sizes that window from the child's layout frame (ContentSizePolicy::None → AdjustWindowSize).
Two consequences break real-world modals:
1. transparent + flex content renders as an empty, unclosable shell.
The overwhelmingly common modal pattern is a transparent host with a full-bleed overlay child:
<Modal transparent visible={open} onRequestClose={close}>
<View style={{ flex: 1 }}>…</View>
</Modal>Inside a content-sized popup there is no bounded size for flex: 1 to fill, so the frame measures {0, 0}, AdjustWindowSize bails, and you get an empty window. Worse, it is stuck: the in-content close button never mounts because the content never laid out, and the native title-bar X only emits onRequestClose — it does not close the window itself. If your handler is on the content that never rendered, there is no way out of the modal.
2. Non-transparent full-screen content becomes a second desktop window. Complete with title bar and chrome — nothing like the in-app sheet the same code produces on iOS and Android.
The fix
react-native-paper's <Modal> works on RNW precisely because it renders an in-tree <Portal> overlay in the same window/island — no native modal host is involved. This package mirrors that approach behind the standard <Modal> API:
- On Windows: renders
childreninto an absolutely-positioned, root-level<Portal>overlay.StyleSheet.absoluteFillgives the overlay a definite size, which is exactly what the content-sized native popup could not provide — soflex: 1children lay out correctly. - On every other platform: delegates to the real
react-native<Modal>, props spread through unchanged. iOS / Android / web behaviour is byte-for-byte identical.
That means you can swap the import globally and only change Windows behaviour.
Installation
npm install @facilitronworks/react-native-windows-modal
# or: yarn add @facilitronworks/react-native-windows-modalPeer dependencies: react, react-native, and react-native-paper >= 5 — Paper's Portal is the overlay host, and Paper's useTheme() supplies the background colour for non-transparent modals.
You must have a <PaperProvider> (or a bare <Portal.Host>) mounted at your app root. That is what the Portal renders into. Without it the modal renders nothing on Windows.
// app root
<PaperProvider>
<App />
</PaperProvider>Usage
import WindowsModal from '@facilitronworks/react-native-windows-modal';
<WindowsModal transparent visible={open} onRequestClose={close}>
<View style={{ flex: 1, justifyContent: 'center' }}>
…
</View>
</WindowsModal>The simplest adoption is a project-wide import swap:
- import { Modal } from 'react-native';
+ import WindowsModal as Modal from '@facilitronworks/react-native-windows-modal';Both a named export (WindowsModal) and a default export are provided.
Supported props, and what is ignored on Windows
Every ModalProps is forwarded verbatim on non-Windows platforms. On Windows, only the props that are meaningful for an in-tree overlay are honoured:
| Prop | Windows behaviour |
| --- | --- |
| visible | Honoured — false renders null. |
| transparent | Honoured — when false, the overlay fills with backdropColor ?? theme.colors.background. |
| backdropColor | Honoured (only when not transparent). |
| onRequestClose | Honoured — also required for the tap-outside-to-close backdrop to be rendered at all. |
| onShow | Called manually when visible flips true, to preserve the RN contract. |
| unmask | Honoured — when true, forwards fsClass="fs-unmask" onto the overlay to un-mask it in FullStory session replay. Defaults to false (masked). See Session replay / FullStory. |
| animationType | Ignored. No animation — the overlay appears instantly. |
| onDismiss, statusBarTranslucent, hardwareAccelerated, supportedOrientations, presentationStyle, navigationBarTranslucent | Ignored (mobile/native-window concepts). |
Session replay / FullStory
FullStory (React Native) masks all content by default and reveals an element only when it carries the fs-unmask marker. On Windows this component renders its overlay with a plain react-native View, so — unlike the app-internal View wrapper it was extracted from, which hardcoded fsClass="fs-unmask" — the modal is masked in session replay.
To un-mask it, pass unmask:
<WindowsModal transparent visible={open} onRequestClose={close} unmask>
…
</WindowsModal>This forwards fsClass="fs-unmask" onto the overlay View. It defaults to false (masked) — the safe FullStory default — so opt in only where the modal content is safe to show in replay. Windows-only (the overlay is Windows-only); it is inert on other platforms.
Traps you will hit
- No
<Portal.Host>means no modal. The single most likely failure. If your app root has noPaperProvider, the Windows path silently renders nothing. - This is not a real modal — it does not trap focus. It is an overlay in the same window. Keyboard focus and screen-reader traversal can still reach the app tree underneath. Do not rely on it for anything security- or correctness-critical.
- Escape does not close it. The native title-bar X is gone (that is the point), but nothing replaces it — wire your own key handling if you need it.
- The backdrop only exists when you pass
onRequestClose. Without that prop there is noPressablescrim, so tapping outside does nothing. This is deliberate — a scrim that cannot close anything is a trap. animationTypesilently does nothing on Windows. Modals appear and disappear instantly; a design that depends on a slide-in will look abrupt.- Sibling z-order. The overlay sets
zIndex/elevation9999, but two of these open simultaneously stack in Portal order, not by any explicit priority. transparentbehaves differently than you might read it. It controls whether this component paints a background fill, not whether the OS window is transparent (there is no OS window).
Verification status
Verified, on this exact packaged source:
- Typechecks clean under TypeScript 5.9 in strict mode, resolved against real
react,react-native,react-native-paperand@types/reactinstallations (RN 0.83.2, Paper 5.14.5). npm packproduces a tarball containing the actual TSX source.
NOT verified — do not assume any of these:
Runtime behaviour of this package. The logic is production-proven in the app it was extracted from, but the code as packaged here has not been rendered — not on Windows, not on any platform.
Two extraction changes are new and unexercised. The original used app-local modules; both were generalised for this package:
- the overlay container was an app-internal
Viewwrapper (adding only a FullStoryfsClassprop) → now a plainreact-nativeViewthat re-appliesfsClass="fs-unmask"only when theunmaskprop is set; - the background colour came from an app-local theme context → now
useTheme()from react-native-paper.
These compile and typecheck; they have not been observed on screen. The Paper
useTheme()swap in particular assumes your Paper theme'scolors.backgroundis the colour you want behind a non-transparent modal.- the overlay container was an app-internal
No example app, no automated tests, no CI.
Behaviour with nested modals, and interaction with other Portal users, is untested.
License
MIT
