Skip to content
Native Animation
Routed Shared Element animation preview

This preview uses fictional demo content. See the props below for the customization options available in this component.

npx nativeanimation add routed-shared-element

Routed Shared Element

A cover-to-detail transition with a measured return path, with configurable items, labels, palette, and fonts.

$9 for this animation

Add to your project

Copy the component and its assets into your app with the Native Animation CLI, or paste the agent prompt into Claude Code, Codex or Cursor once the MCP server is connected.

About this animation

Related guides

A cover-to-detail transition with a measured return path, with configurable items, labels, palette, and fonts.

Why this one

A card expansion usually assumes its source remains mounted. This component keeps the list mounted behind detail, retains a snapshot by stable ID, remeasures the origin on Back, and falls back when that source is missing or outside the viewport. Selection is independent of list order. Direct host entry has no invented origin. This is a Two-route simulation, not universal native shared-element navigation.

How it works

  1. open(id) requests a route. A controlled host accepts or rejects that request through route.
  2. Both layout destinations are mounted for measurement relative to a common root. Original artwork remains visible until both rectangles are valid. Supply imageSource on each item to use a photo, or omit it for the gradient/compass fallback.
  3. One persistent overlay outside both branches interpolates position, width, height, and radius from routeProgress. The same optional local photo is used for the thumbnail, overlay, and hero; text remains live native text.
  4. The outgoing content fades for 100 ms, followed by a 20 ms gap and a 140 ms entrance. The geometric spring uses damping 30, stiffness 320, and explicit mass 1. Back can reverse from its current progress. A changed destination is rebased to preserve the current cover position.
  5. At rest the overlay is removed and the inactive detail branch unmounts; the list stays mounted. Back measures the origin again, including after rotation or reordering. An absent or out-of-view source gets a 180 ms detail exit over the already visible list.
  6. Direct entry fades detail over the mounted list; detail-ID replacement uses sequential detail fades over that same base (transitionDuration, default 200 ms per side). A missing ID renders the supplied missing message and Back. Stale measurements and completion callbacks are ignored using a navigation generation.

Install

Copy this folder without demo/. Configure Reanimated for your Expo app, then install compatible modules:

npx expo install expo-image expo-linear-gradient react-native-reanimated react-native-worklets

The optional demo additionally uses expo-blur and the catalog's fonts. The demo includes two copied local landscape photos; there are no remote assets to download and no navigator dependency. Icons: Lucide (ISC), exact paths copied from the internal kit.

Usage

import { RoutedSharedElement } from './routed-shared-element';

const places = [{
  id: 'canyon',
  title: 'Canyon overlook',
  subtitle: 'A quiet stop, 12 miles from town.',
  accessibilityLabel: 'Explore Canyon overlook',
  heroColors: ['#684737', '#34251F'] as [string, string],
}];
const labels = {
  back: 'Back', open: 'Explore', save: 'Save place',
  empty: 'No places yet.', missing: 'This place is unavailable.',
  arrival: 'Place details ready.',
};

// Place in a host View with a bounded height and flex: 1.
<RoutedSharedElement
  items={places}
  labels={labels}
  accessibilityLabel="Places"
  onRouteChange={next => recordRoute(next)}
  onSave={id => savePlace(id)}
/>

Controlled example with knobs:

const view = useRef<RoutedSharedElementRef>(null);
const [route, setRoute] = useState<SharedRoute>({ name: 'list' });

<RoutedSharedElement
  ref={view}
  items={places}
  route={route}
  onRouteChange={(next, reason) => {
    // Reject stale network responses by item ID before updating route/data.
    setRoute(next);
  }}
  onSave={savePlace}
  labels={labels}
  accessibilityLabel="Saved places"
  colorScheme="light"
  transitionDuration={200}
  motionScale={1}
  style={{ flex: 1 }}
  testID="places"
/>
// Requests continuity from a mounted origin:
view.current?.open('canyon');
// Direct entry fades detail over the list, with no origin snapshot:
setRoute({ name: 'detail', id: 'canyon' });

Import the ref and route types from the same folder. Keep controlled/uncontrolled mode fixed for the instance lifetime. Controlled props prevail: imperative methods request changes but do not mutate remote state, push a real route, or call callbacks twice. A repeated Open/Back request to the current route is ignored. Reset always requests list with reason reset; it never calls Save. Host prop updates do not emit onRouteChange.

Props

PropTypeDefaultBehavior
itemsSharedItem[]RequiredUnique stable IDs, title, subtitle, accessibilityLabel, two heroColors, and optional imageSource (expo-image source). First duplicate wins; blank IDs are omitted.
routeSharedRouteUncontrolled{name:'list'} or {name:'detail',id:string}.
defaultRouteSharedRouteListInitial uncontrolled destination. Detail is a direct entry.
onRouteChange(route, reason) => voidRequiredReasons: open, back, reset. One call per accepted local request.
onSave(id:string) => void—Only an explicit Save press. The host owns persistence and stale-ID checks.
labelsRequired string objectRequiredSee the table below.
transitionDurationnumber200Direct-entry fade per side, 0–1000 ms. Nonfinite values use 200. Does not change the spring.
colorSchemelight / dark / autoautoComplete Paper or Night palette; auto follows system.
paletteSharedPaletteScheme paletteAll nine colors: background, surface, text, muted, border, accent, onAccent, success, error.
fontFamilystringSystemBody and controls.
titleFontFamilystringSystemDetail title.
monoFontFamilystring—Reserved for numeric host content; no counter is rendered.
motionScalenumber1Clamped 0–1.5; zero disables the geometric flight. Positive values scale the shallow spatial arc while preserving measured endpoints.
styleStyleProp<ViewStyle>—Root container; no safe-area insets are added.
testIDstringrouted-shared-elementPrefix for -open-<id>, -back, -save.
accessibilityLabelstringRequiredRegion name.
LabelMeaningDemo value
backReturn actionBack
openOrigin actionExplore
saveBusiness actionSave place
emptyEmpty listNo places yet.
missingUnknown detail IDThis place is unavailable.
arrivalDiscrete arrival announcementPlace details ready.

