Route navigation

Updated: Sep 24, 2026
Use the toolkit’s navigation primitives to coordinate route motion, loading, Back input, and focus. The toolkit provides a router-independent animated page host, destination preloading, an application-level Back hook with a desktop Escape fallback, directional navigation, and an optional React Router adapter.
The APIs in this guide ship from @wearables-ui-toolkit/mrbd. The final integration section uses the separate @wearables-ui-toolkit/mrbd/react-router subpath.

Page transitions

Use PageTransition as a keyed host around the current route. It renders children in a page layer and observes transitionKey. While the key stays the same, the mounted page receives updated children. When the key changes, the toolkit keeps the outgoing page mounted while it animates out, animates the incoming page in, and then removes the outgoing page.
import { PageTransition } from '@wearables-ui-toolkit/mrbd';

<PageTransition transitionKey={route.path} direction={route.direction}>
  {renderRoute(route)}
</PageTransition>
Give the root a parent with a resolved size, typically the application content area, because the root fills its parent and clips its content. During a transition, both pages use absolute positioning and share the same stack. The toolkit marks the outgoing page aria-hidden and inert, ensuring that only the incoming page receives focus, pointer interaction, or assistive-technology output.
See PageTransition for the component reference, including its accessibility contract and recommended patterns.

PageTransition options

PropTypeDefaultDescription
transitionKey
string
required
Identifies the current page. A change starts a transition.
children
ReactNode
required
Provides the current page content.
config
PageTransitionConfig
DefaultPageTransitionConfig
Describes enter and exit motion.
direction
PageTransitionDirection
'forward'
Chooses config.forward or config.back.
disabled
boolean
false
Swaps pages without animation. onTransitionEnd still fires.
initialFocus
PageTransitionInitialFocus
'top-left'
Selects focus for a page without saved state. Restoration resolves it, so it has no effect when preservePageState is off.
preservePageState
boolean
true
Captures and restores scroll offsets and focus per key. Turning it off also disables initialFocus.
timingScale
number \| 'auto'
'auto'
Multiplies every duration, delay, and settle time.
onTransitionEnd
() => void
none
Runs after the toolkit removes the outgoing page.
className
string
''
Adds a class to the viewport element.
pageClassName
string
''
Adds a class to each page layer.
style
CSSProperties
none
Merges inline styles over the generated transition custom properties.
PageTransitionProps extends standard div attributes except children and onTransitionEnd, so additional props reach the viewport element. The toolkit installs capture handlers for focus, mouse down, and pointer down to track the most recently interacted control. Caller-provided onFocusCapture, onMouseDownCapture, and onPointerDownCapture handlers still run.

Transition sequence

The initial render starts settled on its key and does not animate. Each later key change follows this sequence:
  1. The toolkit renders both pages at their starting values in a preparation phase so the browser can rasterize them before movement begins.
  2. After one animation frame, the toolkit waits for images in the incoming page to load and decode. A short timeout prevents a slow image from blocking navigation.
  3. On the next frame, the toolkit starts the enter and exit animations.
  4. After the configtotalDurationMs, multiplied by the resolved timingScale, the toolkit unmounts the outgoing page and calls onTransitionEnd.
This sequence has two important consequences:
  • Updating children during a transition changes only the incoming page. The outgoing page keeps the content captured when the key changed.
  • Changing transitionKey during an animation restarts toward the newest key. Changing timing props does not restart it; the current transition finishes with its original timing.

timingScale

Prefer the default 'auto' value. It uses standard timing in a browser and a slower scale on Meta Ray-Ban Display glasses because the device compositor needs more time to produce the same perceived motion.
A numeric timingScale multiplies every delay, duration, and settle time. The toolkit clamps it between one quarter and four times the configured timing. A value that is not finite and positive falls back to 1. Set a number only for a deliberate timing override.

Reduced motion

The toolkit’s stylesheet reduces page enter and exit animations to a negligible duration under @media (prefers-reduced-motion: reduce), so pages change without sliding or fading. The host keeps the outgoing page mounted for the configured settle time, leaving onTransitionEnd timing unchanged.

Transition configurations

