FLIP Grid Reorder
A filterable grid whose tiles travel to their new positions instead of snapping.
- Category
- Animations
- Added
- 2026-09-20
- Deps
- 1
- Updated
- 2026-09-20
Props
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
type Item = { id: string; label: string; group: string };
export function FlipGridReorder({
items,
filters,
columns = 4,
stiffness = 320,
fadeExit = true,
}: {
items: Item[];
filters: { id: string; label: string }[];
columns?: number;
stiffness?: number;
/** Fade and scale tiles as they enter and leave. Off, only layout moves. */
fadeExit?: boolean;
}) {
const [filter, setFilter] = useState("all");
const visible = items.filter((i) => filter === "all" || i.group === filter);
return (
<div className="flex flex-col gap-3">
<div className="flex gap-1.5">
{filters.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={
"rounded-full px-3 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors " +
(filter === f.id
? "bg-blue-600 text-white"
: "border border-white/20 text-white/60 hover:text-white")
}
>
{f.label}
</button>
))}
</div>
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(" + columns + ", minmax(0, 1fr))" }}
>
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((item) => (
<motion.div
/*
* The key MUST be the item's stable id, never the array index.
* With an index key React reuses the same DOM node for a
* DIFFERENT item after a filter change, so Framer measures a
* before/after box for two unrelated things — tiles appear to
* teleport and morph instead of travelling. This one line is
* the difference between a working FLIP grid and a broken one.
*/
key={item.id}
/*
* `layout` is the whole animation: Framer records each tile's
* box before the DOM change, reads it again after, and animates
* the difference with a transform (FLIP). No manual measuring.
*/
layout
initial={fadeExit ? { opacity: 0, scale: 0.8 } : false}
animate={{ opacity: 1, scale: 1 }}
exit={fadeExit ? { opacity: 0, scale: 0.8 } : undefined}
transition={{
layout: { type: "spring", stiffness, damping: 30 },
duration: 0.18,
}}
className="flex aspect-square items-center justify-center rounded-md border border-white/15 bg-white/[0.05] text-white"
>
{item.label}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
About this animation
Animating a filtered grid is one line of Framer — `layout` on each tile — and one line of React that almost everyone gets wrong. FLIP works by recording an element's box before a DOM change and again after, then animating the difference as a transform. That only makes sense if the before and after boxes belong to the same logical item, which is exactly what a stable key guarantees and an array index key destroys: after filtering, React happily reuses the node that was item three for what is now item one, so Framer measures two unrelated things and the tiles appear to teleport and morph rather than travel. If a reorder animation looks wrong, check the key before anything else. The second detail is `mode="popLayout"` on AnimatePresence, which pulls exiting tiles out of layout flow immediately so the survivors begin moving into the freed space while the exit is still playing — without it the grid waits, then jumps. Keep the spring firm; a floaty reorder on a dense grid reads as lag rather than motion.
Curator’s note
Built for viberdy 2.0. Written specifically around the index-key failure, which is the single most common reason people's FLIP grids look broken.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Flip Reveal Tiles
animationsA tile grid that flips on a diagonal wavefront to resolve into one image.
Animated Stat Grid
sectionsA row of stat counters that count up together the moment they scroll into view.
Searchable FAQ Accordion
patternsA filterable FAQ list where each answer expands independently.