The demo host changes save to “Place saved” after its simulated Save callback. The component contains no demo brand or editorial copy. An empty subtitle occupies no space. The selected snapshot survives removal from items until Back; opening a different nonexistent ID shows missing.

Ref

MethodEffect
open(id)Request detail and continuity from a measured visible source.
back()Request list; measure origin by ID or use the fade fallback.
reset()Request list without a business action.

States and transitions

State / triggerResult
List → Explore / open(id)Prepare both rectangles; one cover flies; text changes sequentially.
Detail → Back during flightReverse the current spring; no progress reset.
Stable detail → BackRemeasure the mounted source by ID, return, then restore origin focus.
Origin removed / offscreen / measurement timeoutDetail fades out over the mounted list, no flight to zero.
Direct entry / host changes detail IDFade detail over the mounted list; no invented source.
Unknown IDMissing icon/message and Back, no Save.
Empty dataSupplied empty copy; no exception.
Container resize during flightFinish at the active logical destination and discard old geometry; next return remeasures.
SaveCall host immediately; animation completion never performs business work.

External Expo Router integration recipe

This is documentation for an adapter, not a tested adapter implementation. Read the Expo Router introduction and the zoom-transition context.

  1. Let the URL supply id via useLocalSearchParams; normalize and validate it against host data.
  2. Keep selection, item snapshot, source ref registry, and any persistent overlay in a provider above the stack. The overlay must outlive both real screens and use a common measurement coordinate space.
  3. In the host, map selection to router.push and Back to router.back. The component's callbacks are route requests, not navigation lifecycle events. Avoid also pushing in an animation completion callback.
  4. On return, wait for the real list to mount and measure the current origin by ID. Check viewport visibility and orientation before moving. If unavailable, use the fade path, then clear the provider snapshot after restoration.
  5. A system deep link with no provider snapshot enters detail with a fade. The host must define stack restoration, exit destination, focus, and cancellation policy.

The host owns overlays between real screens, system deep links, and native stack restoration. The experimental iOS zoom API does not establish Android/web parity and is not used here. Android and web run the same geometric simulation.

Compatibility

Designed for Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and worklets 0.13.0. No Skia or experimental navigation API is required. Installed modules are Expo Go compatible.

Types, lint, formatting, dependency metadata, and bundle checks are recorded in REVIEW.md.

Local demo artwork

demo/assets/canyon.jpg is copied from ink-bloom-chapters-scroll/demo/assets/book-2-desert.jpg (768×1344). demo/assets/coast.jpg is copied from tab-indicator-liquid/demo/assets/coast-trip.jpg (900×675). Both are existing repository artwork, bundled locally without cross-piece imports. No additional artwork is required for this correction; higher-resolution replacements are still needed for full @3x detail fidelity at the widest tablet layout (at least 1296×600 landscape, opaque JPEG/WebP).

Accessibility

The root uses flex:1 and measures its actual host slot. The host supplies safe areas once. The demo has no exterior ScrollView, has a maximum content width, removes its decorative heading/brand in short slots, and keeps controls below the protagonist. Only the actual virtualized item list can scroll when data or font size exceeds its viewport; its indicator is hidden. Detail artwork shrinks before action targets do.

All action targets are at least 48 points. Pressure feedback starts on press-in at 0.97 and uses an independent spring. There is no drag-only action. Inactive branches and the overlay are excluded from accessibility and touch. Heading focus follows detail arrival; Back restores origin-control focus when that ID is still mounted. Web uses DOM focus and never calls native focus APIs. A web-only CSS outlineStyle: 'none' suppresses browser outlines, including :focus-visible; focused buttons use a subtle accent border. Announcements are discrete and supplied by the host.

Reduced motion, motionScale={0}, and large text remove geometric flights and the arrival light. Detail uses 90 ms fades over the mounted list. Font scaling stays enabled. At large text sizes the hero is omitted; the list remains available through explicit scrolling. Hosts with very small slots and long descriptions should offer an explicit expanded detail presentation; do not truncate essential data or reduce the user's font scale.

Demo and simulated content

Northwind, the two landscape destinations, and Save confirmation are demo content. There is no map, network, storage, system link, real stack, or proprietary artwork. Paper/Night, Replay, Direct entry, and Remove/Restore origin are host controls. Autoplay: list 0–600 ms, open at 600, Back at 2000, direct entry at 3000, fallback Back at 4000, reset at 4600, repeat at 5000. Real touch/pointer input clears scheduled work; no old autoplay timers resume.

The control band uses blur on iOS/web with its own 20-point clipping radius, over a readable opaque surface fallback. Android uses that fallback without requesting an unsupported blur target. Night accent/text contrast is approximately 8.82:1; Paper is approximately 6.13:1.