Layout morph
Images rearrange between named layouts (grid, mosaic, strip) with a closed-form tile morph: each image is always rendered full-canvas and only its clip-path window moves, so nothing ever reflows or distorts mid-morph.
LayoutMorph, defaults
Installation
npx shadcn add https://www.reelcn.dev/r/layout-morph.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: remotion.
Usage
<LayoutMorph
items={[{ src: "/a.jpg" }, { src: "/b.jpg" }, { src: "/c.jpg" }, { src: "/d.jpg" }]}
layouts={[{ at: 0, layout: "grid" }, { at: 1.5, layout: "mosaic" }]}
/>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.items*{ src: string; }[]
layouts*{ at: number; layout: NamedLayout; }[]
at in seconds. The layout holds at the first entry's arrangement until the next at.styleCSSProperties
classNamestring
Built from
Use
- A brand collage or portfolio grid that rearranges itself
- Any beat that needs several images to feel like one continuous composition, not a cut
Avoid
- Two images. Use
split-screenor a plain crossfade
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/layout-morph.tsx
/**
* @title Layout Morph
* @category motion
* @description Images rearrange between named layouts (grid, mosaic, strip) with a closed-form tile morph: each image is always rendered full-canvas and only its clip-path window moves, so nothing ever reflows or distorts mid-morph.
* @duration data-driven
* @use A brand collage or portfolio grid that rearranges itself
* @use Any beat that needs several images to feel like one continuous composition, not a cut
* @avoid Two images. Use `split-screen` or a plain crossfade
* @tags layout, morph, grid, mosaic, collage, flip
* @example
* <LayoutMorph
* items={[{ src: "/a.jpg" }, { src: "/b.jpg" }, { src: "/c.jpg" }, { src: "/d.jpg" }]}
* layouts={[{ at: 0, layout: "grid" }, { at: 1.5, layout: "mosaic" }]}
* />
*/
import type React from "react";
import { AbsoluteFill, Img } from "remotion";
import {
clamp01,
flipInterpolate,
layoutRectsFor,
type MotionProps,
type NamedLayout,
tween,
useMotion,
useTheme,
useViewport,
} from "./core";
export type LayoutMorphProps = MotionProps & {
items: { src: string }[];
/** `at` in seconds. The layout holds at the first entry's arrangement until the next `at`. */
layouts: { at: number; layout: NamedLayout }[];
style?: React.CSSProperties;
className?: string;
};
export function LayoutMorph({ items, layouts, style, className, ...motion }: LayoutMorphProps) {
const theme = useTheme();
const { u, width, height, orientation } = useViewport();
const m = useMotion(motion);
const sorted = layouts.slice().sort((a, b) => a.at - b.at);
const atFrame = (l: { at: number }) => m.delay + Math.round(l.at * m.fps);
let prevIndex = 0;
for (let i = 0; i < sorted.length; i++) if (atFrame(sorted[i]) <= m.frame) prevIndex = i;
const prev = sorted[prevIndex] ?? { at: 0, layout: "grid" as NamedLayout };
const next = sorted[prevIndex + 1];
const fromRects = layoutRectsFor(prev.layout, items.length, orientation, { width, height });
const toRects = next ? layoutRectsFor(next.layout, items.length, orientation, { width, height }) : fromRects;
const t = next
? clamp01(
tween(m.frame, m.fps, {
from: atFrame(prev),
duration: Math.max(1, atFrame(next) - atFrame(prev)),
motion: m.preset,
}),
)
: 1;
return (
<AbsoluteFill className={className} style={{ opacity: m.presence, ...style }}>
{items.map((item, i) => {
const rect = flipInterpolate(fromRects[i], toRects[i], t);
const inset = `${rect.y}px ${width - rect.x - rect.width}px ${height - rect.y - rect.height}px ${rect.x}px`;
return (
<Img
key={item.src}
src={item.src}
style={{
position: "absolute",
left: 0,
top: 0,
width,
height,
objectFit: "cover",
clipPath: `inset(${inset} round ${u(theme.radius)}px)`,
}}
/>
);
})}
</AbsoluteFill>
);
}