Catalog layout

A catalog grid where every tile stays mounted and each filter change is one anime.js layout animation: survivors slide to their new cells while leavers and arrivals fade.

Show code
"use client";

import { createLayout, spring } from "animejs";
import { memo, useLayoutEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type Item = { id: string };

const Cell = memo(function Cell<T extends Item>({ item, render }: { item: T; render: (item: T) => ReactNode }) {
  return <>{render(item)}</>;
}) as <T extends Item>(props: { item: T; render: (item: T) => ReactNode }) => ReactNode;

/*
 * Tuned so the move reads as tiles travelling into place: a soft spring
 * (bounce 0.18, no visible overshoot) over 560ms, and leavers / arrivals
 * scaling between 0.9 and 1 as they fade. No stagger: createLayout staggers
 * across hidden tiles too, which held survivors back for over a second.
 */
const MOTION = {
  duration: 560,
  ease: spring({ bounce: 0.18, duration: 560 }),
  enterFrom: { opacity: 0, scale: 0.9 },
  leaveTo: { opacity: 0, scale: 0.9 },
};

const prefersReducedMotion = () =>
  typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/**
 * Show `visibleIds` in order (moving their nodes) and hide the rest. Nothing is
 * mounted or unmounted. Tiles are found by `data-tile-id`, never
 * `data-layout-id`: anime.js keeps its own node ids in that attribute and
 * deletes it on revert(), which hid every tile after a remount.
 */
function applyVisible(root: HTMLElement, visibleIds: readonly string[]) {
  const shown = new Set(visibleIds);
  const nodes = Array.from(root.querySelectorAll<HTMLElement>(":scope > [data-tile-id]"));
  const byId = new Map(nodes.map((el) => [el.dataset.tileId, el]));
  for (const id of visibleIds) {
    const el = byId.get(id);
    if (!el) continue;
    el.hidden = false;
    root.appendChild(el);
  }
  for (const el of nodes) if (!shown.has(el.dataset.tileId ?? "")) el.hidden = true;
}

/**
 * A grid whose filter and sort changes are one anime.js layout animation
 * (https://animejs.com/documentation/layout): every item stays mounted, the
 * grid records where each tile is, the DOM is reordered and hidden in place,
 * and `createLayout` animates each tile from its old box to its new one.
 * Survivors travel to their new cells, leavers shrink and fade where they
 * stood, arrivals grow in where they land.
 *
 * Cells are memoised, so a change re-renders the grid shell only. Keep
 * `renderItem` stable (module scope or `useCallback`).
 */
export function FlipGrid<T extends Item>({
  items,
  visibleIds,
  renderItem,
  className,
  "aria-label": ariaLabel,
}: {
  /** Every item, in a stable order. Never filter this list; filter `visibleIds`. */
  items: readonly T[];
  /** Ids to show, in display order. */
  visibleIds: readonly string[];
  renderItem: (item: T) => ReactNode;
  /** Columns and gaps, e.g. "grid-cols-4 gap-x-10 gap-y-16". */
  className?: string;
  "aria-label"?: string;
}) {
  const rootRef = useRef<HTMLOListElement>(null);
  const layoutRef = useRef<ReturnType<typeof createLayout> | null>(null);
  const primedRef = useRef(false);
  const visibleKey = visibleIds.join("|");

  useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const layout = createLayout(root, { children: "[data-tile-id]", ...MOTION });
    layoutRef.current = layout;
    return () => {
      layout.revert();
      layoutRef.current = null;
      primedRef.current = false;
    };
  }, []);

  useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const ids = visibleKey ? visibleKey.split("|") : [];
    const layout = layoutRef.current;
    // The first pass only places tiles; reduced motion swaps without animating.
    if (!primedRef.current || !layout || prefersReducedMotion()) {
      applyVisible(root, ids);
      primedRef.current = true;
      return;
    }
    // React never moves these nodes (stable keys, stable order), so the
    // "before" state can be recorded here, after render, before the swap.
    layout.record();
    applyVisible(root, ids);
    layout.animate();
  }, [visibleKey]);

  return (
    <ol ref={rootRef} aria-label={ariaLabel} className={cn("relative m-0 grid w-full list-none content-start p-0", className)}>
      {items.map((item) => (
        <li key={item.id} data-tile-id={item.id} className="m-0 p-0">
          <Cell item={item} render={renderItem} />
        </li>
      ))}
    </ol>
  );
}