A transition configuration defines motion in each direction:
TypeShape
PageTransitionConfig
{ forward: PageTransitionMotionConfig; back?: PageTransitionMotionConfig }
PageTransitionMotionConfig
{ enter: PageTransitionStateConfig; exit: PageTransitionStateConfig; totalDurationMs: number }
PageTransitionStateConfig
{ alpha, scale, translateX: PageTransitionValueSegmentConfig }
PageTransitionValueSegmentConfig
PageTransitionSegmentConfig & { from: number; to: number }
PageTransitionSegmentConfig
{ durationMs: number; delayMs?: number; easing: string }
PageTransitionDirection
'forward' \| 'back'
alpha controls opacity, scale applies a uniform scale to page content, and translateX offsets the page layer horizontally in CSS pixels. Each segment has its own delay, duration, and easing, allowing effects such as starting a slide before a fade. totalDurationMs is the transition’s overall settle time, not the sum of its segments.
When direction is 'back' and config.back is absent, the toolkit uses the forward motion.
The toolkit provides three configurations:
ConfigRecommended use
InAppPageTransitionConfig
Route changes within one application. It defines both directions as a short horizontal slide crossed with a fade and no scaling. Forward moves the incoming page from right to left while the outgoing page leaves to the left; back reverses the motion.
AppSwitchPageTransitionConfig
Changes between applications or top-level surfaces. It provides a longer, larger forward motion with scaling so the outgoing surface recedes.
DefaultPageTransitionConfig
An alias of InAppPageTransitionConfig and the default when config is omitted.
import {
  AppSwitchPageTransitionConfig,
  PageTransition,
} from '@wearables-ui-toolkit/mrbd';

<PageTransition
  transitionKey={surface.id}
  config={AppSwitchPageTransitionConfig}
>
  {surface.content}
</PageTransition>
For custom motion, start with a provided configuration and override only the needed segments. This preserves coordinated defaults and avoids repeating every field:
import { InAppPageTransitionConfig } from '@wearables-ui-toolkit/mrbd';
import type { PageTransitionConfig } from '@wearables-ui-toolkit/mrbd';

const SlowerForward: PageTransitionConfig = {
  ...InAppPageTransitionConfig,
  forward: {
    ...InAppPageTransitionConfig.forward,
    totalDurationMs: InAppPageTransitionConfig.forward.totalDurationMs * 1.5,
  },
};

Initial focus and page state

Keep preservePageState enabled for ordinary routes. When a page is left, the toolkit records scroll offsets and one positional focus index in the latest snapshot for its transitionKey. Returning to that key restores scroll and focus before revealing the page. Two browser-history entries that share a key also share that latest snapshot. This is DOM focus-and-scroll restoration, not general page-state persistence: store router data, React state, form models, selections, and pager or carousel indices separately. The toolkit also synchronizes the control’s focus state so it shows focused material instead of only a browser outline.
State capture handles common timing races. If a click triggers navigation before focus moves, the toolkit restores the clicked control. If the browser drops focus before the snapshot, the toolkit uses the last focused control in that page.
Focus restoration is positional: the toolkit stores the control’s index in page focus order. If a returning page renders a different number of focusable elements, focus can move to a neighboring control. Prefer a stable focus order across visits so the restored target remains predictable.
initialFocus chooses a target when a key has no snapshot, such as on a first visit. The restoration pass resolves this value. With preservePageState off, initialFocus is also disabled and the page owns focus.
ValueBehavior
'top-left'
Default. Focuses the eligible element nearest the page’s top-left corner.
'none'
Leaves focus unchanged so the page can manage it.
{ selector: string }
Focuses the first matching element inside the page.
(pageElement: HTMLElement) => HTMLElement \| null
Resolves the target in application code.
PageTransitionInitialFocus is the union of these forms. The resolved element must be focusable and eligible for initial focus. The toolkit skips controls with initialFocusEligible={false}; if a selector or function returns an ineligible element, focus remains unchanged. When focusable content renders lazily, the toolkit retries after the next render, so a Suspense fallback does not prevent initial focus.

Choose a transition direction

Derive direction from navigation meaning rather than the triggering control’s position.
  • Use 'forward' when moving deeper, such as opening list details, entering a flow, or pushing a destination onto history.
  • Use 'back' when returning to a destination already behind the current one in history, including a programmatic return to a parent route.
