Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

Our Expo web build animates its own navigation in 196 lines, and half of them are not about animation

Munchable ships from one Expo codebase to iOS, Android and the web. On the phones, navigation is the native stack: real UIKit and Fragment transitions, nothing to write. On the web, react-native-screens renders a pile of

Munchable ships from one Expo codebase to iOS, Android and the web. On the phones, navigation is the native stack: real UIKit and Fragment transitions, nothing to write. On the web, react-native-screens renders a pile of absolutely positioned plain views, so every push lands instantly with no transition at all.

The fix is one platform file. src/components/Stack.tsx is three lines:

export { Stack } from 'expo-router';

src/components/Stack.web.tsx is 196, exports the same shape, and reads the same animation and presentation options, so every layout under app/ stays platform-blind and never mentions the web.

What I did not expect is how little of that file is the animation. The slide is a translateX on a shared value. The rest is four problems that only exist because this is a browser.

Rejected first: the vendored JavaScript stack

Expo Router ships a JS stack that does animate on the web, and it was the obvious answer. It sizes card content for body scrolling and toggles pointer events from animation listeners, and the result in this app was pages that would not scroll to the bottom, or would not scroll at all until a reload. The plain pile of views is exactly what the app's scroll views want, so the decision was to keep the layout and add motion on top of it.

That is done through screenLayout, with one subtlety:

// `screenLayout` is called as a plain function inside the navigator's render,
// not mounted as a component, so it has to return an element of a component
// for Transition's hooks to have a component of their own.
const layout = (args: Args) => <Transition {...args} />;

Writing the body inline would have put useState, useEffect and useSharedValue into the navigator's own render. One extra element is the entire fix.

Problem 1: the page underneath has to still be there

A slide reveals the page below. The native stack hides every route except the focused one unless the route above is transparent, so every route on the web is presented as a transparent modal, plus one line that is the real find:

const TRANSPARENT = {
  presentation: 'transparentModal',
  contentStyle: { overflow: 'hidden' },
} as const;

Without the clip, a page translated one viewport to the right makes the document a viewport wider. The browser grows a scrollable area to reach it, and the app ends up sitting in the top-left quarter of a page you can scroll away from. On native, off-screen is off-screen. In a document, off-screen is just content.

Each page paints its own background, so nothing shows through the transparency.

Problem 2: arriving is easy, leaving is not

A screen that is pushed can animate in: it exists, then it moves. A screen that is popped is unmounted by the navigator before it can move anywhere. So the removal is held, the slide runs, and then the very same action is dispatched again:

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;
  offset.value = withTiming(1, { duration: SLIDE_MS, easing: motion.ease }, () => {
    runOnJS(proceed)();
  });
});

Three guards, and each one is a bug that happened:

  • leaving.current stops the re-dispatched action being held a second time, which is an infinite hold.
  • alone is the case where this is the only route in the stack. Boot redirecting into the app, or sign-out redirecting to sign-in, would slide the page off to reveal bare background. The replacement then simply appears, because it mounts as a first route with nothing to animate over.
  • REPLACE is the same story by a different route.

And proceed has a safety net, because preventDefault() on something you intend to let through is a promise you cannot keep alone:

const proceed = () => {
  navigation.dispatch(e.data.action);
  // If the action did not unmount this screen (absorbed, or held by another
  // listener), the page is still here and must come back, or it sits
  // off-screen for good, widening the document by a whole viewport.
  setTimeout(() => {
    if (!mounted.current) return;
    leaving.current = false;
    offset.value = withTiming(0, { duration: SLIDE_MS, easing: motion.ease });
  }, 50);
};

A screen stuck off-screen is worse than no animation: the page looks blank and the scrollbars say otherwise.

Problem 3: whether this screen was pushed at all

Only a screen arriving on top of another should come in from off-screen. The first route of a stack, and any route already sitting underneath, is simply there. That is read once, at mount:

const [pushed] = useState(() => {
  const state = navigation.getState();
  return state.routes.length > 1 && state.routes[state.index]?.key === route.key;
});

The lazy initialiser is load-bearing. Navigator state at mount is the push; a frame later it is just state, and the question cannot be asked again.

Problem 4: aria-hidden and the focused button

This one is pure browser. The stack marks every unfocused route aria-hidden, which is correct: the screens underneath are not available to a screen reader. But at the moment of the push, the browser's focused element is still the button that pushed the new page, and that button is inside the page being hidden. Chrome refuses to hide a focused subtree from assistive technology and logs a warning instead.

So a page that loses focus drops any focus it holds, in a layout effect, in the same task as the DOM update that set the attribute:

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 trick: it runs before the browser's accessibility check, so the attribute is accepted the first time.

Reduced motion is a branch, not a shorter duration

useReducedMotion() from Reanimated does three things here: the entering screen starts at its final position instead of off-screen, the beforeRemove hold is skipped entirely so navigation is immediate, and no timing animation is ever started. Not a faster slide. No slide, and no held removal either, because holding a removal for an animation nobody will see is just latency.

Where to look

The transitions themselves are behind a login, so the honest link is the product rather than a demo: munchable.app has the app, and the about page says what the thing is and who publishes it. The web build lives at app.munchable.app, where the signed-out route you can open directly is the onboarding flow, which is one route with its own internal steps and so does not exercise any of the above. The public recipe pages are the Next.js site, a different app entirely.

If you are shipping Expo to the web and the transitions bother you: budget for the clip, the hold and the blur. The slide is the easy part.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.