
A tour on layout animation with 3D scenes
Layout Animation
When animating components and between different component layouts, it's often desirable to let the component transition to its new layout without intermediate states.
iOS has some built-in libraries to animate layout changes. One such example is the iOS App Store card that animates from a grid card to a full-screen layout:
Implementing this on the Web can be quite complex. There are JS-based libraries that utilize WAAPI
or requestAnimationFrame
to animate layout changes. They often use the FLIP technique to animate layout changes:
-
First (F): We measure the element's initial position, size, and styles in the DOM.
-
Last (L): We measure the element's final position, size, and styles after the layout changes (e.g., due to a state update or DOM reflow).
-
Invert (I): Using the difference between the initial and final measurements, we calculate a transform (like translation, scaling, or rotation) to invert the element back to its original position and size.
-
Play (P): Finally, we animate the element from its inverted state to its final state, creating a smooth transition.
Examples
- The tabs (
FLIP
,Libraries
, andView Transitions API
) above in the header use FLIP to animate layout changes. Note how the background boxes animate to the tab you hover over and how the selected tab animates. - By clicking on the video, the video transforms into a full-screen lightbox layout:
- A group of radio buttons that animate together when selected:
Implementing FLIP involves some DOM calculations and math, one of the libraries that implements this really well is Motion.
See the Motion's documentation for more details on implementing layout animations.
Modal Animation
I'd like to focus on the modals in this portfolio and the challenges I faced while animating them.
You can see how the container, its title, and the positions and sizes of the 3D model animate when opening and closing the modal. You can pan the scene after you open the modal.
Implementation in React
I used motion to implement them. There are two ways to do this:
- Use the same elements for the button and the modal and animate the same element's position and size by changing their styles.
- Use separate elements for the button and the modal, and animate using motion's
layoutId
.
Same element
This comes with quite a few limitations. For reference, you can't make an element fixed to viewport in HTML if any of its parent has transform
, filter
, or perspective
property set. The element will be fixed to the parent's position in this case.
This is why modals or dialogs in component libraries are usually attached to the body element. This is typically done using ReactDOM.createPortal()
in React.
Additionally, if you want modal children to transition when layout changes, it's hard to style them correctly since you have to keep the element hierarchy, and we generally don't move them around in the DOM in React
. However, it's still possible to do this with absolute positions. It just seems mucky to me.
However, if there are no complex layout changes and its parents don't have any of those properties, it's quite easy to implement. The lightbox video/picture component used in my articles is an example of this:


