Components
Shuffle grid
A grid you filter with a row of chips. Items that stay glide to their new places, items that leave shrink away from where they stood and items that arrive grow in, so a filter reads as a rearrangement rather than a swap.
pnpm dlx shadcn@latest add @caliper/shuffle-gridWhen to use
Use it for
- Portfolios, template galleries, integration directories and team pages with a few categories.
- Any small collection where seeing what moved matters more than speed.
Skip it when
- Large or paginated collections; filter on the server and use a table or `InfiniteMasonry`.
- Several filters at once; use `FilterToolbar`.
Decisions
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- Measured, not guessed
- Each item is measured after every change and played from where it was drawn, so any grid, gap or wrap works.
- Leaving items keep their place
- An item on its way out holds its old box for its exit, so the others never jump into a space it still fills.
Usage
- Toggle chips and a count
- Chips are toggle buttons in a named group and the number shown is announced after each change.
Installation
pnpm dlx shadcn@latest add @caliper/shuffle-grid
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage
This is the code behind the preview above.
import { type ReactNode } from "react" import { ShuffleGrid, type ShuffleItem } from "@/components/ui/shuffle-grid" function Panel({ title, context, className = "max-w-md", children }: { title: string; context: string; className?: string; children: ReactNode }) { return ( <section className={`grid w-full gap-5 rounded-overlay border border-border bg-background p-6 shadow-resting max-sm:p-4 ${className}`}> <div className="grid gap-1"> <p className="font-medium">{title}</p> <p className="text-sm text-muted-foreground">{context}</p> </div> {children} </section> ); } const work: ShuffleItem[] = [ ["Harbour app", "mobile", "harbour"], ["Dune journal", "editorial", "dunes"], ["Forest trails", "mobile", "forest"], ["Night market", "brand", "night"], ["Field notes", "editorial", "field"], ["Ridge outfitters", "brand", "ridge"], ["Terrace hotel", "brand", "terrace"], ["Harbour wallet", "mobile", "harbour"], ].map(([label, tag, photo], i) => ({ id: `w${i}`, label, tags: [tag], content: ( <figure className="grid gap-1.5"> <img src={`/media/${photo}.jpg`} alt="" className="aspect-[4/3] w-full rounded-[8px] object-cover" /> <figcaption className="truncate text-sm">{label}</figcaption> </figure> ), })); export function ShuffleGridDemo() { return ( <Panel title="Selected work" context="Filter by kind of project." className="max-w-2xl"> <ShuffleGrid label="Projects" items={work} filters={[ { value: "mobile", label: "Mobile" }, { value: "brand", label: "Brand" }, { value: "editorial", label: "Editorial" }, ]} noun={["project", "projects"]} gridClassName="grid-cols-2 sm:grid-cols-4" /> </Panel> ); }
API reference
ShuffleGrid
component
Also accepts the native props of its root element and a ref.
Accessibility
It exposes group semantics, with aria-label, aria-pressed, aria-hidden and aria-live kept in step with state. Changes that matter are announced through a live region.
No custom key handling; Tab and the native activation keys do the work.
Motion
It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.
Related
- Filter toolbarA search field and filter menus with result counts, for narrowing a list.
- Animated listA list whose rows come and go gracefully: new rows open into place, removed rows close their gap and the rest glide to their new positions.
- Chip groupA set of toggleable chips for single or multiple choices.






