{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"shuffle-grid","type":"registry:ui","title":"Shuffle grid","description":"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.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/shuffle-grid.tsx","type":"registry:ui","target":"components/ui/shuffle-grid.tsx","content":"\"use client\";\nimport { useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./shuffle-grid.css\";\n\nexport type ShuffleItem = { id: string; label: string; tags: string[]; content?: ReactNode };\nexport type ShuffleFilter = { value: string; label: string };\n\nexport type ShuffleGridProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Names the collection, e.g. \"Projects\". */\n  label: string;\n  items: ShuffleItem[];\n  /** The filters after \"All\"; an item shows when its `tags` include the chosen value. */\n  filters: ShuffleFilter[];\n  /** Classes for the grid, such as its columns. */\n  gridClassName?: string;\n  /** Names a count, e.g. [\"project\", \"projects\"]. */\n  noun?: [string, string];\n};\n\ntype Box = { left: number; top: number; width: number; height: number };\n\n/**\n * A grid you filter with a row of chips. Items that stay glide to their new places, items that leave shrink away\n * from where they stood, and items that arrive grow in, so a filter reads as a rearrangement rather than a swap.\n * The chips are toggle buttons and the count is announced. Under reduced motion the grid changes at once.\n */\nexport function ShuffleGrid({ label, items, filters, gridClassName, noun = [\"item\", \"items\"], className, ...props }: ShuffleGridProps) {\n  const [filter, setFilter] = useState(\"all\");\n  const grid = useRef<HTMLUListElement>(null);\n  const boxes = useRef(new Map<string, Box>());\n  const height = useRef(0);\n  const shown = new Set(items.filter((item) => filter === \"all\" || item.tags.includes(filter)).map((item) => item.id));\n\n  // After each change, every item is played from where it was drawn to where it now is.\n  useLayoutEffect(() => {\n    const el = grid.current;\n    if (!el) return;\n    const still = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const origin = el.getBoundingClientRect();\n    const cells = [...el.querySelectorAll<HTMLElement>(\"[data-slot='shg-cell']\")];\n    const next = new Map<string, Box>();\n    for (const cell of cells) {\n      if (cell.hasAttribute(\"data-out\")) continue;\n      cell.getAnimations().forEach((animation) => animation.cancel());\n      cell.style.cssText = \"\";\n      const box = cell.getBoundingClientRect();\n      next.set(cell.dataset.id!, { left: box.left - origin.left, top: box.top - origin.top, width: box.width, height: box.height });\n    }\n    for (const cell of cells) {\n      const id = cell.dataset.id!;\n      const was = boxes.current.get(id);\n      const now = next.get(id);\n      if (still || !boxes.current.size) continue;\n      if (now && was) {\n        const dx = was.left - now.left,\n          dy = was.top - now.top;\n        if (dx || dy) cell.animate([{ translate: `${dx}px ${dy}px` }, { translate: \"0 0\" }], { duration: 460, easing: \"cubic-bezier(.3,.9,.25,1)\" });\n      } else if (now) {\n        cell.animate([{ opacity: 0, scale: 0.86 }, { opacity: 1, scale: 1 }], { duration: 360, delay: 120, easing: \"cubic-bezier(.2,.8,.3,1)\", fill: \"backwards\" });\n      } else if (was) {\n        // A leaving item keeps its old box for its exit, then the stylesheet hides it.\n        Object.assign(cell.style, { display: \"block\", position: \"absolute\", left: `${was.left}px`, top: `${was.top}px`, width: `${was.width}px`, height: `${was.height}px` });\n        cell.animate([{ opacity: 1, scale: 1 }, { opacity: 0, scale: 0.86 }], { duration: 240, easing: \"ease-in\", fill: \"forwards\" }).finished.then(() => (cell.style.cssText = \"\"), () => undefined);\n      }\n    }\n    // The grid eases to its new height, so items still leaving are not left hanging below it.\n    const now = el.offsetHeight;\n    if (!still && height.current && height.current !== now) el.animate([{ height: `${height.current}px` }, { height: `${now}px` }], { duration: 460, easing: \"cubic-bezier(.3,.9,.25,1)\" });\n    height.current = now;\n    boxes.current = next;\n  }, [filter]);\n\n  // A resize moves items without a filter change, so their last boxes are measured again, without motion.\n  useLayoutEffect(() => {\n    const el = grid.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      if (el.getAnimations().length) return; // mid-change; the next change measures again\n      height.current = el.offsetHeight;\n      const origin = el.getBoundingClientRect();\n      for (const cell of el.querySelectorAll<HTMLElement>(\"[data-slot='shg-cell']:not([data-out])\")) {\n        const box = cell.getBoundingClientRect();\n        boxes.current.set(cell.dataset.id!, { left: box.left - origin.left, top: box.top - origin.top, width: box.width, height: box.height });\n      }\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const count = shown.size;\n  return (\n    <div data-slot=\"shuffle-grid\" className={cn(\"grid gap-4\", className)} {...props}>\n      <div role=\"group\" aria-label={`Filter ${label.toLowerCase()}`} data-slot=\"shg-filters\" className=\"flex flex-wrap gap-1.5\">\n        {[{ value: \"all\", label: \"All\" }, ...filters].map((option) => (\n          <button key={option.value} type=\"button\" data-slot=\"shg-chip\" aria-pressed={filter === option.value} onClick={() => setFilter(option.value)}>\n            {option.label}\n          </button>\n        ))}\n      </div>\n      <ul ref={grid} aria-label={label} data-slot=\"shg-grid\" className={cn(\"relative grid\", gridClassName)}>\n        {items.map((item) => (\n          <li key={item.id} data-slot=\"shg-cell\" data-id={item.id} data-out={shown.has(item.id) ? undefined : \"\"} aria-hidden={shown.has(item.id) ? undefined : true}>\n            {item.content ?? item.label}\n          </li>\n        ))}\n      </ul>\n      <p aria-live=\"polite\" className=\"text-sm text-muted-foreground\">\n        {count} {count === 1 ? noun[0] : noun[1]}\n      </p>\n    </div>\n  );\n}\n"},{"path":"ui/shuffle-grid.css","type":"registry:file","target":"components/ui/shuffle-grid.css","content":"/* shuffle-grid.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Shuffle grid: chips filter; cells are moved by script, and a leaving cell is hidden once its exit ends. */\n  [data-slot=\"shuffle-grid\"] [data-slot=\"shg-chip\"] { height: 30px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--background); color: var(--muted-foreground); font-size: 13px; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out); }\n  @media (hover: hover) { [data-slot=\"shuffle-grid\"] [data-slot=\"shg-chip\"]:hover { color: var(--foreground); } }\n  [data-slot=\"shuffle-grid\"] [data-slot=\"shg-chip\"][aria-pressed=\"true\"] { border-color: var(--foreground); background: var(--foreground); color: var(--background); }\n  [data-slot=\"shuffle-grid\"] [data-slot=\"shg-chip\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }\n  [data-slot=\"shuffle-grid\"] [data-slot=\"shg-grid\"] { gap: 10px; margin: 0; padding: 0; overflow: clip; overflow-clip-margin: 8px; list-style: none; }\n  [data-slot=\"shuffle-grid\"] [data-slot=\"shg-cell\"] { min-width: 0; }\n  [data-slot=\"shuffle-grid\"] [data-slot=\"shg-cell\"][data-out] { display: none; }\n  @media (pointer: coarse) { [data-slot=\"shuffle-grid\"] [data-slot=\"shg-chip\"] { height: 36px; } }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"browse","docs":"/docs/components/shuffle-grid"}}