Skip to content

FLIP Grid Reorder

A filterable, shuffleable grid of rich tiles that travel to their new positions on a hand-sampled spring: survivors glide, leavers fade out of the flow, newcomers pop in, and a pointer light follows your cursor across each tile.

DOM + CSS0 deps1 file · 21 KB TSXreduced-motion aware

Freeminimalplayfulglass
Preview

Props

4
320
30
Fade in/out
Shuffle and sort
No runtime dependencies
"use client";

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";

export type FlipGridItem = {
  id: string;
  /** Short label, shown as the pill on the card. */
  label: string;
  /** Which filter this tile belongs to. */
  group: string;
  /** Optional headline on the tile. Sorting uses it (or the label). */
  title?: string;
  /** Optional small line under the headline. */
  meta?: string;
  /** Optional 0-360 hue for the tile art. Derived from the group when omitted. */
  hue?: number;
};

export type FlipGridFilter = { id: string; label: string };

const DEFAULT_ITEMS: FlipGridItem[] = [
  { id: "halide", label: "01", group: "brand", title: "Halide Rail", meta: "Identity system" },
  { id: "tessel", label: "02", group: "product", title: "Tessel Home", meta: "Mobile app" },
  { id: "orbit", label: "03", group: "motion", title: "Orbit Loader", meta: "Loop, 4s" },
  { id: "nocturne", label: "04", group: "brand", title: "Nocturne", meta: "Packaging" },
  { id: "ledger", label: "05", group: "product", title: "Ledger Pro", meta: "Dashboard" },
  { id: "tidemark", label: "06", group: "motion", title: "Tidemark", meta: "Title sequence" },
  { id: "kiln", label: "07", group: "brand", title: "Kiln & Co.", meta: "Wordmark" },
  { id: "meridian", label: "08", group: "product", title: "Meridian", meta: "Web platform" },
];

const DEFAULT_FILTERS: FlipGridFilter[] = [
  { id: "all", label: "All" },
  { id: "brand", label: "Brand" },
  { id: "product", label: "Product" },
  { id: "motion", label: "Motion" },
];

const HUES = [214, 268, 332, 24, 164, 48];
const EASE_OUT = "cubic-bezier(0.2, 0.9, 0.25, 1)";

type Mode = "original" | "shuffle" | "az";
type Leaving = { item: FlipGridItem; left: number; top: number; w: number; h: number };
type Box = { l: number; t: number; w: number; h: number };
type Pending = { before: Map<string, Box>; hBefore: number; exiting: string[]; fx: boolean; reduce: boolean };

