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
open(id)requests a route. A controlled host accepts or rejects that request throughroute.- Both layout destinations are mounted for measurement relative to a common root. Original artwork remains visible until both rectangles are valid. Supply
imageSourceon each item to use a photo, or omit it for the gradient/compass fallback. - 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. - 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.
- 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.
- 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-workletsThe 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
| Prop | Type | Default | Behavior |
|---|---|---|---|
items | SharedItem[] | Required | Unique stable IDs, title, subtitle, accessibilityLabel, two heroColors, and optional imageSource (expo-image source). First duplicate wins; blank IDs are omitted. |
route | SharedRoute | Uncontrolled | {name:'list'} or {name:'detail',id:string}. |
defaultRoute | SharedRoute | List | Initial uncontrolled destination. Detail is a direct entry. |
onRouteChange | (route, reason) => void | Required | Reasons: 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. |
labels | Required string object | Required | See the table below. |
transitionDuration | number | 200 | Direct-entry fade per side, 0–1000 ms. Nonfinite values use 200. Does not change the spring. |
colorScheme | light / dark / auto | auto | Complete Paper or Night palette; auto follows system. |
palette | SharedPalette | Scheme palette | All nine colors: background, surface, text, muted, border, accent, onAccent, success, error. |
fontFamily | string | System | Body and controls. |
titleFontFamily | string | System | Detail title. |
monoFontFamily | string | — | Reserved for numeric host content; no counter is rendered. |
motionScale | number | 1 | Clamped 0–1.5; zero disables the geometric flight. Positive values scale the shallow spatial arc while preserving measured endpoints. |
style | StyleProp<ViewStyle> | — | Root container; no safe-area insets are added. |
testID | string | routed-shared-element | Prefix for -open-<id>, -back, -save. |
accessibilityLabel | string | Required | Region name. |
| Label | Meaning | Demo value |
|---|---|---|
back | Return action | Back |
open | Origin action | Explore |
save | Business action | Save place |
empty | Empty list | No places yet. |
missing | Unknown detail ID | This place is unavailable. |
arrival | Discrete arrival announcement | Place 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
| Method | Effect |
|---|---|
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 / trigger | Result |
|---|---|
List → Explore / open(id) | Prepare both rectangles; one cover flies; text changes sequentially. |
| Detail → Back during flight | Reverse the current spring; no progress reset. |
| Stable detail → Back | Remeasure the mounted source by ID, return, then restore origin focus. |
| Origin removed / offscreen / measurement timeout | Detail fades out over the mounted list, no flight to zero. |
| Direct entry / host changes detail ID | Fade detail over the mounted list; no invented source. |
| Unknown ID | Missing icon/message and Back, no Save. |
| Empty data | Supplied empty copy; no exception. |
| Container resize during flight | Finish at the active logical destination and discard old geometry; next return remeasures. |
| Save | Call 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.
- Let the URL supply
idviauseLocalSearchParams; normalize and validate it against host data. - 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.
- In the host, map selection to
router.pushand Back torouter.back. The component's callbacks are route requests, not navigation lifecycle events. Avoid also pushing in an animation completion callback. - 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.
- 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.




