flemo

LayoutScreenExperimental

Shared-element morphs between two screens — the list thumbnail that unfolds into the detail image

<LayoutScreen> is a drop-in replacement for <Screen> that adds shared-element morphing across navigation. A thumbnail in a list "unfolds" into the hero image on the next screen, then folds back when you go back. It's the same gesture iOS uses for photos and Music, and Material 3 calls container transform.

It lives in @flemo/react-layout — a separate package from @flemo/react. Motion is its peer dependency, so apps that don't morph don't pay for it.

When to reach for it

You haveUse
A static list → detail navigation with no continuity between cards<Screen>
A thumbnail/card that should visually become the detail screen<LayoutScreen>
A hero image that needs to expand across the transition<LayoutScreen>
Two screens that should look like one piece of UI mid-flight<LayoutScreen>

If the source and destination don't share any element, you don't need it. A plain <Screen> with the default cupertino or material transition is lighter and simpler.

Install

pnpm add @flemo/react-layout motion

motion is a peer dependency. If you're already using motion elsewhere in your app, you already have it.

The mental model

A morph is four pieces working together:

PieceWhere it goesJob
transitionName: "layout"The navigate.push optionScreen-level cross-fade that won't hide the morph
layoutId (push option)The navigate.push optionThe pairing key, made available on the destination
<LayoutConfig>Outermost wrapper around the morphing tree on each screenAligns motion's layout timing with the screen transition
<LayoutScreen>Destination screen (replaces <Screen>)Keeps the layoutId pairing alive across unmount
motion.* + layoutIdEvery element that should morph (image, title, container, …)Tells motion which DOM nodes are "the same"

The pairing rule is brutally simple: the destination's layoutId must be byte-for-byte identical to the source's layoutId for each motion element. If they differ, motion can't pair them and the morph silently fails (the element just fades).

Why a separate <LayoutScreen>?

<Screen> unmounts its tree when you navigate away. That's fine for a normal push — the old screen is gone, the new screen draws fresh. But morphing requires motion to see the same layoutId on both sides of the transition at the same time. <LayoutScreen> keeps the tree alive a beat longer (and wraps children in AnimatePresence) so motion can match the destination node against the source node and animate the FLIP.

It also paints the screen background as transparent, so the morphing element doesn't get covered by a solid color while it's animating across.

Why <LayoutConfig>?

Motion's <motion.div layout> animates with its own default spring (~stiffness 700, damping 30). The flemo screen transition runs on a different timing (layout preset: 0.3s ease). Without syncing them, the screen finishes its fade before motion finishes the morph — you see the morph land on a half-faded background, or the new screen pops in while the thumbnail is still expanding.

<LayoutConfig> reads the current transition's duration + ease from the active navigate.push call and feeds them to motion's MotionConfig. The morph and the screen fade land on the same frame.

Place it as the outermost wrapper around whatever should morph — usually right inside <LayoutScreen>.

Why transitionName: "layout"?

The default cupertino transition slides the entire new screen in from the right. That slide covers the morph — by the time the new screen settles, the morphing element has nothing to animate to.

layout is a preset that does a brief, subtle opacity ramp on the screens themselves (0.97 → 1 over 0.3s) without any translation. It stays out of motion's way so the morph reads clearly.

You can author a custom transition for this slot — the requirement is "no translation that covers the source element." Most useful custom layout transitions tweak only opacity or filter.

A complete example

Gallery.tsx — the source screen
import { Screen, useNavigate } from "@flemo/react";
import { LayoutConfig } from "@flemo/react-layout";
import { motion } from "motion/react";

function Gallery() {
  const navigate = useNavigate();
  return (
    <Screen>
      <LayoutConfig>
        <ul>
          {photos.map((p) => (
            <motion.li
              key={p.id}
              layoutId={`photo-card-${p.id}`}
              onClick={() =>
                navigate.push(
                  "/photos/:id",
                  { id: p.id },
                  { transitionName: "layout", layoutId: p.id }
                )
              }
            >
              <motion.img layoutId={`photo-image-${p.id}`} src={p.thumb} />
              <motion.span layoutId={`photo-title-${p.id}`}>{p.title}</motion.span>
            </motion.li>
          ))}
        </ul>
      </LayoutConfig>
    </Screen>
  );
}
Photo.tsx — the destination screen
import { useScreen } from "@flemo/react";
import { LayoutScreen, LayoutConfig } from "@flemo/react-layout";
import { motion } from "motion/react";