Avoid choosing direction from the control’s left or right screen position, or alternating it for visual variety. Back motion paired with a new history entry suggests a return that the history stack cannot honor.
When the router exposes history positions, derive direction from them. The React Router adapter later in this guide provides this behavior.

Route preloading

Use preloading to warm a destination’s code, data, or both before navigation, without changing visible state. Preloading is inactive until RoutePreloadProvider receives a handler.
import { RoutePreloadProvider, RoutePreloadTarget } from '@wearables-ui-toolkit/mrbd';

<RoutePreloadProvider preloadRoute={warmRoute}>
  <RoutePreloadTarget preloadKey="/settings">
    <Button title="Settings" onActivate={openSettings} />
  </RoutePreloadTarget>
</RoutePreloadProvider>
ExportKindPurpose
RoutePreloadProvider
component
Owns the preload queue for its subtree.
RoutePreloadProviderProps
type
{ children?: ReactNode; preloadRoute?: RoutePreloadHandler }
RoutePreloadHandler
type
(preloadKey: string) => Promise<unknown> \| unknown
RoutePreloadRequest
type
(preloadKey: string) => boolean
RoutePreloadTarget
component
Renders a div that requests preloading when visible, focused, or hovered.
RoutePreloadTargetProps
type
Combines target options with standard div attributes.
useRoutePreloadTarget
hook
Returns the same behavior as bindings for an existing element.
UseRoutePreloadTargetOptions
type
Defines the hook’s options.
RoutePreloadTargetBindings
type
Defines the hook’s return value.
useRoutePreloader
hook
Returns a RoutePreloadRequest for imperative warming.

Queue behavior

The provider deduplicates and serializes requests so preloading does not compete with active interaction:
  • A key already queued or warmed is ignored. Visibility, focus, and pointer signals for the same destination therefore produce one handler call.
  • The queue handles one request at a time. When a handler returns a promise, the next request waits for it to settle.
  • The toolkit schedules work with an idle callback and uses a short timer fallback when the browser does not provide one.
  • Directional input postpones queue work. The toolkit waits until arrow keys have been quiet for slightly longer than AnimationDurations.CONTAINER_STATE_CHANGE, preventing a code download from interrupting rapid movement through a list.
  • If the handler throws or rejects, the toolkit releases the key so a later request can retry. Replacing a failed handler also allows visible targets to request warming again.
Without preloadRoute, the system remains inactive. useRoutePreloader returns a function that always returns false; the provider adds no key listener; targets create no IntersectionObserver; and targets add focus or pointer handlers only when the caller supplied handlers of its own.

Targets

RoutePreloadTarget and useRoutePreloadTarget accept the same options:
OptionTypeDefaultDescription
preloadKey
string \| null
none
Supplies the key to preloadRoute. null or omission disables the target.
preloadOnFocus
boolean
true
Warms when focus enters the target subtree.
preloadOnPointerEnter
boolean
true
Warms when a pointer enters the target.
preloadOnVisible
boolean
true
Warms when the target intersects its observation root.
root
Element \| null
nearest scroll container
Sets the observation root. Pass null to use the viewport.
rootMargin
string
no margin
Sets the IntersectionObserver root margin.
threshold
IntersectionObserverInit['threshold']
0.01
Sets the intersection ratio that counts as visible.
Prefer leaving root undefined in a scrolling list. The toolkit then uses the nearest toolkit scroll container, or the viewport when no scroll container exists. A row far below the visible scroll area will not warm merely because it intersects the window. Targets with the same root, margin, and threshold share one observer, and the toolkit stops observing a target after its first successful warm.
The hook returns RoutePreloadTargetBindings<T>: a ref, a data-route-preload-key attribute, and onFocusCapture and onPointerEnter handlers that wrap caller-provided handlers. Spread these bindings onto an existing element:
import { useRoutePreloadTarget } from '@wearables-ui-toolkit/mrbd';
import type { ReactNode } from 'react';

function PreloadingSection({
  children,
  preloadKey,
}: {
  children: ReactNode;
  preloadKey: string;
}) {
  const bindings = useRoutePreloadTarget<HTMLElement>({ preloadKey });
  return <section {...bindings}>{children}</section>;
}
For a destination the application knows will be needed next, such as the end of a countdown, call the requester directly. It returns false when no provider handler is configured:
import { useRoutePreloader } from '@wearables-ui-toolkit/mrbd';

