Magic Modallatest
Platforms

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-modal

That 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:

next.config.ts
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:

app/magic-modal-shell.tsx
"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:

app/layout.tsx
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.

app/confirmation.tsx
"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.

app/confirmation.css
.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:

InteractionResult reason
useMagicModal().hide(data)INTENTIONAL_HIDE
Backdrop clickBACKDROP_PRESS
Completed swipeSWIPE_COMPLETE
Browser EscapeBACK_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.

On this page