<Picture layout style={open ? openStyle : closeStyle}/>
The actual implementation is a bit more complex as we need to prevent the main content from shifting around when the lightbox is open. But the idea is the same.
Separate elements
We could use createPortal()
in React to create a modal that's attached to the body, and animate from the base component to it with layoutId
. One thing to note is that we need to attach layoutRoot
to the containing fixed element, so the children elements don't wiggle around and are fixed to the container.
This approach resets element states, such as video playback, animations, or 3D scenes.
Combining both
Is there a way to move DOM elements in React?
- The new proposed
moveBefore()
API that allows atomic move operation for element reparenting & reordering. As of writing this, this is still experimental. - Use a
reverse portal
, which creates a portal node that holds a DOM element and usescreatePortal()
to render its children into any element. You can find one such implementation here.
I used the second approach to animate the 3D scenes in the modal. The scene is rendered in a separate element and is moved to the button or modal depending on the state. This approach preserves the scene's state and eliminates the need for expensive computations to reinitialize the 3D scenes, as they remain in the DOM once mounted.
Click move to move the DOM object here :)
Creating 3D scenes in Spline revealed a memory leak in spline-runtime
. Unmounting scenes frequently crashes WebGL contexts. I made a CodeSandbox demonstrating this. Keeping the scene mounted in the DOM solves this issue.
A limitation of this approach was animating the scene container's width and height to simulate two elements during movement.
export function DialogPortal({
open, setOpen,
rootLayoutId = undefined,
iconId = undefined,
children,
title,
style,
gradient = true,
setAnimated = () => {
},
transition,
buttons,
onContentScroll
}: {
open: boolean;
setOpen: (open: boolean) => void;
iconId?: string;
rootLayoutId?: string;
children: React.ReactNode;
title: React.ReactNode;
style?: React.CSSProperties;
gradient?: boolean;
setAnimated?: (animated: boolean) => void;
transition?: any;
buttons?: React.ReactNode;
onContentScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
}) {
useEffect(() => {
if (open) {
lockScroll();
}
}, [open]);
const setOpenInternal = (open: boolean) => {
setOpen(open);
}
const [internalAnimated, setInternalAnimated] = useState(false);
return createPortal(
<PerformanceWrapper>
<AnimatePresence
onExitComplete={() => {
releaseScroll();
setInternalAnimated(false);
}}
mode="wait" initial={false}>
{open &&
<>
<motion.div
animate={{opacity: 1}}
exit={{opacity: 0}}
onLayoutAnimationStart={() => {
setAnimated(false);
}}
onLayoutAnimationComplete={() => {
setAnimated(true);
setInternalAnimated(true);
}}
transition={transition}
style={style}
layoutId={rootLayoutId}
initial={{borderRadius: "24px 24px 0 0"}}
id={rootLayoutId ? `${rootLayoutId}-dialog` : undefined}
className={`z-30 fixed max-w-[960px] modal-inset contain-strict bg-zinc-950 rounded-t-xl
flex flex-col gap-4 text-zinc-50 px-0.5 pt-8 pointer-events-auto`}>
<motion.div
layoutRoot
onScroll={onContentScroll}
className={`overflow-y-auto overflow-x-hidden w-full h-full ${gradient ? 'before:b-mask after:t-mask' : ''}`}>
<motion.div
className={"pt-8 px-8 max-lg:px-6 max-md:px-4 top-0 flex justify-between items-center gap-12 absolute z-40 w-full"}>
{title}
<motion.div className={"flex gap-3 justify-center items-center"}>
{buttons}
<motion.button
animate={internalAnimated ? {scale: [1, 1.3, 1], transition: {
scale: {duration: 0.6, delay: 0.5},
}} : {}}
layoutId={iconId}
onClick={() => {
setOpenInternal(false)
}}
onTouchEnd={() => {
setOpenInternal(false)
}}
whileTap={{scale: 0.96}}
className={`modal-tr-button`}>
<XIcon size={21} strokeWidth={2}/>
</motion.button>
</motion.div>
</motion.div>
{children}
</motion.div>
</motion.div>
<motion.div
variants={{
hidden: {
opacity: 0,
transition: {
duration: 0.2
}
},
visible: {
opacity: 1,
transition: {
delay: 0.04,
duration: 0.2
}
}
}}
initial="hidden"
exit="hidden"
animate="visible"
className={"backdrop-blur-2xl z-[25] fixed bg-[rgba(0,0,0,0.18)] inset-0 pointer-events-auto"}
onClick={() => {
setOpenInternal(false);
}}
>
</motion.div>
</>
}
</AnimatePresence></PerformanceWrapper>, document.body)
}
Performance Considerations
When testing the portfolio performance on different devices, I noticed that layout animations and WebGL rendering become sluggish or even impossible if no form of hardware acceleration is available.
This also affects rendering and repainting performance. For instance, CSS animations that involve blur or elements with large sizes can be really taxing on the CPU.
I decided to check WebGL compatability before proceeding with mounting the 3D scenes. If WebGL isn't well-supported, 3D scenes are replaced with images or videos. I also conditionally disabled most layout animations and heavy CSS animations on devices that don't support hardware WebGL:
const detectWebGLFallback = (): number => {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') as WebGLRenderingContext | null;
if (!gl) {
console.warn('WebGL is not supported in this browser.');
return 0;
}
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (debugInfo) {
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
console.log('Renderer:', renderer);
console.log('Vendor:', vendor);
if (
renderer.toLowerCase().includes('swiftshader')
) {
console.warn('Fallback to software WebGL detected. Performance may be degraded.');
return 1; // Software fallback detected
}
} else {
console.warn('WEBGL_debug_renderer_info extension not supported.');
return 0;
}
} catch (e) {
console.error('An error occurred while detecting WebGL fallback:', e);
return 0;
}
return 2;
};
You can experiment with this by disabling any graphical acceleration in your browser settings and visiting this site again. Or, you may click the button below to manually toggle performance mode:
Tags:
FLIP,
Motion,
Spline,
WebGL,
WAAPI,
CSS Animations