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 have | Use |
|---|---|
| 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 motionmotion 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:
| Piece | Where it goes | Job |
|---|---|---|
transitionName: "layout" | The navigate.push option | Screen-level cross-fade that won't hide the morph |
layoutId (push option) | The navigate.push option | The pairing key, made available on the destination |
<LayoutConfig> | Outermost wrapper around the morphing tree on each screen | Aligns motion's layout timing with the screen transition |
<LayoutScreen> | Destination screen (replaces <Screen>) | Keeps the layoutId pairing alive across unmount |
motion.* + layoutId | Every 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
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>
);
}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
layoutIdprefix.photo-card-,photo-image-,photo-title-— each with the sameidappended. They morph as one unit. - The destination reads
useScreen().layoutIdto rebuild the same id string the source passed tonavigate.push. There's no other channel —layoutIdfromuseScreen()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.idwhile the destination casts it to string (or vice versa) - The source passes
layoutId: p.idbut the destination readsuseScreen().layoutIdand getsundefinedbecause the push call omitted the option - A typo in the prefix (
photo-image-vsphoto-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.
cupertinoormaterialis the right call. - You need to ship the smallest possible bundle and don't morph anywhere. Don't install
@flemo/react-layoutat 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.