const preload = useRoutePreloader();
preload('/summary');

Preloadable lazy routes

Use createPreloadableLazyComponent to pair a lazy component with a preload() function. The preload queue and React rendering then share the same module load:
import { createPreloadableLazyComponent } from '@wearables-ui-toolkit/mrbd';

const SettingsRoute = createPreloadableLazyComponent(
  () => import('./routes/SettingsRoute'),
  module => module.SettingsRoute,
);
The first argument loads the module, and the second selects its component, so a default export is not required. The returned PreloadableLazyComponent<Props> has these members:
MemberTypeDescription
Component
ComponentType<Props>
Renders the selected component and suspends until the module loads.
preload
() => Promise<void>
Starts the load or joins one already in progress.
Concurrent calls share an in-progress load. After a failure, the toolkit releases the failed load so the next preload() call or render retries the import. Render Component inside a Suspense boundary because loading suspends.

Back handling

Register useBackNavigation once at the application boundary for Back requests that reach the route. System Back is the primary path on Meta Ray-Ban Display. During desktop testing, an unclaimed Escape key exercises the same handler:
import { useBackNavigation } from '@wearables-ui-toolkit/mrbd';

useBackNavigation(() => {
  if (!canGoBack) {
    return false;
  }
  goBack();
});
BackNavigationHandler is () => boolean | void. One application-boundary registration produces predictable routing behavior. System Back follows the host’s browser-history path. On desktop, the hook’s document listener calls the latest handler for an unclaimed Escape key; mounted hooks receive that fallback in installation order, and the first handler to claim it stops later handlers.
The following contracts determine event ownership:
  • A component that already owns Back stops the request before route navigation. During desktop testing, preventing Escape’s default behavior expresses the same ownership.
  • Any handler return value except false claims the request. Returning false leaves it available to the host outside the application.
  • Transient overlays must dismiss before route navigation. Context menus, vertical menus, and focusable tooltips own a temporary same-URL history entry while open, so system Back dismisses them before the previous route can be reached. Desktop Escape follows the same dismissal path. Programmatic closure removes the temporary entry, and ordinary dismissal restores focus to the trigger. Modal is an exception: it does not claim Back automatically. A route that presents a modal must close it in its application-level handler before navigating.

Focus navigation

App renders a FocusNavigationProvider around the application, scoped to its content root with automatic initial focus. This provider owns directional input, the primary navigation method on Meta Ray-Ban Display, so most applications do not need another one.
Add a provider only when focus needs a different container scope or the surface uses arrow-key-only traversal. FocusNavigationProviderProps is the complete prop type. See FocusNavigationProvider for its behavior and accessibility contract.
PropTypeDefaultDescription
children
ReactNode
required
Supplies the subtree participating in focus navigation.
enabled
boolean
true
Enables or disables provider key and pointer handling.
containerRef
RefObject<HTMLElement \| null>
none
Scopes focus queries to an element. Without it, the provider renders a full-size wrapper.
blockTabNavigation
boolean
false
Prevents native Tab traversal for arrow-key-only movement.
autoFocusFirst
boolean
false
Lets the focus coordinator claim the top-left interactable on mount and page change.
Keep blockTabNavigation off for ordinary web content. Keyboard and assistive-technology users must not be trapped. Enable it only for a surface whose verified input contract is exclusively directional.
useFocusNavigation() reads the nearest provider:
MemberTypeDescription
isActive
boolean
Reports whether the provider is enabled.
moveFocus
(direction: 'up' \| 'down' \| 'left' \| 'right') => boolean
Moves focus one step and reports whether focus or scrolling changed.
activateFocused
() => void
Activates the currently focused element.
clearFocus
() => void
Returns focus to the first eligible target after it escapes the container or lands on the container element itself. Does nothing while a descendant already owns focus.
Use these methods rather than synthesizing key events. The toolkit then gives the target the same visual state it would receive from directional input.

Retain focus during navigation

Some embedded browsers briefly move focus to the document root during same-document navigation. That can make the active control lose and reacquire its focus material. Call preserveFocusedInteractableDuringNavigation() when navigation starts to open a short retention window, then call its returned function after the destination appears:
import { useRef } from 'react';
import { preserveFocusedInteractableDuringNavigation } from '@wearables-ui-toolkit/mrbd';

