Motion

Morph

<Morph> pairs two elements that share a layoutId, so one element moves from the old screen to the new one while your screen transition runs unchanged.

3 min read

Places

Saved for later

Idlelayout
Duration400ms
LiveTap a thumbnail and it grows into the large image on the detail screen. Go back and it returns to where it was.

Pair two elements

Wrap the element on both screens in Morph with the same layoutId. That is all the pairing needs. The element on the new screen starts where its partner was and ends exactly on its own layout box.

Gallery.tsx
import { Morph, Screen, useNavigate } from "@flemo/react";​import { photos } from "./photos";​export function Gallery() {  const navigate = useNavigate();​  return (    <Screen>      <ul>        {photos.map((photo) => (          <li key={photo.id}>            <Morph              layoutId={`photo-${photo.id}`}              onClick={() => navigate.push("/photos/:id", { id: photo.id })}            >              <img src={photo.thumb} alt="" />            </Morph>          </li>        ))}      </ul>    </Screen>  );}
Photo.tsx
import { Morph, Screen, useParams } from "@flemo/react";​import { photoById } from "./photos";​export function Photo() {  const { id } = useParams<"/photos/:id">();​  return (    <Screen>      <Morph layoutId={`photo-${id}`} className="hero">        <img src={photoById(id).full} alt="" />      </Morph>    </Screen>  );}

What happens during the transition

  • The element on the new screen moves from the box of the element on the old screen, measured the moment navigation starts.
  • The element on the old screen switches to its exit style on the first frame. A copy of what it showed, the ghost, fades out on top of the new element.
  • The box itself animates, so the content is laid out at every size along the way, and it ends pixel-exact on its real layout box.
  • Because the content is laid out rather than scaled, let it fill the Morph's box (for example width: 100%; height: 100%). A child with a fixed size keeps that size while the box grows or shrinks around it.
  • Swipe-back moves the morph with the finger. You do not write anything for it.

Both elements must be mounted when navigation starts, because a morph pairs elements, not routes. While the transition runs the element is moved out of your tree, so do not measure or change it from outside until it ends.

Transition rules in detailDetails
  • Keep your screen transition. While the transition runs, the moving element is drawn in a layer above both screens, so a fade, a slide or an instant switch cannot clip, cover or drag it.
  • The starting box is measured where you last saw it. The two elements never cross-fade: the old one is held at its exit end style, and the ghost is a copy of what was on screen (crossFade).
  • The element returns to your tree exactly as it was. border-radius is interpolated between the values you set on each of the two elements.
  • On a pop, the element that moves is the one on the screen you return to. That is the inactive side, because active follows the stack, not the direction of navigation.
  • Give both elements the same children. Anything the element on the new screen does not contain cannot be carried across.
  • The element leaves its screen when the transition starts and returns when it ends, so a scroll container, an opaque new screen or a sliding transition cannot get in the way.
  • While the transition runs, the element is drawn above shared bars, tab bars and decorator dims. Its layer sits above every screen container, nothing inside a screen can be drawn above it, and there is currently no way to prevent this.
  • A morph belongs to the Router of its enclosing Screen and pairs only during that Router's transitions. Inside a nested Router it moves within that Router's box. See .
  • A <Part> inside a growing Morph is laid out once at its normal width, the width it has outside a transition. As the Morph grows, the Part is clipped instead of re-wrapping.
  • as renders another tag (default div). Props stay typed as a div's. Do not render a structural tag such as li or td with it; put the Morph inside the li or td instead.
  • A name with nothing registered under it animates nothing and warns once in development.

Nested morphs and shared text

A nested morph moves with its container: the card moves and its artwork stays in place on it. Text that appears on both screens should be its own nested name="text" Morph, which lays the text out again at each size between the two, without a ghost.

AlbumCard.tsx
import { Morph } from "@flemo/react";​export function AlbumCard({ id, title }: { id: string; title: string }) {  return (    <Morph layoutId={`album-${id}`} className="card">      <img src={`/covers/${id}.jpg`} alt="" />      <span style={{ display: "block", height: 24 }}>        <Morph          as="span"          name="text"          layoutId={`album-title-${id}`}          style={{ display: "block", fontSize: 16, lineHeight: "24px" }}        >          {title}        </Morph>      </span>    </Morph>  );}

Give a text Morph its own box (display: block or inline-block) with fontSize and lineHeight on it. An inline one can appear at its destination before it moves.

Why repeated text needs its own MorphDetails
  • If a card and its artwork moved on their own curves, the card would come apart mid-transition and the artwork would drift out of its box. The eye follows the container as one unit.
  • The text preset uses no ghost: a heading and the label it came from are the same words at two sizes.
  • Every morph interpolates font size, so the text is laid out again at each size rather than scaled as a bitmap.
  • Ordinary text inside a container Morph is drawn twice: the ghost fades the old screen's letters over the new screen's letters, and the text visibly doubles or blurs.
  • A nested Morph grows with its container's timing. Its own duration is not used.
  • A non-replaced inline box can receive a computed translate without its line box moving, so the words show at the destination first. A block or inline-block box fixes that, and a wrapper with a fixed height keeps the surrounding layout from collapsing while the text is moving.
  • A Morph stands for one element shared by both screens. Copy that differs between the screens (an eyebrow, a summary, controls) belongs in a Part beside it, which changes on each screen.
  • Do not wrap the text Morph in that fading Part. Fading its parent would fade the letters that must stay visible.