const CSS = [
  ".fgr{--ink:#f4f5f7;--mute:rgba(244,245,247,.56);--tray:#111216;--tray-hi:#1a1b20;--line:rgba(255,255,255,.09);--chip:#f4f5f7;--chip-ink:#0b0c0e;--card:#0d0e11;--ring:rgba(255,255,255,.88);--halo:rgba(0,0,0,.6);--shadow:0 18px 36px -22px rgba(0,0,0,.9);color:var(--ink);container-type:inline-size;container-name:fgr;display:flex;flex-direction:column;gap:14px}",
  '.fgr[data-tone="light"]{--ink:#14161a;--mute:rgba(20,22,26,.56);--tray:#eceef1;--tray-hi:#e1e4e8;--line:rgba(20,22,26,.1);--chip:#14161a;--chip-ink:#fff;--card:#fff;--ring:#14161a;--halo:rgba(255,255,255,.9);--shadow:0 18px 32px -22px rgba(20,22,26,.5)}',
  ".fgr *{box-sizing:border-box}",
  ".fgr-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px}",
  ".fgr-tray{position:relative;display:inline-flex;gap:2px;padding:4px;border-radius:999px;background:var(--tray);border:1px solid var(--line);max-width:100%;overflow-x:auto;scrollbar-width:none}",
  ".fgr-ind{position:absolute;top:4px;bottom:4px;left:0;width:0;border-radius:999px;background:var(--chip);box-shadow:0 4px 14px -6px rgba(0,0,0,.6);pointer-events:none;will-change:transform,width}",
  ".fgr-ind[data-ready]{transition:transform .5s " + EASE_OUT + ",width .5s " + EASE_OUT + "}",
  ".fgr-btn{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;padding:0 15px;border:0;border-radius:999px;background:transparent;color:var(--mute);font:500 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.005em;white-space:nowrap;cursor:pointer;transition:color .25s,background-color .25s,transform .2s;-webkit-tap-highlight-color:transparent}",
  ".fgr-chip[aria-pressed=true]{color:var(--chip-ink)}",
  ".fgr-chip:hover{color:var(--ink)}.fgr-chip[aria-pressed=true]:hover{color:var(--chip-ink)}",
  ".fgr-count{font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;opacity:.55;font-variant-numeric:tabular-nums}",
  ".fgr-tools{display:inline-flex;gap:6px}",
  ".fgr-tool{background:var(--tray);box-shadow:inset 0 0 0 1px var(--line);color:var(--ink);padding:0 14px 0 12px}",
  ".fgr-tool:hover{background:var(--tray-hi)}.fgr-tool:active{transform:scale(.96)}",
  ".fgr-tool[aria-pressed=true]{background:var(--chip);color:var(--chip-ink);box-shadow:none}",
  ".fgr-tool svg{flex:none;transition:transform .55s " + EASE_OUT + "}",
  ".fgr-btn:focus-visible{outline:2px solid var(--ring)!important;outline-offset:2px!important;border-radius:999px!important;box-shadow:0 0 0 5px var(--halo)!important}",
  "@container fgr (min-width:560px){.fgr-btn{min-height:36px}}",
  ".fgr-grid{position:relative;display:grid;gap:10px;margin:0;padding:0;list-style:none;grid-template-columns:repeat(var(--cs,2),minmax(0,1fr));touch-action:pan-y}",
  "@container fgr (min-width:460px){.fgr-grid{gap:12px;grid-template-columns:repeat(var(--c,4),minmax(0,1fr))}}",
  ".fgr-card{position:relative;aspect-ratio:1/1;list-style:none;margin:0;padding:0}",
  ".fgr-card[data-leaving]{aspect-ratio:auto;pointer-events:none}",
  ".fgr-in{position:absolute;inset:0;border-radius:18px;overflow:hidden;isolation:isolate;background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow);transition:transform .45s " + EASE_OUT + ",box-shadow .45s}",
  ".fgr-art{position:absolute;inset:0;z-index:-1;background:radial-gradient(120% 90% at 18% 6%,hsl(var(--h) 92% 68%/.96),transparent 56%),radial-gradient(95% 85% at 94% 104%,hsl(calc(var(--h) + 52) 88% 54%/.92),transparent 62%),linear-gradient(160deg,hsl(var(--h) 46% 17%),hsl(calc(var(--h) + 34) 52% 7%));transition:transform .8s " + EASE_OUT + "}",
  ".fgr-sheen{position:absolute;inset:0;z-index:-1;background:linear-gradient(115deg,transparent 30%,rgba(255,255,255,.16) 48%,transparent 66%);opacity:.5;mix-blend-mode:soft-light}",
  ".fgr-glow{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .3s;background:radial-gradient(170px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.34),transparent 66%);mix-blend-mode:soft-light}",
  ".fgr-edge{position:absolute;inset:0;border-radius:inherit;padding:1px;pointer-events:none;opacity:0;transition:opacity .3s;background:radial-gradient(130px circle at var(--mx,50%) var(--my,50%),rgba(255,255,255,.85),transparent 70%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}",
  ".fgr-pill{position:absolute;top:10px;left:10px;padding:4px 8px;border-radius:999px;background:rgba(8,9,11,.42);box-shadow:inset 0 0 0 1px rgba(255,255,255,.16);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em}",
  ".fgr-text{position:absolute;left:0;right:0;bottom:0;padding:38px 13px 12px;background:linear-gradient(to top,rgba(5,6,8,.82),rgba(5,6,8,.4) 55%,transparent);color:#fff}",
  ".fgr-title{margin:0;font:600 14px/1.2 ui-sans-serif,system-ui,sans-serif;letter-spacing:-.005em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
  ".fgr-sub{margin:3px 0 0;font:500 11px/1.2 ui-sans-serif,system-ui,sans-serif;color:rgba(255,255,255,.62);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
  "@media (hover:hover){.fgr-card:hover .fgr-in{transform:translateY(-3px);box-shadow:0 26px 40px -22px rgba(0,0,0,.95)}.fgr-card:hover .fgr-art{transform:scale(1.07)}.fgr-card:hover .fgr-glow,.fgr-card:hover .fgr-edge{opacity:1}}",
  ".fgr-card[data-lit] .fgr-glow,.fgr-card[data-lit] .fgr-edge{opacity:1}",
  ".fgr-empty{margin:0;padding:28px 0;text-align:center;color:var(--mute);font:500 13px/1.4 ui-sans-serif,system-ui,sans-serif}",
  ".fgr-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}",
  "@media (prefers-reduced-motion:reduce){.fgr-in,.fgr-art,.fgr-tool svg,.fgr-ind[data-ready]{transition:none!important}.fgr-card:hover .fgr-in,.fgr-card:hover .fgr-art{transform:none!important}}",
].join("\n");

