## Filter Grid — Data Filtering rearranges, it does not blink. Docs: https://www.interior.dev/docs/filter-grid Reference: https://www.interior.dev/reference/filter-grid License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/filter-grid.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { FilterGrid, type FilterDefinition } from "@/components/interior/filter-grid"; type Asset = { id: string; name: string; kind: "image" | "clip" | "doc"; size: string }; const FILTERS: FilterDefinition[] = [ { id: "all", label: "All", match: () => true }, { id: "image", label: "Images", match: (a) => a.kind === "image" }, { id: "clip", label: "Clips", match: (a) => a.kind === "clip" }, { id: "doc", label: "Docs", match: (a) => a.kind === "doc" }, ]; export function AssetLibrary({ assets }: { assets: Asset[] }) { const [kind, setKind] = useState("all"); return ( a.id} columns={3} rowHeight={72} maxRows={4} renderItem={(a) => (

{a.name}

{a.size}

)} /> ); } ``` ### Props - `items` (`readonly T[]`) The full unfiltered set. Its length fixes the reserved height, so it must not depend on the active filter. - `filters` (`readonly FilterDefinition[]`) Each entry is { id, label, match }. Include an "all" entry whose match returns true; the first entry is the fallback when value is unknown. - `getKey` (`(item: T) => string`) Stable identity per item. A key that changes between filters turns a move into an unmount and remount. - `renderItem` (`(item: T) => ReactNode`) Cell contents only. The component owns the card chrome, the radius and the fixed row height. - `label` (`string`) Accessible name for the filter radiogroup. - `value` (`string | undefined`) Active filter id for controlled use. Omit to let the component hold the selection. - `defaultValue` (`string | undefined`) — default: `filters[0].id` Initial filter id when uncontrolled. - `onValueChange` (`((id: string) => void) | undefined`) Fires only when the active id actually changes, never on a re-click of the current filter. - `columns` (`number`) — default: `3` Fixed column count. A number, not a breakpoint, because the reserved height is derived from it during render. - `rowHeight` (`number`) — default: `72` Row height in px. Fixed so the grid's height is known before the first paint and identical on server and client. - `maxRows` (`number`) — default: `4` Height cap in rows. Beyond it the grid scrolls internally instead of growing. - `gap` (`number`) — default: `8` Gap in px between cells, counted into the reserved height. - `emptyLabel` (`string`) — default: `"Nothing matches this filter"` Shown centred in the reserved space when a filter matches nothing. - `className` (`string`) — default: `""` Appended last on the outer wrapper. ### Behavior notes - The grid reserves the height of the whole unfiltered set before the first paint, so narrowing forty cards to three never drags the content below it up the page; past maxRows the box scrolls rather than growing. - An item that survives a filter change is never unmounted and remounted — it keeps its element and travels to its new slot on transform, while the items that lost are pulled out of flow by popLayout, so the grid rearranges instead of blinking white between two states. - Counts are computed per filter over the full set, not the visible one, so a chip's width is the same in every reachable state and the chip row cannot reflow when the selection moves. - Filters clicked in fast succession interrupt: each card's spring resumes from where it currently sits, and no card replays a trip it had already half finished. - If focus was inside a card that the new filter removed, it lands on the grid container once the exit settles, never on the document body. - The count is announced once per settled filter change through a polite region rather than per item, and under prefers-reduced-motion the cards are simply in their new slots — the empty state and the counts still arrive. ### Source (`components/interior/filter-grid.tsx`) ```tsx "use client"; import { useCallback, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const MOVE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const EASE = [0.23, 1, 0.32, 1] as const; const LEAVE = { duration: 0.14, ease: [0.4, 0, 1, 1] } as const; const INSTANT = { duration: 0 } as const; export type FilterDefinition = { id: string; label: string; match: (item: T) => boolean; }; export type UseFilterGridOptions = { items: readonly T[]; filters: readonly FilterDefinition[]; value?: string; defaultValue?: string; onValueChange?: (id: string) => void; }; export type UseFilterGridResult = { active: string; activeLabel: string; select: (id: string) => void; visible: T[]; counts: Record; total: number; }; export function useFilterGrid({ items, filters, value, defaultValue, onValueChange, }: UseFilterGridOptions): UseFilterGridResult { const fallback = filters[0]?.id ?? ""; const [internal, setInternal] = useState(() => defaultValue ?? fallback); const requested = value ?? internal; const current = filters.find((f) => f.id === requested) ?? filters[0]; const active = current?.id ?? fallback; const emit = useRef(onValueChange); emit.current = onValueChange; const counts = useMemo(() => { const next: Record = {}; for (const filter of filters) { let n = 0; for (const item of items) if (filter.match(item)) n += 1; next[filter.id] = n; } return next; }, [filters, items]); const visible = useMemo(() => { const filter = filters.find((f) => f.id === active); if (!filter) return [...items]; return items.filter((item) => filter.match(item)); }, [filters, items, active]); const select = useCallback( (id: string) => { if (value === undefined) setInternal(id); if (id !== active) emit.current?.(id); }, [value, active], ); return { active, activeLabel: current?.label ?? "", select, visible, counts, total: items.length, }; } export type FilterGridProps = { items: readonly T[]; filters: readonly FilterDefinition[]; getKey: (item: T) => string; renderItem: (item: T) => ReactNode; label: string; value?: string; defaultValue?: string; onValueChange?: (id: string) => void; columns?: number; rowHeight?: number; maxRows?: number; gap?: number; emptyLabel?: string; className?: string; }; export function FilterGrid({ items, filters, getKey, renderItem, label, value, defaultValue, onValueChange, columns = 3, rowHeight = 72, maxRows = 4, gap = 8, emptyLabel = "Nothing matches this filter", className = "", }: FilterGridProps) { const uid = useId(); const gridId = `${uid}-grid`; const reduced = useReducedMotion(); const { active, activeLabel, select, visible, counts, total } = useFilterGrid({ items, filters, value, defaultValue, onValueChange, }); const gridRef = useRef(null); const chips = useRef<(HTMLButtonElement | null)[]>([]); const heldFocus = useRef(false); const cols = Math.max(1, Math.floor(columns)); const rows = Math.min(Math.max(1, Math.ceil(total / cols)), Math.max(1, maxRows)); const box = rows * rowHeight + (rows - 1) * gap; const index = Math.max( 0, filters.findIndex((f) => f.id === active), ); const choose = useCallback( (id: string) => { const grid = gridRef.current; heldFocus.current = !!grid && grid.contains(document.activeElement) && grid !== document.activeElement; select(id); }, [select], ); const settle = useCallback(() => { if (!heldFocus.current) return; heldFocus.current = false; const grid = gridRef.current; if (grid && !grid.contains(document.activeElement)) grid.focus(); }, []); const go = useCallback( (i: number) => { const next = filters[(i + filters.length) % filters.length]; if (!next) return; chips.current[(i + filters.length) % filters.length]?.focus(); choose(next.id); }, [filters, choose], ); const onKeyDown = (e: KeyboardEvent, i: number) => { if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); go(i + 1); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); go(i - 1); } else if (e.key === "Home") { e.preventDefault(); go(0); } else if (e.key === "End") { e.preventDefault(); go(filters.length - 1); } }; const swap = reduced ? INSTANT : CELL; const step = reduced ? INSTANT : { layout: MOVE, duration: 0.2, ease: EASE }; const leave = reduced ? INSTANT : LEAVE; const capped = Math.ceil(total / cols) > Math.max(1, maxRows); return (
{filters.map((filter, i) => { const on = i === index; return ( ); })}
    {visible.map((item) => ( {renderItem(item)} ))}
{visible.length === 0 && ( {emptyLabel} )}

{activeLabel}: {visible.length} of {total} shown

); } ```