Presets

NameUse it forSettings
shared (default)Any element. A ghost fades out over the opaque new elementcrossFade: 0.55, radius: true, no duration
textRepeated text inside a container MorphcrossFade: 0, radius: false, no duration
zoomA card opening into a full view. The screen it sits on zooms with itshared plus carry: "screen"

Use zoom (or any carry: "screen") with a screen transition that does not move the screen, such as none or an opacity-only one. Its zoom replaces that screen's transform, so a slide would disappear. Development warns.

Timing, and an element that fills the screenDetails
  • Duration: the morph's own enter duration if set, else the running screen transition's duration, else 0.4s when the screen transition is none.
  • Easing: the morph's own, except while the screen itself moves. Then it uses the screen's easing, because the destination moves with that screen.
  • The presets set no duration on purpose, so a morph ends with its screen under any transition. They do set a curve, [0.4, 0, 0.2, 1], because borrowing a fade's front-loaded curve would make the element jump across and then sit still.
  • A morph with no duration of its own ends on the same frame as its screen.

A container that grows to fill the viewport is the same feature with a bigger box. What happens behind it is up to the screen transition. Let the previous screen follow the element out (for example exit: { scale: 1.08, filter: "blur(10px)" } in a createTransition) and leave the new screen transparent so the previous one shows through.

Write a morph transition

createMorphTransition has the same shape as createTransition. Register it with Router morphTransitions and select it with <Morph name>. flemo measures the movement on every transition. You set the timing, the fade and the options.

App.tsx
import { Route, Router, createMorphTransition } from "@flemo/react";​import { Gallery } from "./Gallery";import { Photo } from "./Photo";​const EASE = [0.4, 0, 0.2, 1] as const;​const quick = createMorphTransition({  name: "quick",  initial: {},  idle: { value: { opacity: 1 }, options: { duration: 0 } },  enter: { value: { opacity: 1 }, options: { duration: 0.3, ease: EASE } },  exit: { value: { opacity: 0 }, options: { ease: EASE } },  options: { crossFade: 0.3 }});​export function App() {  return (    <Router morphTransitions={[quick]}>      <Route path="/" element={<Gallery />} />      <Route path="/photos/:id" element={<Photo />} />    </Router>  );}​// Select it by name on both screens// <Morph layoutId={`photo-${id}`} name="quick">...</Morph>
VariantMeaning
initialExtra starting style for the new element, applied over the old element's box
idleStyle at rest, and the old element's style before it switches to exit
enterThe new element, which moves. Its duration sets how long the movement takes. Leave it out to end with the screen
exitStyle the old element switches to on the first frame. End it hidden
OptionWhat it does
crossFadeShare of the transition (0-1, default 0.55) the ghost takes to fade out. 0 makes no copy and shows the new element's content from the first frame
radiusInterpolates border-radius between the values set on the two elements (default true)
carry"screen" (default off) zooms the screen on which the element is small by exactly the element's zoom, replacing that screen's transform

End exit at opacity: 0. A visible end style keeps the old element drawn, covered on push and revealed on pop, and it flickers after a pop ends. Development warns.

Pop direction, raw factory and option edge casesDetails
StatusScreenMorph variant
PUSHING / REPLACING, activethe new screenenter, moves
PUSHING / REPLACING, inactivethe previous screen, going behind or leavingexit, switches at once
POPPING, activethe closing screen, still on topexit, switches at once
POPPING, inactivethe screen behind, returningenter, moves
COMPLETEDtransition endedidle

Reading active as "the side that moves" pairs morphs backwards on every pop. The moving side always starts at initial, and the side being left always starts at rest.

createRawMorphTransition takes initial, idle, pushOnEnter, pushOnExit, replaceOnEnter, replaceOnExit, popOnEnter, popOnExit and options. popOnEnter fills POPPING-false, the returning element that moves. popOnExit fills POPPING-true. Rest variants stay idle, because a pair exists only while a transition runs.

  • initial: { opacity: 0 } makes the new element start as a cross-fade. Nothing is scaled, so radius needs no correction.
  • crossFade: the new element stays opaque under the ghost. Fading both would let the background show through by a(1 - a), a brightness dip of up to 25% halfway through, which is why the presets' initial is {}.
  • crossFade: paired descendants are already hidden in the ghost, so it only holds content with no counterpart. With 0, that content looks clipped instead of fading out.
  • radius is animated with the content, never with the geometry, so the geometry keyframes stay on the compositor.
  • carry: "screen" zooms the screen on which the element is small: the previous screen on a push, the screen you return to on a pop. Every other card moves as if the view zoomed in on the tapped one.
Edit on GitHub