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.
Loading preview…
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>
);
}