Munchable's phone app is also a website. Same Expo Router tree, same screens, exported with web.output: "single" and served at app.munchable.app. Across the whole app there is exactly one platform-forked file:
apps/mobile/src/components/Stack.web.tsx
196 lines, none of which ever run on a phone. This is what is in it and why none of the cheaper options worked.
The two-line file that forces the question
Every layout in app/ imports Stack from one place:
/**
* The app's stack navigator.
*
* On iOS and Android this is Expo Router's native stack, which drives real
* UIKit / Fragment transitions. The web build resolves `Stack.web.tsx`
* instead: react-native-screens renders plain views there, so the native
* stack cannot animate on web at all and every push would land instantly.
* Both files export the same `Stack` shape and read the same `animation` and
* `presentation` options, so the layouts under `app/` stay platform-blind.
*/
export { Stack } from 'expo-router';
That is the native file, in full. The forking happens in Metro's resolver, not in the app code, which is the property worth protecting: the root layout declares what it wants and never asks which platform it is on.
const SHEET = { presentation: 'modal', animation: 'slide_from_bottom' } as const;
<Stack screenOptions={{ headerShown: false, animation: 'slide_from_right' }}>
<Stack.Screen name="index" />
<Stack.Screen name="onboarding" />
<Stack.Screen name="(app)" />
<Stack.Screen name="result" options={{ presentation: 'card' }} />
<Stack.Screen name="capture" options={SHEET} />
<Stack.Screen name="menu" options={SHEET} />
<Stack.Screen name="paywall" options={SHEET} />
<Stack.Screen name="(auth)/sign-in" options={{ presentation: 'card', gestureEnabled: false }} />
</Stack>
Pages slide in from the right. Capture, menu and paywall rise from the bottom, because they are tasks you dismiss rather than places you go. On a phone, UIKit and the Android fragment manager do all of that. On the web, react-native-screens renders a pile of plain absolutely positioned views and swaps which one is visible, so every one of those options is ignored and every navigation is an instant cut.
Why not the JavaScript stack
Expo Router vendors a JavaScript stack that does animate on the web, and it was the first thing we tried. It was rejected for a reason that has nothing to do with animation:
It sizes card content for body scrolling and toggles pointer events from animation listeners, and on web that left pages that would not scroll to the bottom, or would not scroll at all, until a reload.
That is a worse bug than no transition. A page that will not scroll is a page you cannot use, and it arrived intermittently, which is the worst way for it to arrive. Meanwhile the native stack's flat pile of views is exactly the layout our scroll views want. So the decision was to keep the layout that works and add motion on top of it, rather than to swap the layout for one that animates.
Every route is a transparent modal
A stack normally unmounts or hides everything below the focused route, which is correct and also fatal here: a page cannot slide in to reveal the page underneath if the page underneath is not being drawn.
const TRANSPARENT = {
presentation: 'transparentModal',
contentStyle: { overflow: 'hidden' },
} as const;
The native stack keeps the route below rendered when the route above declares itself transparent, so every route on the web is presented that way. Nothing shows through, because every page in this app paints its own background. On the web that option is purely a rendering instruction, and it is applied by wrapping the navigator rather than by asking screens to opt in:
const WebNavigator = withLayoutContext(Navigator, (screens) =>
screens.map((screen) => ({ ...screen, options: { ...screen.options, ...TRANSPARENT } })),
);
The overflow: 'hidden' next to it is not tidiness. A page translated one viewport to the right is content at x = 1440px, and a browser's answer to content outside the viewport is a scrollbar. Without the clip the document grows by a whole screen in each direction during every transition, and the app ends up sitting in the top-left quarter of a page you can scroll away from.
Arriving is easy
const [pushed] = useState(() => {
const state = navigation.getState();
return state.routes.length > 1 && state.routes[state.index]?.key === route.key;
});
const offset = useSharedValue(pushed && !reduced ? 1 : 0);
useEffect(() => {
offset.value = withTiming(0, { duration: SLIDE_MS, easing: motion.ease });
}, [offset]);
One shared value, 0 for in place and 1 for fully off-screen, multiplied by the window width or height depending on whether the screen asked for slide_from_bottom.
The lazy useState initialiser is the part that matters. Only a screen pushed on top of another arrives from off-screen. The first route of a stack is simply there, and so is any route already sitting underneath when a new one mounts. Deciding that once, at mount, from the navigation state at that instant, is what stops the whole app sliding in from the right on first load.
Leaving is the hard part
A push animation can start whenever the component mounts. A pop has to happen before the thing being animated is removed, and by default the navigator removes it immediately.
navigation.addListener('beforeRemove', (e) => {
if (leaving.current || e.defaultPrevented || reduced) return;
const alone = (navigation.getState?.()?.routes.length ?? 1) <= 1;
if (alone || e.data.action.type === 'REPLACE') return;
e.preventDefault();
leaving.current = true;
const proceed = () => {
navigation.dispatch(e.data.action);
setTimeout(() => {
if (!mounted.current) return;
leaving.current = false;
offset.value = withTiming(0, { duration: SLIDE_MS, easing: motion.ease });
}, 50);
};
offset.value = withTiming(1, { duration: SLIDE_MS, easing: motion.ease }, () => {
runOnJS(proceed)();
});
});
Hold the removal, animate, then re-dispatch the very same action. Four guards, each one earned:
leaving.current stops the re-dispatched action from being intercepted by the listener a second time and animating forever.
alone skips the animation when this is the only route in the stack. Sliding the sole page off to the right would reveal bare background, and its replacement then just appears, because it mounts as the first route with nothing to push against. Boot redirecting into the app and sign-out redirecting to sign-in both go through here.
REPLACE is the same case arriving under a different name.
The 50ms timer is the one that looks like superstition and is not. The contract is that the re-dispatched action unmounts this screen at once. If something absorbed it, or another listener held it, the screen is still mounted and currently parked one full viewport off-screen, where it will stay for the rest of the session, widening the document exactly as described above. So the animation is reversed if the component is still alive shortly after. A guess about the future of a dispatch, with a cheap correction when the guess is wrong, beats a permanently misplaced page.
The accessibility bug that is not obvious from the symptom
Unfocused routes get aria-hidden, because a screen reader must not read a page that is visually behind another one. Chrome refuses:
Blocked aria-hidden on an element because its descendant retained focus.
The sequence is ordinary. You tap a button, that button pushes a page, and that button is still document.activeElement inside the page that just became hidden. Chrome will not hide a focused element from assistive technology, so it drops the attribute and logs a warning, and the old page stays in the accessibility tree.
const host = useRef<View>(null);
useLayoutEffect(() => {
if (focused || typeof document === 'undefined') return;
const node = host.current as unknown;
const active = document.activeElement;
if (node instanceof HTMLElement && active instanceof HTMLElement && node.contains(active)) {
active.blur();
}
}, [focused]);
useLayoutEffect rather than useEffect is the whole fix. A layout effect runs in the same task as the DOM mutation that set the attribute, ahead of the check that would reject it. In a passive effect the blur lands a frame late, after Chrome has already made up its mind.
The same five lines appear in our Collapsible for the same reason, and that duplication is deliberate: they are two unrelated components that both happen to hide a subtree the user may have just been typing in.
One function that has to return an element
A small trap at the end. screenLayout is called as a plain function inside the navigator's render, not mounted as a component:
const layout = (args: Args) => <Transition {...args} />;
Inlining the body of Transition into layout would be calling six hooks in a function that React does not know is a component, which is a hooks-order crash waiting for the first conditional render. The one-line indirection gives those hooks a component of their own.
See it working
- app.munchable.app is the thing itself. Move between screens and watch pages travel sideways; open the scan or paywall screens and watch them rise instead. Then turn on Reduce Motion in your OS and watch all of it become instant with the same layout.
- munchable.app is the marketing site for comparison, a plain Next.js app with no navigator in it at all.
- munchable.app/recipes is the public version of a library that exists as a screen in the app too, which is the reason the same tree has to work in both places.
If you are shipping an Expo Router app to the web and the navigation feels like a slideshow, this is why. The fix is not a flag.
Top comments (0)