const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;

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

/* Sampled spring: progress 0 -> 1 for x'' = k(1 - x) - c x', unit mass. Cached per (k, c). */
const springCache = new Map<string, { p: number[]; ms: number }>();
function spring(k: number, c: number) {
  const key = k + ":" + c;
  const hit = springCache.get(key);
  if (hit) return hit;
  const dt = 1 / 240;
  let x = 0;
  let v = 0;
  let t = 0;
  const p: number[] = [0];
  let settled = 0;
  while (t < 1.6 && settled < 12) {
    v += (k * (1 - x) - c * v) * dt;
    x += v * dt;
    t += dt;
    if (Math.round(t / dt) % 4 === 0) {
      p.push(x);
      settled = Math.abs(1 - x) < 0.0015 && Math.abs(v) < 0.02 ? settled + 1 : 0;
    }
  }
  p.push(1);
  const out = { p, ms: Math.max(240, Math.round(t * 1000)) };
  springCache.set(key, out);
  return out;
}

function hash(s: string) {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
  return h >>> 0;
}

function order(items: FlipGridItem[], filter: string, mode: Mode, seed: number) {
  const base = items.filter((i) => filter === "all" || i.group === filter);
  if (mode === "az") {
    return [...base].sort((a, b) => (a.title ?? a.label).localeCompare(b.title ?? b.label));
  }
  if (mode === "shuffle") {
    const key = (i: FlipGridItem) => hash(i.id + ":" + seed);
    return [...base].sort((a, b) => key(a) - key(b));
  }
  return base;
}

