How Flip works
Normally, changing a CSS class from grid to flex teleports elements — the browser repaints in the new position with no transition. Flip works around this with the FLIP technique (First, Last, Invert, Play):
- First — Flip records the current position of every target element
- Last — You change the DOM/CSS (the layout jumps)
- Invert — Flip applies transforms to make each element look like it's still in its old position
- Play — Flip tweens the transforms to zero, so elements appear to slide to their new positions
Your code only sees step 1 (get state) and the trigger (change layout). Flip handles everything else.
Register and import
import gsap from "gsap";
import { Flip } from "gsap/Flip";
gsap.registerPlugin(Flip);In Next.js, this must be inside a "use client" component.
The React pattern with flushSync
The tricky part: React state updates are asynchronous. If you call setState and then Flip.from on the same tick, the DOM hasn't changed yet — Flip has nothing to animate to.
The fix is flushSync from react-dom, which forces React to commit the DOM update synchronously before the next line executes.
"use client";
import { useState } from "react";
import { flushSync } from "react-dom";
import gsap from "gsap";
import { Flip } from "gsap/Flip";
gsap.registerPlugin(Flip);
export function LayoutToggle() {
const [layout, setLayout] = useState<"grid" | "list">("grid");
const toggle = (next: "grid" | "list") => {
// 1. Capture positions BEFORE the DOM changes
const state = Flip.getState(".card");
// 2. Change layout synchronously — DOM updates immediately
flushSync(() => setLayout(next));
// 3. Animate from old positions to new
Flip.from(state, {
duration: 0.55,
ease: "power2.inOut",
stagger: 0.04,
absolute: true,
});
};
return (
<div>
<button onClick={() => toggle("grid")}>grid</button>
<button onClick={() => toggle("list")}>list</button>
<div className={layout === "grid" ? "grid grid-cols-3 gap-3" : "flex flex-col gap-2"}>
{items.map((item) => (
<div key={item.id} className="card">{item.label}</div>
))}
</div>
</div>
);
}absolute: true temporarily makes elements position: absolute during the animation so they don't affect document flow while transitioning.
Filtering with Flip
Flip is especially powerful when items appear and disappear. Pass onEnter and onLeave callbacks to animate new elements in and removed elements out.
const filter = (tag: string) => {
const state = Flip.getState(".card");
flushSync(() => setActiveTag(tag));
Flip.from(state, {
duration: 0.45,
ease: "power2.inOut",
stagger: 0.03,
absolute: true,
onEnter: (elements) =>
gsap.fromTo(
elements,
{ opacity: 0, scale: 0.85 },
{ opacity: 1, scale: 1, duration: 0.35 },
),
onLeave: (elements) =>
gsap.to(elements, { opacity: 0, scale: 0.85, duration: 0.25 }),
});
};Items that were hidden and are now shown call onEnter. Items that become hidden call onLeave. Items that stay visible but move call neither — they just tween position.
Card expand / detail panel
Flip handles any DOM restructuring, not just layout toggles. Expanding a card into a full modal is the same pattern:
const expand = (id: string) => {
const state = Flip.getState(`#card-${id}, .detail-panel`);
flushSync(() => setExpanded(id));
Flip.from(state, {
duration: 0.6,
ease: "expo.inOut",
nested: true,
});
};nested: true lets you run a Flip animation on a parent element where children are also being Flip-animated independently.
Targeting by selector vs ref
// Selector string — captures everything matching now
const state = Flip.getState(".card");
// Ref array — more explicit, useful inside components
const state = Flip.getState(cardRefs.current);
// Single element
const state = Flip.getState(containerRef.current);Selectors are convenient but be careful with dynamic lists — if elements are added between getState and from, Flip won't know about them (use onEnter for that).
