Next.js and web
Run Magic Modal in a Next.js App Router application with React Native Web.
Magic Modal runs in the browser through React Native Web. Mount one portal in a Client Component,
call show() from an event or effect, and await the same HideReturn<T> contract used by Expo.
This guide targets the App Router with Next.js 16 and Turbopack. The repository fixture builds the published package boundary, hydrates it in Chrome, opens a modal, and verifies backdrop dismissal.
Install
React and React DOM already come with a Next.js application. Add Magic Modal and its runtime peers:
pnpm add magic-modal react-native react-native-web react-native-gesture-handler react-native-reanimated react-native-workletsreact-native supplies package types and peer resolution. The browser bundle resolves its component
imports to react-native-web.
Configure Next.js
Transpile the React Native packages. Resolve .web files before their native counterparts and map
react-native to react-native-web:
import type { NextConfig } from "next";
const config = {
reactStrictMode: true,
transpilePackages: [
"react-native-gesture-handler",
"react-native-reanimated",
],
turbopack: {
resolveAlias: {
"react-native": "react-native-web",
},
resolveExtensions: [
".web.tsx",
".web.ts",
".web.jsx",
".web.js",
".web.mjs",
".web.cjs",
".tsx",
".ts",
".jsx",
".js",
".mjs",
".cjs",
".json",
],
},
} satisfies NextConfig;
export default config;The matching repository fixture is examples/next-web.
Add the client shell
The portal and imperative ref use client-side React state. Keep them in one Client Component:
"use client";
import type { ReactNode } from "react";
import { MagicModalPortal } from "magic-modal";
export function MagicModalShell({ children }: { children: ReactNode }) {
return (
<>
{children}
<MagicModalPortal />
</>
);
}Render that shell from the server layout:
import type { ReactNode } from "react";
import { MagicModalShell } from "./magic-modal-shell";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<MagicModalShell>{children}</MagicModalShell>
</body>
</html>
);
}Mount one MagicModalPortal for the whole application. Calls made before the shell mounts throw a
MagicModalPortal not found error.
Gesture Handler runs without GestureHandlerRootView on web. Shared Expo/native layouts keep the
gesture root.
Open a modal
Any component that calls magicModal, uses useMagicModal, or owns interactive modal state needs a
"use client" boundary.
"use client";
import type { HideReturn } from "magic-modal";
import { useState } from "react";
import { MagicModalHideReason, magicModal, useMagicModal } from "magic-modal";
import "./confirmation.css";
type Confirmation = { confirmed: true };
function ConfirmationModal() {
const { hide } = useMagicModal<Confirmation>();
return (
<section className="confirmation-modal">
<h2>Publish this release?</h2>
<button onClick={() => hide({ confirmed: true })} type="button">
Publish
</button>
</section>
);
}
function describeResult(result: HideReturn<Confirmation>) {
if (result.reason === MagicModalHideReason.INTENTIONAL_HIDE) {
return "Published";
}
return `Cancelled: ${result.reason}`;
}
export function ConfirmationButton() {
const [status, setStatus] = useState("Waiting");
async function open() {
setStatus("Open");
const entry = magicModal.show<Confirmation>(ConfirmationModal, {
accessibilityLabel: "Publish this release",
swipeDirection: undefined,
});
setStatus(describeResult(await entry));
}
return (
<>
<button onClick={open} type="button">
Open confirmation
</button>
<output>{status}</output>
</>
);
}The portal provides the dialog role, modal state, focus trap, Escape handling, and focus
restoration. accessibilityLabel gives the wrapper an accessible name. The rendered component owns
its visible heading, controls, form labels, and status messages.
.confirmation-modal {
align-self: center;
background: white;
border-radius: 20px;
color: #171717;
margin: auto;
max-width: min(420px, calc(100vw - 32px));
padding: 24px;
pointer-events: auto;
}The modal container itself takes CSS through the style option:
magicModal.show(ConfirmationModal, {
style: { justifyContent: "flex-end", paddingInline: 16 },
});`style` is CSS on the web as of v10.1
It used to be StyleProp<ViewStyle>, translated at runtime by React Native
Web. The browser bundle has no React Native Web in it any more, so style is
React.CSSProperties here: one object, no arrays, and CSS property names —
paddingInline rather than paddingHorizontal, boxShadow rather than
shadowColor. See Container
style for the full list. The
React Native entry is unchanged.
Use a CSS class for complex responsive styles
Inline style cannot express a media query. A web-only modal can render
semantic HTML and reach for a CSS class when it needs one, or any other
browser-specific styling.
Backdrop, swipe, and close reasons
The backdrop closes the modal by default. A downward swipe is also enabled by default. Set
swipeDirection to "up", "down", "left", or "right" to make the choice explicit. Set it to
undefined for scroll-heavy content.
Every dismissal resolves the promise:
| Interaction | Result reason |
|---|---|
useMagicModal().hide(data) | INTENTIONAL_HIDE |
| Backdrop click | BACKDROP_PRESS |
| Completed swipe | SWIPE_COMPLETE |
| Browser Escape | BACK_BUTTON_PRESS |
magicModal.hideAll() | GLOBAL_HIDE_ALL |
BACK_BUTTON_PRESS covers system dismissal: Android's back action, web Escape, and the native
accessibility escape action. Browser navigation does not close the modal.
Read Hide results for the full discriminated union.
Read Accessibility for focus order, explicit close controls, stacked modals, and test coverage.
Troubleshooting
Next cannot resolve react-native
Install both react-native and react-native-web. Copy the resolveAlias and resolveExtensions
entries from this guide. Restart the Next.js development server.
A server component imports the modal
Move the imperative call and modal hooks into a file with "use client". A Server Component can
render that Client Component and pass serializable props to it.
The portal was not found
Mount MagicModalShell from the top-level layout. Run magicModal.show() from an event or effect
that executes after hydration.
A modal cannot receive clicks
Give the modal content its own background and layout box. If the modal renders semantic HTML, make
sure its CSS does not set pointer-events: none.
A scroll view moves the modal
Disable the modal gesture:
magicModal.show(ScrollableModal, {
swipeDirection: undefined,
});See Gestures and scroll for the shared behavior.