export function FlipGridReorder({
  items = DEFAULT_ITEMS,
  filters = DEFAULT_FILTERS,
  columns = 4,
  stiffness = 320,
  fadeExit = true,
  damping = 30,
  controls = false,
  defaultFilter = "all",
  tone = "dark",
  className,
}: {
  items?: FlipGridItem[];
  filters?: FlipGridFilter[];
  /** Columns when the component is at least 460px wide. Narrower, it uses two. */
  columns?: number;
  /** Spring stiffness for the reorder travel. */
  stiffness?: number;
  /** Fade and scale tiles as they enter and leave. Off, only layout moves. */
  fadeExit?: boolean;
  /** Spring damping. Lower is bouncier. */
  damping?: number;
  /** Show the Shuffle and A-Z buttons beside the filter chips (off by default). */
  controls?: boolean;
  /** Filter id selected on first render. */
  defaultFilter?: string;
  tone?: "dark" | "light";
  className?: string;
}) {
  const [filter, setFilter] = useState(defaultFilter);
  const [mode, setMode] = useState<Mode>("original");
  const [seed, setSeed] = useState(0);
  const [leaving, setLeaving] = useState<Leaving[]>([]);

  const gridRef = useRef<HTMLUListElement | null>(null);
  const trayRef = useRef<HTMLDivElement | null>(null);
  const indRef = useRef<HTMLDivElement | null>(null);
  const nodes = useRef(new Map<string, HTMLLIElement>());
  const moveAnims = useRef(new Map<string, Animation>());
  const exitAnims = useRef(new Map<string, Animation>());
  const heightAnim = useRef<Animation | null>(null);
  const pending = useRef<Pending | null>(null);

  const cols = Math.min(6, Math.max(1, Math.round(columns) || 4));
  const visible = useMemo(() => order(items, filter, mode, seed), [items, filter, mode, seed]);
  const visibleIds = useMemo(() => new Set(visible.map((i) => i.id)), [visible]);
  const hueFor = useCallback(
    (item: FlipGridItem) => {
      if (typeof item.hue === "number" && isFinite(item.hue)) return Math.round(item.hue) % 360;
      const gi = Math.max(0, filters.findIndex((f) => f.id === item.group) - 1);
      return (HUES[gi % HUES.length] ?? 214) + (hash(item.id) % 24) - 12;
    },
    [filters],
  );

  /** Snapshot everything on screen, then commit the next state. */
  const commit = (next: { filter?: string; mode?: Mode; seed?: number }) => {
    const nf = next.filter ?? filter;
    const nm = next.mode ?? mode;
    const ns = next.seed ?? seed;
    const grid = gridRef.current;
    const reduce = prefersReduced();
    const fx = fadeExit && !reduce;
    if (grid) {
      const gr = grid.getBoundingClientRect();
      const s = grid.offsetWidth ? gr.width / grid.offsetWidth : 1;
      const before = new Map<string, Box>();
      nodes.current.forEach((el, id) => {
        const r = el.getBoundingClientRect();
        before.set(id, { l: r.left, t: r.top, w: r.width, h: r.height });
      });
      const nextIds = new Set(order(items, nf, nm, ns).map((i) => i.id));
      const alreadyLeaving = new Set(leaving.map((l) => l.item.id));
      const fresh: Leaving[] = [];
      if (fx) {
        before.forEach((r, id) => {
          if (nextIds.has(id) || alreadyLeaving.has(id)) return;
          const item = items.find((i) => i.id === id);
          if (item) fresh.push({ item, left: (r.l - gr.left) / s, top: (r.t - gr.top) / s, w: r.w / s, h: r.h / s });
        });
      }
      pending.current = { before, hBefore: gr.height / s, exiting: fresh.map((l) => l.item.id), fx, reduce };
      setLeaving((prev) => (fx ? [...prev.filter((l) => !nextIds.has(l.item.id)), ...fresh] : []));
    } else {
      setLeaving([]);
    }
    if (next.filter !== undefined) setFilter(nf);
    if (next.mode !== undefined) setMode(nm);
    if (next.seed !== undefined) setSeed(ns);
  };

  // The FLIP: runs after the DOM has its new order, before paint.
  useIsoLayoutEffect(() => {
    const p = pending.current;
    const grid = gridRef.current;
    if (!p || !grid) return;
    pending.current = null;
    const gr0 = grid.getBoundingClientRect();
    const s = grid.offsetWidth ? gr0.width / grid.offsetWidth : 1;

    heightAnim.current?.cancel();
    moveAnims.current.forEach((a) => a.cancel());
    moveAnims.current.clear();
    visibleIds.forEach((id) => {
      exitAnims.current.get(id)?.cancel();
      exitAnims.current.delete(id);
    });

    const sp = spring(Math.max(40, stiffness), Math.max(4, damping));
    let enterIndex = 0;
    visible.forEach((item) => {
      const el = nodes.current.get(item.id);
      if (!el) return;
      const b = p.before.get(item.id);
      if (b) {
        if (p.reduce) return;
        const r = el.getBoundingClientRect();
        const dx = (b.l - r.left) / s;
        const dy = (b.t - r.top) / s;
        if (Math.abs(dx) + Math.abs(dy) < 0.5) return;
        const frames = sp.p.map((v, i) => ({
          offset: i / (sp.p.length - 1),
          transform: "translate(" + (dx * (1 - v)).toFixed(2) + "px," + (dy * (1 - v)).toFixed(2) + "px)",
        }));
        moveAnims.current.set(item.id, el.animate(frames, { duration: sp.ms, easing: "linear" }));
      } else if (p.reduce) {
        el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160, easing: "ease-out" });
      } else if (p.fx) {
        const delay = 60 + enterIndex++ * 28;
        el.animate(
          [
            { opacity: 0, transform: "scale(0.82)" },
            { opacity: 1, transform: "scale(1)" },
          ],
          { duration: 300, delay, easing: EASE_OUT, fill: "backwards" },
        );
      }
    });

    p.exiting.forEach((id) => {
      const el = nodes.current.get(id);
      if (!el) return;
      const a = el.animate(
        [
          { opacity: 1, transform: "scale(1)" },
          { opacity: 0, transform: "scale(0.82)" },
        ],
        { duration: 200, easing: "ease-out", fill: "forwards" },
      );
      exitAnims.current.set(id, a);
      a.onfinish = () => {
        exitAnims.current.delete(id);
        setLeaving((l) => l.filter((x) => x.item.id !== id));
      };
    });

    const hAfter = grid.getBoundingClientRect().height / s;
    if (!p.reduce && Math.abs(hAfter - p.hBefore) > 1) {
      heightAnim.current = grid.animate(
        [{ height: p.hBefore + "px" }, { height: hAfter + "px" }],
        { duration: 420, easing: EASE_OUT },
      );
    }
  });

  // Sliding chip indicator, measured in layout pixels.
  useEffect(() => {
    const tray = trayRef.current;
    const ind = indRef.current;
    if (!tray || !ind) return;
    const place = () => {
      const on = tray.querySelector<HTMLElement>('[data-chip][aria-pressed="true"]');
      if (!on) {
        ind.style.width = "0px";
        return;
      }
      ind.style.width = on.offsetWidth + "px";
      ind.style.transform = "translateX(" + on.offsetLeft + "px)";
    };
    place();
    const raf = requestAnimationFrame(() => ind.setAttribute("data-ready", ""));
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(place) : null;
    ro?.observe(tray);
    return () => {
      cancelAnimationFrame(raf);
      ro?.disconnect();
    };
  }, [filter, filters]);

  const lightUp = (e: ReactPointerEvent<HTMLUListElement>) => {
    const li = (e.target as Element).closest?.("[data-fgr-card]") as HTMLElement | null;
    if (!li || li.hasAttribute("data-leaving")) return;
    const r = li.getBoundingClientRect();
    const k = r.width ? li.offsetWidth / r.width : 1;
    li.style.setProperty("--mx", ((e.clientX - r.left) * k).toFixed(1) + "px");
    li.style.setProperty("--my", ((e.clientY - r.top) * k).toFixed(1) + "px");
    if (e.pointerType !== "mouse") {
      unlightOthers(e.currentTarget, li);
      li.setAttribute("data-lit", "");
    }
  };

  const countFor = (id: string) => (id === "all" ? items.length : items.filter((i) => i.group === id).length);
  const shown = visible.length;

  const cardStyle = (item: FlipGridItem, extra?: CSSProperties) =>
    ({ "--h": hueFor(item), ...extra }) as CSSProperties;

  const renderCard = (item: FlipGridItem, out?: Leaving) => (
    <li
      key={item.id}
      ref={(el) => {
        if (el) nodes.current.set(item.id, el);
        else nodes.current.delete(item.id);
      }}
      className="fgr-card"
      data-fgr-card=""
      data-leaving={out ? "" : undefined}
      aria-hidden={out ? true : undefined}
      style={
        out
          ? cardStyle(item, { position: "absolute", left: out.left, top: out.top, width: out.w, height: out.h, margin: 0 })
          : cardStyle(item)
      }
    >
      <div className="fgr-in">
        <div className="fgr-art" />
        <div className="fgr-sheen" />
        <span className="fgr-pill">{item.label}</span>
        {item.title || item.meta ? (
          <div className="fgr-text">
            {item.title ? <p className="fgr-title">{item.title}</p> : null}
            {item.meta ? <p className="fgr-sub">{item.meta}</p> : null}
          </div>
        ) : null}
        <div className="fgr-glow" />
        <div className="fgr-edge" />
      </div>
    </li>
  );

  return (
    <div
      className={"fgr" + (className ? " " + className : "")}
      data-tone={tone === "light" ? "light" : "dark"}
      style={{ "--c": cols, "--cs": Math.min(cols, 2) } as CSSProperties}
    >
      <style>{CSS}</style>

      <div className="fgr-bar">
        <div className="fgr-tray" ref={trayRef} role="group" aria-label="Filter">
          <div className="fgr-ind" ref={indRef} aria-hidden="true" />
          {filters.map((f) => (
            <button
              key={f.id}
              type="button"
              data-chip=""
              className="fgr-btn fgr-chip"
              aria-pressed={filter === f.id}
              onClick={() => filter !== f.id && commit({ filter: f.id })}
            >
              {f.label}
              <span className="fgr-count">{countFor(f.id)}</span>
            </button>
          ))}
        </div>

        {controls ? (
          <div className="fgr-tools" role="group" aria-label="Arrange">
            <button
              type="button"
              className="fgr-btn fgr-tool"
              onClick={() => commit({ mode: "shuffle", seed: seed + 1 })}
            >
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ transform: "rotate(" + seed * 180 + "deg)" }}>
                <path d="M16 3h5v5" />
                <path d="M4 20 21 3" />
                <path d="M21 16v5h-5" />
                <path d="m15 15 6 6" />
                <path d="m4 4 5 5" />
              </svg>
              Shuffle
            </button>
            <button
              type="button"
              className="fgr-btn fgr-tool"
              aria-pressed={mode === "az"}
              onClick={() => commit({ mode: mode === "az" ? "original" : "az" })}
            >
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="m3 16 4 4 4-4" />
                <path d="M7 20V4" />
                <path d="M15 5h5l-5 6h5" />
                <path d="M15 20v-3.5a2.5 2.5 0 0 1 5 0V20" />
                <path d="M15 17h5" />
              </svg>
              A–Z
            </button>
          </div>
        ) : null}
      </div>

      <ul
        ref={gridRef}
        className="fgr-grid"
        aria-label="Items"
        onPointerMove={lightUp}
        onPointerDown={lightUp}
      >
        {visible.map((item) => renderCard(item))}
        {leaving.filter((l) => !visibleIds.has(l.item.id)).map((l) => renderCard(l.item, l))}
      </ul>

      {shown === 0 ? <p className="fgr-empty">Nothing in this filter yet.</p> : null}
      <p className="fgr-sr" role="status" aria-live="polite">
        {"Showing " + shown + " of " + items.length + (mode === "az" ? ", sorted A to Z" : mode === "shuffle" ? ", shuffled" : "")}
      </p>
    </div>
  );
}

