Skip to content

Mobile navigation and motion

A list opens a detail screen. The user scrolls, edits a note, opens settings, then presses the browser Back button twice. If the app rebuilt every view, the list position is gone and the note draft may disappear. If it kept every screen forever, memory and subscriptions would grow with each tap.

Mobile navigation needs explicit state, bounded retention, and history behavior that does not fight the browser. Motion should explain direction and change. It should not become another state machine in generated code.

The design assets in A design system agents can read can provide the CSS and a small navigation helper. The app should supply screen factories and business state.

Compact model

text
root -> detail -> sub-detail
  ^       ^          |
  |_______|__________|
      browser history

retained: one active branch
motion: in-app transitions only

Terms

Screen is one full mobile view with its own scroll container.

Stack entry is a screen name plus serializable parameters.

Retained branch is the mounted root, current detail, and optional sub-detail kept for state preservation.

Push adds a deeper entry.

Pop returns to the prior entry.

Replace changes the current branch without adding a browser-history trail.

Traversal is a browser Back or Forward action.

View transition is a browser-managed visual transition between two rendered states.

Safe area is the inset needed to avoid device cutouts and home indicators.

Start with viewport mechanics

Generated apps should fit the dynamic viewport, not an assumed desktop page.

Use 100dvh with a 100vh fallback. Make flex children that contain scrollers use min-height: 0; otherwise the document grows and the bottom action area leaves the screen. Apply safe-area insets to headers, bottom actions, and full-width lists. Set viewport-fit=cover.

Keep the composer or main action area in normal flex layout when possible. Fixed positioning often collides with mobile keyboards and browser chrome. If a fixed action dock is required, bound the content scroll area and include the bottom safe-area inset.

At narrow widths, show one screen at a time. At wider widths, a list and detail can sit side by side. The navigation state should stay the same across layouts. CSS decides whether retained screens are visible together.

Touch targets should measure at least 44 by 44 CSS pixels, but the visible icon can remain smaller. A checkbox may be 22 pixels if its wrapping label forms the target.

Put navigation state in one helper

Generated apps should not write scattered pushState, hidden, and animation code. A small stack helper can own:

js
const stack = enhanceStack(document.querySelector("[data-mini-stack]"))

stack.push("detail", { id: item.id })
stack.pop()
stack.replace("detail", { id: next.id })

Each entry should live in history.state under a namespaced key. Do not put navigation state in the URL fragment if another subsystem uses the fragment for capability keys. A shared-app URL may carry the only credential that grants access. Replacing it to represent a detail screen can disconnect the app or leak the wrong state into share links.

Use same-URL history entries for internal screens. Browser Back and in-app Back should follow the same path. Invalid restored entries should fall back to root instead of throwing.

Keep only one branch mounted. Root, detail, and one sub-detail cover many mini apps while preserving list scroll, field drafts, expanded rows, and loaded state. Selecting a different item can replace the retained detail branch. This avoids accumulating hidden subscriptions and DOM.

Motion follows the source of navigation

In-app taps can use a forward or backward shared-axis transition. Browser traversals should usually change state without starting another custom transition. Mobile browsers may already animate edge-swipe history; adding a second slide looks wrong.

View Transitions are useful because the browser captures old and new states. The app only sets a direction marker around the state change. CSS owns the actual animation.

Keep durations short, around 160 to 200 milliseconds for lists and screen movement. Use ease-out for entries. Animate transform and opacity, not layout properties.

List changes need a different mechanism. A keyed reconciler can preserve unchanged DOM nodes, measure old and new positions, and animate insertion, removal, and movement. Updating text inside one row should not animate the whole row. Stable keys preserve focus and in-progress controls.

Native dialog, popover, and hidden transitions can use CSS discrete transitions and @starting-style where supported. Interrupted transitions should continue from the current visual value instead of restarting.

Disable motion under prefers-reduced-motion. Avoid merely shortening it to a fast flash. If the platform exposes reduced transparency, replace blurred surfaces with opaque ones.

Scroll behavior is part of navigation

Retaining a screen should retain its scroll position. Removing and recreating it often resets to the top before the browser can restore anything.

For feeds or chats, follow new content only when the user is already near the bottom. A threshold near 96 pixels works better than exact equality because image loads and fractional layout can move the sentinel. Sending a local item can restore bottom-following. Remote updates should not drag someone away from older content.

Place scroll sentinels outside animated list parents. Otherwise insertion animation can move the sentinel and trigger competing smooth-scroll behavior. Let list motion complete against final layout positions.

Decisions and rejected options

Use browser history as the back contract. A private array disconnected from browser Back produces two navigation systems.

Store entries in history state, not the fragment. Fragments often belong to capability sharing or deep links.

Retain one branch. Unmounting everything loses useful state. Retaining all screens leaks work.

Animate in-app navigation, not browser traversal. This avoids double motion during gestures.

Use keyed reconciliation for collections. Replacing innerHTML loses focus and makes move animation impossible.

Prefer platform dialogs and popovers. Custom portals, focus traps, and scroll locks are too much code for common generated apps.

Keep swipe dismissal out until required. Sheets can use native dialog dismissal and explicit controls without gesture complexity.

Failure modes

The body scrolls behind a full-screen view. Constrain the app to the dynamic viewport and give each screen its own scroller.

The keyboard covers an input or action. Avoid unnecessary fixed positioning and test with a coarse pointer viewport.

Back erases a capability fragment. Keep navigation out of the fragment.

Every tap pushes history. Use replace when changing sibling detail branches.

Hidden screens keep growing. Retain one bounded branch and destroy replaced controllers.

Animation moves focus unexpectedly. Reconcile keyed nodes instead of rebuilding them.

Reduced motion still slides. Put all motion behind one media query and test it.

A restored depth lacks intermediate entries. Validate the full stack shape and fall back to the deepest reconstructable state.

Field checklist

  • Does the root app use dynamic viewport units and min-height: 0 correctly?
  • Are safe-area insets applied to top and bottom chrome?
  • Are narrow layouts one screen at a time?
  • Do browser and in-app Back follow the same history?
  • Is navigation state separate from capability fragments?
  • Is retention bounded to one branch?
  • Do replaced branches destroy listeners and subscriptions?
  • Are in-app and browser traversals animated differently?
  • Do keyed list updates preserve focus?
  • Does reduced motion remove transitions?
  • Are scroll positions retained across pop and push?
  • Are touch targets at least 44 pixels through the full clickable label or control?

Navigation determines where controls live. The controls themselves still need semantic markup, keyboard behavior, validation, and clear states. Native forms and accessibility makes native forms the default generated interface.

Built from field notes on durable software systems.