I built shared-element transitions for React Router - hereβs how they work
How Routeveil morphs elements between React Router routes while handling async rendering, lazy routes, scrolling, interruptions, and cleanup.
Route transitions usually treat navigation as two completely separate moments: the current page disappears, and the destination page appears. But what if the element a user clicked did not disappear? What if the image, card, heading, or button visually continued into its new position on the next route? That is what I have been building into Routeveil, an open-source transition engine for React Router. The newest release adds shared-element transitions.
See it in action:
Interactive demo: https://www.routeveil.dev/lab/shared-elements
Routeveil can take an element from the outgoing route and morph it into a matching element on the incoming route. The transition can include changes in:
- Position
- Width and height
- Border radius
- Opacity
- Other visual properties
The actual source and destination elements still belong to their respective routes. Routeveil creates and manages the temporary visual layer connecting them.
The API
On the outgoing route, wrap an element with RouteveilSharedElement and give it a name:
import { RouteveilSharedElement } from ' routeveil/react-router '
function ProjectCard({ project }) {
return (
< RouteveilSharedElement name = { `project- ${ project . id } ` } >
< img src = { project . image } alt = { project . title } />
</ RouteveilSharedElement >
)
}
On the destination route, wrap the matching element with the same name:
import { RouteveilSharedElement } from ' routeveil/react-router '
function ProjectPage({ project }) {
return (
< RouteveilSharedElement name = { `project- ${ project . id } ` } >
< img src = { project . image } alt = { project . title } />
</ RouteveilSharedElement >
)
}
That shared name connects the two elements during navigation. Routeveil handles the measurement, temporary clone, positioning, animation, route coordination, and cleanup.
Why this is harder than a normal animation
A shared-element route transition is not simply transform: translate(...). The source and destination exist in two different route trees. When navigation starts:
- The source route may begin unmounting.
- The destination element may not exist yet.
- The incoming route may be lazy-loaded.
- Images or asynchronous content may still be rendering.
- The destination may be outside the viewport.
- Navigation may be interrupted before the animation finishes.
The engine has to keep everything synchronized without blocking React Router or leaving hidden elements and temporary clones behind.
What Routeveil does during navigation
The shared-element pipeline roughly works like this:
- Measure the outgoing element - Before the current route disappears, Routeveil records the source elementβs position, dimensions, and visual state.
- Create a temporary visual clone - A temporary clone is placed above the route content. This clone becomes the visual bridge between the two pages while the real React elements continue through their normal mount and unmount lifecycle.
- Begin the route transition - Routeveil coordinates the shared element with the selected page or overlay transition. The navigation itself is still handled by React Router.
- Wait for the destination - The incoming route may not be ready immediately. Routeveil waits for the matching destination element to appear before calculating the final animation state.
- Measure the destination element - Once the destination is ready, Routeveil records its position, size, border radius, and other supported visual properties.
- Morph between both states - The temporary clone animates from the outgoing geometry into the incoming geometry.
- Hand control back to the real element - When the animation finishes, Routeveil removes the temporary layer and reveals the actual destination element.
The application does not need to manually create portals, clone DOM nodes, calculate coordinates, or clean up interrupted animations.
Shared-element anchors
One problem appears when the matching element exists on the destination route but is farther down the page. Animating toward an off-screen target usually creates a transition that feels disconnected or lands somewhere the user cannot see. To handle that, I added scrollToSharedElement. It works like a destination anchor for the shared element. Before the destination geometry is measured, Routeveil can scroll the matching element into position. The engine then calculates the target rectangle after the scroll has completed. This means the shared element can land in the correct visible location even when its original destination position was outside the viewport.
Asynchronous destination content
Rendering a route does not always mean that its content is visually ready. A destination might still be waiting for:
- Syntax highlighting
- Images
- Data-derived rendering
- Asynchronous formatting
- Other client-side work
Routeveil includes useRouteveilPendingWork, which lets destination components register work that should finish before the incoming page is revealed.
import { useRouteveilPendingWork } from ' routeveil/react-router '
This prevents the transition from finishing only to reveal partially prepared content. There is also a safety timeout so failed work cannot trap the application inside a transition forever.
Lazy-route preloading
Routeveil can also preload matching React Router lazy-route modules before transitioned navigation starts. Available strategies include:
- intent
- viewport
- render
For example, an intent-based preload can begin when a user shows intent to navigate instead of waiting until after the click. When navigation begins, Routeveil can reuse the preload already in progress rather than starting the same work again. This gives the incoming route more time to become ready before the current route exits.
More than images
Shared elements are not limited to gallery thumbnails. The component can be used with:
- Images
- Cards
- Headings
- Buttons
- Containers
- SVG elements
- Multiple matching elements in the same transition
The goal is to make separate routes feel like connected states of the same interface.
Handling failure and interruption
Animations look simple when everything goes correctly. The difficult part is making sure the interface recovers when something goes wrong. Routeveil accounts for cases such as:
- A destination element never appearing
- A route preload failing
- Registered destination work timing out
- Navigation being interrupted
- A component unmounting during the animation
- Reduced-motion preferences
- Forward and reverse navigation
The transition should enhance navigation, never prevent it. When Routeveil cannot complete the ideal animation, it safely continues to the destination instead of leaving the application stuck.
Installation
npm install routeveil
React Router integrations are exported from:
import {
RouteveilLink,
RouteveilProvider,
RouteveilSharedElement,
RouteveilView,
useRouteveilNavigate,
useRouteveilPendingWork,
useRouteveilTransition,
} from ' routeveil/react-router '
Try it
- Shared-elements demo: https://www.routeveil.dev/lab/shared-elements
- Documentation and other transition demos: https://www.routeveil.dev
- GitHub: https://github.com/milkevich/routeveil
- npm: https://www.npmjs.com/package/routeveil
Routeveil is open source, and I would genuinely appreciate feedback from developers using React Router. Contributions, issue reports, API suggestions, browser edge cases, and stars are all welcome. I am especially interested in finding real applications where the current shared-element model breaks, because those cases will help shape the next releases.
Comments
No comments yet. Start the discussion.