/** Touch has no hover: keep exactly one tile lit, the one last touched. */
function unlightOthers(grid: HTMLElement, keep: HTMLElement) {
  grid.querySelectorAll("[data-lit]").forEach((el) => {
    if (el !== keep) el.removeAttribute("data-lit");
  });
}

Free. Pro unlocks everything, from $27/month.

Details

About this animation

Each tile is a keyed list item. Before any state change the component measures every tile on screen, so a filter click that lands mid-flight still starts from where the tile visibly is. After React commits the new order it measures again, cancels the old animations and plays the difference as a transform (the F, L, I and P of FLIP) on the Web Animations API. The travel is a real spring, not a cubic curve: a damped spring (stiffness 320, damping 30 by default) is integrated once, sampled into a few dozen keyframes, cached, and replayed with linear easing, so the slight overshoot and settle survive and the main thread is free while it plays. Tiles that leave are lifted out of the grid flow at their measured box and faded and scaled to 0.82 for 200ms, which is what lets the survivors begin moving immediately instead of waiting; tiles that arrive scale in from 0.82 with a 28ms stagger. The grid’s own height is animated between the old and new values so the content below it never jumps. With controls on (off by default), Shuffle applies a seeded permutation (stable per seed and per id, so a shuffle survives filtering), and A–Z sorts by title or label. Tiles carry a pill, a title and a line of metadata over generated art derived from a hue (or from the group), and a pointer light follows the cursor across the tile and its one-pixel edge; on touch the last-touched tile stays lit. Columns apply from 460px of component width and collapse to two below it, using a container query, never the viewport. Props: items, filters, columns, stiffness, fadeExit, damping, controls, defaultFilter, tone (dark or light) and className.

Curator’s note

Rebuilt in October without a single dependency: the FLIP is hand-written on the Web Animations API with a sampled spring, so a filter change reads as tiles travelling, not teleporting.

Pairs well with

Squish Flip

animations
Pro