function Photo() {
  const { layoutId } = useScreen();
  const photo = usePhoto(layoutId);

  return (
    <LayoutScreen>
      <LayoutConfig>
        <motion.div layoutId={`photo-card-${layoutId}`} className="fixed inset-0">
          <motion.img layoutId={`photo-image-${layoutId}`} src={photo.full} />
          <motion.h1 layoutId={`photo-title-${layoutId}`}>{photo.title}</motion.h1>
          <p>{photo.caption}</p>
        </motion.div>
      </LayoutConfig>
    </LayoutScreen>
  );
}

Things to notice:

  • Both screens wrap their motion tree in <LayoutConfig>. The source needs it so the card on its side animates on the same timeline.
  • Every paired element shares a layoutId prefix. photo-card-, photo-image-, photo-title- — each with the same id appended. They morph as one unit.
  • The destination reads useScreen().layoutId to rebuild the same id string the source passed to navigate.push. There's no other channel — layoutId from useScreen() is the only way the destination knows which card it came from.
  • The destination's morphing container is fixed inset-0. That's deliberate: morphing the container to fill the viewport is what creates the "unfold" effect.

Patterns

List → detail (the example above)

The cleanest fit. A grid of thumbnails, each becoming a full-screen detail view.

Card → modal-style overlay

Same shape, but the destination doesn't fill the full viewport — it morphs to a card-sized sheet over the previous screen. Set the destination's screen background to transparent (already the default in <LayoutScreen>) so the source stays visible underneath.

Hero shared element (image stays put)

Tag only the image with layoutId, leave the surrounding chrome unkeyed. The image floats from one position to another while the rest of the screen does its normal transition. Works especially well for header images that "slide up" into a detail page.

Common pitfalls

"The morph runs on the wrong timing — feels rubbery or out of sync"

You forgot <LayoutConfig> on at least one side. Motion is using its default spring instead of the screen transition's duration/ease. Add <LayoutConfig> to both screens.

"The new screen slides in and I don't see any morph"

You're missing transitionName: "layout" on the navigate.push call. The default cupertino slide is hiding the morph. Add it:

navigate.push("/photos/:id", { id: p.id }, { transitionName: "layout", layoutId: p.id });

"Nothing morphs at all — elements just fade"

The layoutId strings on the source and destination don't match. Print both:

// Source
console.log("source", `photo-image-${p.id}`);
// Destination
console.log("dest", `photo-image-${layoutId}`);

Common causes:

  • The source uses the raw p.id while the destination casts it to string (or vice versa)
  • The source passes layoutId: p.id but the destination reads useScreen().layoutId and gets undefined because the push call omitted the option
  • A typo in the prefix (photo-image- vs photo-img-)

"Only the outer container morphs — the inner image and title fade in awkwardly"

You tagged only the parent with layoutId. Tag every element that should travel together:

<motion.div layoutId={`card-${id}`}>
  <motion.img layoutId={`image-${id}`} /> {/* tag the image too */}
  <motion.h1 layoutId={`title-${id}`} /> {/* and the title */}
</motion.div>

"The morph works forward but going back is broken"

You're using <Screen> on the destination instead of <LayoutScreen>. When you pop, the destination unmounts before motion finishes the reverse morph, breaking the pairing. Switch to <LayoutScreen>.

"The source screen freezes / re-renders during the morph"

Don't put expensive components inside the morphing tree. The destination is mounted while the source is still visible during the morph — heavy work on the destination steals frames from motion's layout engine. Lazy-load detail content with Suspense so the morph runs on the shell, then content streams in.

When to skip it

  • The destination doesn't visually continue from any source element. Use <Screen> with a regular transition.
  • You want a generic "page slide" between two unrelated screens. cupertino or material is the right call.
  • You need to ship the smallest possible bundle and don't morph anywhere. Don't install @flemo/react-layout at all — it pulls in motion.

Props

<LayoutScreen> accepts the same props as <Screen> — app bars, navigation bars, safe areas, background, scroll, all identical. See the Screen page for the full table. The one difference is the default background: transparent instead of white, so the morphing element stays visible across the transition.

<LayoutConfig> accepts the same props as motion's MotionConfig. The transition field is filled in automatically from the active flemo transition; pass it explicitly if you want to override.

On this page