Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
layoutKeystringOn FlipGrid. Change it whenever the order or filter changes, such as `${filter}-${sort}`. Required.
hiddenbooleanfalseOn 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.