export function useFocusRetainingNavigation(navigateTo: (path: string) => void) {
  const releaseRef = useRef<(() => void) | null>(null);

  return {
    navigate(path: string) {
      releaseRef.current?.();
      releaseRef.current = preserveFocusedInteractableDuringNavigation();
      navigateTo(path);
    },
    settle() {
      releaseRef.current?.();
      releaseRef.current = null;
    },
  };
}
Call settle() after the destination arrives; PageTransition’s onTransitionEnd is a suitable point. Avoid releasing in the navigation tick, because the browser has not yet moved focus and the window would close too soon. The window expires automatically if the application misses a release, while an explicit release prevents retention from outliving the transition.

React Router integration

Use the separate @wearables-ui-toolkit/mrbd/react-router entry point to connect these primitives to React Router. This entry point requires react-router-dom as an optional peer dependency. Install it in applications that import the adapter; the main toolkit entry point does not include it.
npm install react-router-dom
ExportKindPurpose
ReactRouterNavigationProvider
component
Registers Back handling, repairs history, and provides the preload queue.
ReactRouterPageTransition
component
Derives the PageTransition key and direction from the router.
ReactRouterPreloadLink
component
Renders a Link that warms its destination.
useReactRouterNavigation
hook
Reads { navigateBack } from the provider.
useReactRouterPageTransition
hook
Computes transition state without rendering the component.
getBrowserHistoryIndex
function
Reads the router history index from browser history state.
getPageTransitionDirectionForHistory
function
Maps history positions and navigation type to a direction.
Exported types are ReactRouterNavigation, ReactRouterNavigationProviderProps, ReactRouterNavigationType, ReactRouterPageTransitionChildren, ReactRouterPageTransitionProps, ReactRouterPageTransitionRender, ReactRouterPageTransitionState, ReactRouterPreloadLinkProps, and UseReactRouterPageTransitionOptions.

ReactRouterNavigationProvider

Render ReactRouterNavigationProvider inside the router and around the routed application.
PropTypeDefaultDescription
children
ReactNode
none
Supplies the routed application.
fallbackTo
To \| null
'/'
Selects the Back destination at the first history entry. null disables fallback navigation.
getHistoryIndex
() => number \| null
getBrowserHistoryIndex
Supplies the current history position.
preloadRoute
RoutePreloadHandler
none
Handles the internal RoutePreloadProvider queue.
The provider connects application-level Back to router navigation. System Back traverses browser history and reaches React Router as a POP navigation. Desktop Escape and imperative navigateBack() calls use navigate(-1) when the index is greater than zero. At the first entry, or when the index is unavailable, the provider navigates to fallbackTo with replace and marks the transition as back, keeping motion aligned with the user’s navigation intent. It returns false and leaves desktop Escape unclaimed when fallbackTo is null or the current location already matches the fallback.
The provider also repairs push navigation in hosts that convert pushState to replaceState, keeping each router push reachable through system Back.
useReactRouterNavigation() returns ReactRouterNavigation, which is { navigateBack }, and throws when no provider is present. Use this hook when a control needs the same application-level Back behavior.

ReactRouterPageTransition

ReactRouterPageTransition accepts all PageTransition props except children, direction, and transitionKey, which it derives. It also accepts the following options:
PropTypeDefaultDescription
getTransitionKey
(location: Location) => string
location => location.pathname
Identifies the current page.
getHistoryIndex
() => number \| null
the provider’s, then getBrowserHistoryIndex
Supplies the current history position.
children
ReactRouterPageTransitionChildren
required
Supplies route content or a render function receiving transition state.
The render-function form exposes the state calculated by the adapter:
import { ReactRouterPageTransition } from '@wearables-ui-toolkit/mrbd/react-router';

<ReactRouterPageTransition>
  {state => <Routes location={state.location}>{routeElements}</Routes>}
</ReactRouterPageTransition>
ReactRouterPageTransitionState contains direction, historyIndex, location, navigationType ('POP' | 'PUSH' | 'REPLACE'), and transitionKey. Pass state.location to Routes so back navigation renders the destination page instead of resolving against the new location too early.
useReactRouterPageTransition(options?) returns the same state for applications that render PageTransition directly. It remembers the committed direction for each key, preventing callback identity changes during a re-render from reversing an active transition.

