This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

创建自定义网页弹窗

学习如何用 Expo Router 为网页构建模态覆盖层。


Expo Router 不再提供实验性的网页模态实现。在网页上,带有 presentation: 'modal' 或 presentation: 'formSheet' 的屏幕会显示为普通的堆栈路由。

🌐 Expo Router no longer provides an experimental web modal implementation. On web, screens with presentation: 'modal' or presentation: 'formSheet' render as regular stack routes.

使用透明覆盖路径

🌐 Use a transparent overlay route

对于大多数应用,使用内置的 Stack 并在网页上将模态路由设置为 transparentModal。Expo Router 会在透明模态后保持之前的路由可见,所以路由组件只需要渲染背景和对话框。

🌐 For most apps, use the built-in Stack and set the modal route to transparentModal on web. Expo Router keeps the previous route visible behind a transparent modal, so the route component only needs to render the backdrop and dialog.

src/app/_layout.tsx
import { Stack } from 'expo-router'; import { Platform } from 'react-native'; export const unstable_settings = { anchor: 'index' }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: Platform.select({ web: 'transparentModal', default: 'modal' }), headerShown: false, animation: Platform.select({ web: 'none', default: undefined }), }} /> </Stack> ); }

对视觉效果使用特定平台的组件。网页版实现会处理背景、Esc 键和关闭操作。原生实现则保持 children 不变,因此 modal.tsx 可以在每个平台上都用 WebModal 封装它的内容。

🌐 Use a platform-specific component for the visual treatment. The web implementation handles the backdrop, the Escape key, and dismissal. The native implementation returns children unchanged, so modal.tsx can wrap its content in WebModal on every platform.

src/components/WebModal.web.tsx
import { router } from 'expo-router'; import { useEffect, type ReactNode } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; export function WebModal({ children }: { children: ReactNode }) { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && router.canGoBack()) router.back(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, []); const dismiss = () => (router.canGoBack() ? router.back() : router.replace('/')); return ( <View style={styles.overlay}> <Pressable accessibilityLabel="Dismiss modal" onPress={dismiss} style={StyleSheet.absoluteFill} /> <View role="dialog" style={styles.dialog}> {children} </View> </View> ); } const styles = StyleSheet.create({ overlay: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.4)', padding: 24, }, dialog: { width: '100%', maxWidth: 640, borderRadius: 16, backgroundColor: 'white', padding: 24 }, });
src/components/WebModal.native.tsx
import type { ReactNode } from 'react'; export function WebModal({ children }: { children: ReactNode }) { return children; }

创建自定义导航器

🌐 Build a custom navigator

对于有多个网页模态的应用,使用 StackRouter 创建一个 自定义导航器。该导航器会添加一个 modal 屏幕选项,渲染到最后一个非模态路由的堆栈,然后将每个模态路由叠加在上面。将覆盖层的样式调整为与你的应用一致。

🌐 For apps with several web modals, create a custom navigator with StackRouter. The navigator adds a modal screen option, renders the stack up to the last non-modal route, then layers each modal route on top. Style the overlay to match your app.

src/components/ModalStack.web.tsx
import { NativeStackView, type NativeStackDescriptorMap, type NativeStackNavigationOptions, type NativeStackViewState, StackRouter, createStandardRouterNavigator, type NavigatorContentProps, } from 'expo-router'; import type { ReactNode } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; type Options = NativeStackNavigationOptions & { modal?: boolean }; function YourModal({ children, onDismiss }: { children: ReactNode; onDismiss: () => void }) { return ( <View style={styles.overlay}> <Pressable onPress={onDismiss} style={StyleSheet.absoluteFill} /> <View style={styles.dialog}>{children}</View> </View> ); } function ModalStackContent({ state, descriptors, actions }: NavigatorContentProps<Options>) { // Filter preloaded routes. const activeRoutes = state.routes.slice(0, state.index + 1); const lastNonModalRouteIndex = activeRoutes.findLastIndex( route => !descriptors[route.key].options.modal ); const hasModals = lastNonModalRouteIndex < state.index; // Standard navigation descriptors use the same runtime shape as NativeStackView descriptors. const nativeStackDescriptors = descriptors as unknown as NativeStackDescriptorMap; if (!hasModals) { return ( <NativeStackView state={state as NativeStackViewState} descriptors={nativeStackDescriptors} /> ); } const baseStackRoutes = activeRoutes.slice(0, lastNonModalRouteIndex + 1); const baseStackPreloadedRoutes = state.routes.slice(state.index + 1); const baseStackState = { ...state, index: lastNonModalRouteIndex, routes: [...baseStackRoutes, ...baseStackPreloadedRoutes], } as NativeStackViewState; const modalRoutes = activeRoutes.slice(lastNonModalRouteIndex + 1); return ( <View style={{ flex: 1 }}> <NativeStackView state={baseStackState} descriptors={nativeStackDescriptors} /> {modalRoutes.map(route => ( <YourModal key={route.key} onDismiss={actions.back}> {descriptors[route.key].render()} </YourModal> ))} </View> ); } export const ModalStack = createStandardRouterNavigator(ModalStackContent, StackRouter); const styles = StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.4)', padding: 24, }, dialog: { width: '100%', maxWidth: 640, backgroundColor: 'white', padding: 24 }, });
src/app/_layout.web.tsx
import { ModalStack } from '../components/ModalStack.web'; export const unstable_settings = { anchor: 'index' }; export default function Layout() { return ( <ModalStack> <ModalStack.Screen name="index" /> <ModalStack.Screen name="modal" options={{ modal: true }} /> </ModalStack> ); }