Flip grid
A grid that glides its items to their new places when you sort or filter them.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/flip-grid.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/flip-grid
Filter pills and a sort. Tiles glide, filtered ones fade, and focus stays on the pill.
Usage
import { FlipGrid, FlipGridItem } from "@/components/ui/flip-grid"
<FlipGrid layoutKey={`${filter}-${sort}`}>
{sorted.map((item) => (
<FlipGridItem key={item.id} hidden={item.kind !== filter}>
{item.name}
</FlipGridItem>
))}
</FlipGrid>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| layoutKey | string | On FlipGrid. Change it whenever the order or filter changes, such as `${filter}-${sort}`. Required. | |
| hidden | boolean | false | On FlipGridItem. Fades the item out and takes it out of the tab order. |
Source
flip-grid/flip-grid.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, Flip);
const MOTION = "(prefers-reduced-motion: no-preference)";
type SnapshotProps = {
layoutKey: string;
capture: () => Flip.FlipState | null;
play: (state: Flip.FlipState) => void;
children: React.ReactNode;
};
/**
* getSnapshotBeforeUpdate is the one place React lets you read the DOM after a render and before the
* commit changes it, so an interrupted glide starts from where a tile really is. It has no hook form.
*/
class LayoutSnapshot extends React.Component<SnapshotProps> {
getSnapshotBeforeUpdate(previous: SnapshotProps) {
return previous.layoutKey === this.props.layoutKey ? null : this.props.capture();
}
componentDidUpdate(_props: SnapshotProps, _state: unknown, snapshot: Flip.FlipState | null) {
if (snapshot) this.props.play(snapshot);
}
render() {
return this.props.children;
}
}
type FlipGridProps = React.ComponentProps<"ul"> & {
/** Change this whenever the order or the filter changes. The current filter and sort joined is enough. */
layoutKey: string;
};
/** A grid whose items glide to their new places when you sort or filter them. Instant under reduced motion. */
function FlipGrid({ layoutKey, className, children, ...props }: FlipGridProps) {
const root = React.useRef<HTMLUListElement>(null);
const motion = React.useRef(false);
const { contextSafe } = useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, () => {
motion.current = true;
return () => {
motion.current = false;
};
});
return () => media.revert();
},
{ scope: root },
);
const items = () => gsap.utils.toArray<HTMLElement>("[data-slot=flip-grid-item]", root.current);
const capture = () => (motion.current ? Flip.getState(items()) : null);
const play = contextSafe((state: Flip.FlipState) => {
const count = state.targets.length;
Flip.from(state, {
scale: true,
duration: 0.6,
ease: "power3.inOut",
stagger: Math.min(0.05, 0.3 / Math.max(count, 1)),
absoluteOnLeave: true,
onEnter: (entering) =>
gsap.fromTo(
entering,
{ opacity: 0, scale: 0.96 },
{ opacity: 1, scale: 1, duration: 0.4, delay: 0.2, ease: "power2.out" },
),
onLeave: (leaving) => gsap.to(leaving, { opacity: 0, scale: 0.96, duration: 0.24, ease: "power2.in" }),
});
});
return (
<LayoutSnapshot layoutKey={layoutKey} capture={capture} play={play}>
<ul
ref={root}
data-slot="flip-grid"
className={cn("relative m-0 grid list-none grid-cols-2 gap-3 p-0 md:grid-cols-4", className)}
{...props}
>
{children}
</ul>
</LayoutSnapshot>
);
}
type FlipGridItemProps = Omit<React.ComponentProps<"li">, "hidden"> & {
/** Filtered out: fades away and leaves the tab order. Keep the item mounted so it has an exit. */
hidden?: boolean;
};
/** One tile. Give it a stable `key`, and pass `hidden` instead of removing it. */
function FlipGridItem({ hidden, className, ...props }: FlipGridItemProps) {
return (
<li
data-slot="flip-grid-item"
data-hidden={hidden || undefined}
className={cn("min-w-0 data-hidden:hidden", className)}
{...props}
/>
);
}
export { FlipGrid, FlipGridItem };
For AI agents
Hand your agent this page, the markdown at /components/flip-grid/prompt.md, or the Copy AI prompt button above.
When to use
- A gallery or catalogue of up to about 40 tiles with filters or a sort
- When keeping sight of a tile as it moves helps people follow the change
- Tiles of equal size, where every move is a pure translate
When not to use
- Long lists or virtualised grids, because every item is measured
- Search results that change on each keystroke: swap them with no motion
- Tiles of mixed sizes, which stretch while they scale to fit
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- Keep every item mounted and pass hidden. An item you unmount has no exit, it just disappears
- Give each item a stable key, so React moves the same DOM node and Flip can follow it
- Under reduced motion the change is instant. Keep filter controls outside the grid so they hold focus
The maths
- Stagger step is min(50ms, 300ms / n), so eight tiles start 37.5ms apart and the sweep stays under 300ms.
- Columns in the demo share one width, so each move is pure translation and the scale term stays at 1.