Direction from history

getPageTransitionDirectionForHistory(previousHistoryIndex, currentHistoryIndex, navigationType) returns 'back' only for a 'POP' navigation with a lower history index. A push, replace, forward pop, or missing index returns 'forward'. getBrowserHistoryIndex() reads this index from browser history state and returns null when unavailable, which falls back cleanly to forward motion.
ReactRouterPreloadLink renders a Link that also acts as a preload target. It accepts every Link prop and these additions:
PropTypeDefaultDescription
preloadKey
string \| null
the resolved href
Supplies the key to preloadRoute.
preloadOnFocus
boolean
true
Warms when the link receives focus.
preloadOnPointerEnter
boolean
true
Warms on pointer entry.
preloadOnVisible
boolean
true
Warms when the link becomes visible.
preloadRoot
Element \| null
nearest scroll container
Sets the observation root.
preloadRootMargin
string
no margin
Sets the observation root margin.
preloadThreshold
IntersectionObserverInit['threshold']
0.01
Sets the visibility threshold.
Because preloadKey defaults to the resolved href, a preload handler keyed by pathname works without additional link configuration. Set preloadKey={null} to disable warming for one link while leaving the provider active. The component forwards its ref to the underlying anchor.
The following example combines the application shell, router, history-derived page transitions, lazy routes, and a pathname-keyed preload handler:
import { Suspense } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { App, createPreloadableLazyComponent } from '@wearables-ui-toolkit/mrbd';
import {
  ReactRouterNavigationProvider,
  ReactRouterPageTransition,
} from '@wearables-ui-toolkit/mrbd/react-router';

const HomeRoute = createPreloadableLazyComponent(
  () => import('./routes/HomeRoute'),
  module => module.HomeRoute,
);
const SettingsRoute = createPreloadableLazyComponent(
  () => import('./routes/SettingsRoute'),
  module => module.SettingsRoute,
);

const preloadableRoutes = {
  '/': HomeRoute,
  '/settings': SettingsRoute,
};

function preloadRoute(preloadKey: string): Promise<void> | void {
  const route = preloadableRoutes[preloadKey as keyof typeof preloadableRoutes];
  return route?.preload();
}

export function ExampleApp() {
  return (
    <App>
      <BrowserRouter>
        <ReactRouterNavigationProvider preloadRoute={preloadRoute}>
          <ReactRouterPageTransition>
            {state => (
              <Suspense fallback={null}>
                <Routes location={state.location}>
                  <Route path="/" element={<HomeRoute.Component />} />
                  <Route
                    path="/settings"
                    element={<SettingsRoute.Component />}
                  />
                </Routes>
              </Suspense>
            )}
          </ReactRouterPageTransition>
        </ReactRouterNavigationProvider>
      </BrowserRouter>
    </App>
  );
}
A route can warm its destinations through links and targets:
import { Button, ListItem, Page, RoutePreloadTarget } from '@wearables-ui-toolkit/mrbd';
import { ReactRouterPreloadLink } from '@wearables-ui-toolkit/mrbd/react-router';
import { useNavigate } from 'react-router-dom';

export function HomeRoute() {
  const navigate = useNavigate();

  return (
    <Page showHeader headerText="Home">
      <ReactRouterPreloadLink to="/settings">Settings</ReactRouterPreloadLink>
      <RoutePreloadTarget preloadKey="/settings">
        <Button title="Settings" onActivate={() => navigate('/settings')} />
      </RoutePreloadTarget>
      <ListItem title="Recent" subtitle="Nothing new" />
    </Page>
  );
}
RoutePreloadTarget adds a plain, non-focusable wrapper. Wrapping an interactive component therefore does not create a nested focus target.

Validation checklist

  • Confirm that ordinary forward navigation pushes history. Reserve replace for genuine redirects so Back remains predictable.
  • Use a transition key that identifies the page rather than the render. States of the same page should share a key so they do not transition against each other.
  • Derive direction from history or navigation semantics, not screen position.
  • Confirm that preloading makes no visible change: focus stays in place, layout does not shift, and route state is not committed.
  • Verify that a restored page regains scroll offsets and focus before it becomes visible.
  • Verify on the device that open transient layers receive system Back and dismiss before the route changes. Verify the equivalent Escape path during desktop testing.