Skip to content
caliper ui docs

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.

Free
pnpm dlx shadcn@latest add @caliper/shuffle-grid

Selected work

Filter by kind of project.

  • Harbour app
  • Dune journal
  • Forest trails
  • Night market
  • Field notes
  • Ridge outfitters
  • Terrace hotel
  • Harbour wallet

8 projects

Installs components/ui/shuffle-grid.tsxDepends on none beyond ReactMotion honours reduced motion

When 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

terminal
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.

example.tsx
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.

PropTypeDefaultDescription
label *string—Names the collection, e.g. "Projects".
items *ShuffleItem[]—
filters *ShuffleFilter[]—The filters after "All"; an item shows when its `tags` include the chosen value.
gridClassNamestring—Classes for the grid, such as its columns.
noun[string, string]["item", "items"]Names a count, e.g. ["project", "projects"].

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.

All Compare & browse examples