Next.js and web
Run Magic Modal in a Next.js App Router application.
The browser entry renders plain DOM elements and imports nothing but react. 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:
pnpm add magic-modalThat is the whole install. The web entry ships with zero React Native dependencies, so a browser
application needs neither react-native nor react-native-web. The native entries still use the
full React Native stack, and every React Native peer is optional in the manifest for exactly that
reason.
Configure Next.js
Nothing to configure. next.config.ts needs no transpilePackages, no resolveAlias, and no
.web.* entry in resolveExtensions for Magic Modal:
import type { NextConfig } from "next";
const config = {
reactStrictMode: true,
} satisfies NextConfig;
export default config;Upgrading from a React Native Web setup
Earlier versions rendered through React Native Web and this guide shipped an
alias, an extension order, and a transpile list to make that work. Delete
those entries along with react-native, react-native-web,
react-native-gesture-handler, react-native-reanimated, and
react-native-worklets if nothing else in the application uses them.
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.
There is no GestureHandlerRootView here, because there is no Gesture Handler in the browser bundle.
Shared Expo and native layouts still 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
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.