DEV Community

Cover image for I built shared-element transitions for React Router — here’s how they work
gleb
gleb

Posted on

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:

  1. The current page disappears.
  2. 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

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:

1. Measure the outgoing element

Before the current route disappears, Routeveil records the source element’s position, dimensions, and visual state.

2. 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.

3. 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.

4. 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.

5. Measure the destination element

Once the destination is ready, Routeveil records its position, size, border radius, and other supported visual properties.

6. Morph between both states

The temporary clone animates from the outgoing geometry into the incoming geometry.

7. 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

React Router integrations are exported from:

import {
  RouteveilLink,
  RouteveilProvider,
  RouteveilSharedElement,
  RouteveilView,
  useRouteveilNavigate,
  useRouteveilPendingWork,
  useRouteveilTransition,
} from 'routeveil/react-router'
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)