{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-wall-reflow",
  "title": "Testimonial Wall Reflow",
  "description": "A masonry testimonial wall that physically reflows as you read: expanding one card's quote via 'read more' re-packs every other card into its new shortest-column slot, animated with a FLIP transform so only the cards that actually move ever animate.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/testimonial-wall-reflow/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// TestimonialWallReflow — a testimonial wall where reading one card visibly\n// moves the others. Cards are packed into the shortest-column-first masonry\n// order every layout pass (own math, not CSS `columns`, because CSS columns\n// flow top-to-bottom per column and can't be re-packed on demand). Expanding\n// a card's quote via \"read more\" changes that card's own height; every OTHER\n// card then reflows to its new packed slot via a FLIP transform (position is\n// pure arithmetic from the packing pass, so the \"before\" position for the\n// FLIP delta is read from a ref of the LAST computed layout, never from a\n// live getBoundingClientRect — the packing math is deterministic, so there\n// is nothing to race). Only actual position deltas ever animate; a card\n// whose slot doesn't move is untouched. No dependency: refs + inline\n// transforms, matching the direct-DOM-write convention this registry uses\n// for hot-path visual state.\n// ---------------------------------------------------------------------------\n\nexport interface Testimonial {\n  id: string;\n  name: string;\n  role: string;\n  quote: string;\n}\n\nconst GAP = 16;\nconst DURATION = 420;\nconst EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\nconst EXCERPT_LEN = 130;\n\nconst DEFAULT_ITEMS: Testimonial[] = [\n  {\n    id: \"t1\",\n    name: \"Priya Nair\",\n    role: \"VP Engineering, Fathom\",\n    quote:\n      \"We moved our whole onboarding flow over in a week. The thing that actually convinced the team was how little custom CSS we had to write to make it feel like ours.\",\n  },\n  {\n    id: \"t2\",\n    name: \"Marcus Ohl\",\n    role: \"Founder, Driftwood\",\n    quote: \"Shipped a working demo before lunch. That never happens.\",\n  },\n  {\n    id: \"t3\",\n    name: \"Elena Voss\",\n    role: \"Design Lead, Northbound\",\n    quote:\n      \"Every component reads its own theme tokens, so our dark mode just worked the day we flipped the switch. No follow-up ticket, which is rare enough that I remember it.\",\n  },\n  {\n    id: \"t4\",\n    name: \"Tomasz Krol\",\n    role: \"Staff Engineer, Ledgerly\",\n    quote:\n      \"The accessibility audit we were dreading came back clean. Focus states, live regions, reduced motion — all handled before we asked.\",\n  },\n  {\n    id: \"t5\",\n    name: \"Aiyana Redcloud\",\n    role: \"Product, Kestrel\",\n    quote: \"Fast, honest docs, and the demos actually match the shipped behavior.\",\n  },\n  {\n    id: \"t6\",\n    name: \"Jonah Petrov\",\n    role: \"CTO, Millrace\",\n    quote:\n      \"Our team ships marketing pages faster now than we ship internal tools, which is a strange sentence to say out loud but here we are.\",\n  },\n];\n\nfunction columnsFor(width: number): number {\n  if (width < 560) return 1;\n  if (width < 900) return 2;\n  return 3;\n}\n\ntype Pos = { x: number; y: number };\n\nfunction pack(\n  order: string[],\n  heightOf: (id: string) => number,\n  cols: number,\n  colW: number\n): { pos: Record<string, Pos>; maxH: number } {\n  const colH = new Array(cols).fill(0);\n  const pos: Record<string, Pos> = {};\n  for (const id of order) {\n    let target = 0;\n    for (let c = 1; c < cols; c++) if (colH[c] < colH[target]) target = c;\n    pos[id] = { x: target * (colW + GAP), y: colH[target] };\n    colH[target] += heightOf(id) + GAP;\n  }\n  const maxH = Math.max(0, ...colH.map((h) => Math.max(0, h - GAP)));\n  return { pos, maxH };\n}\n\nexport function TestimonialWallReflow({\n  items = DEFAULT_ITEMS,\n  className = \"\",\n}: {\n  /** the testimonials */\n  items?: Testimonial[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<Record<string, HTMLElement | null>>({});\n  const prevPosRef = useRef<Record<string, Pos>>({});\n  const reducedRef = useRef(false);\n  const [expanded, setExpanded] = useState<Record<string, boolean>>({});\n  const [containerHeight, setContainerHeight] = useState(0);\n  const [colW, setColW] = useState(0);\n\n  const ids = useMemo(() => items.map((t) => t.id), [items]);\n\n  const layout = useCallback(\n    (animate: boolean) => {\n      const container = containerRef.current;\n      if (!container) return;\n      const width = container.getBoundingClientRect().width;\n      if (width < 4) return;\n      const cols = columnsFor(width);\n      const w = (width - GAP * (cols - 1)) / cols;\n      setColW(w);\n\n      const heightOf = (id: string) => cardRefs.current[id]?.offsetHeight ?? 0;\n      const { pos, maxH } = pack(ids, heightOf, cols, w);\n      const prev = prevPosRef.current;\n\n      for (const id of ids) {\n        const el = cardRefs.current[id];\n        if (!el) continue;\n        const after = pos[id]!;\n        const before = prev[id];\n        el.style.left = `${after.x}px`;\n        el.style.top = `${after.y}px`;\n        if (\n          animate &&\n          !reducedRef.current &&\n          before &&\n          (Math.round(before.x) !== Math.round(after.x) ||\n            Math.round(before.y) !== Math.round(after.y))\n        ) {\n          const dx = before.x - after.x;\n          const dy = before.y - after.y;\n          el.style.transition = \"none\";\n          el.style.transform = `translate(${dx}px, ${dy}px)`;\n          // force layout so the \"before\" transform actually paints before\n          // the transition below is armed\n          void el.getBoundingClientRect();\n          el.style.transition = `transform ${DURATION}ms ${EASE}`;\n          el.style.transform = \"translate(0, 0)\";\n        } else {\n          el.style.transition = \"\";\n          el.style.transform = \"\";\n        }\n      }\n\n      prevPosRef.current = pos;\n      setContainerHeight(maxH);\n    },\n    [ids]\n  );\n\n  useLayoutEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    layout(false);\n    const container = containerRef.current;\n    if (!container) return;\n    const ro = new ResizeObserver(() => layout(false));\n    ro.observe(container);\n    return () => ro.disconnect();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const mountedRef = useRef(false);\n  useLayoutEffect(() => {\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      return;\n    }\n    // re-pack on every expand/collapse toggle, animated: this is the reflow\n    layout(true);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [expanded]);\n\n  const toggle = (id: string) => {\n    setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={`relative w-full ${className}`}\n      style={{ height: containerHeight || undefined }}\n    >\n      {items.map((t) => {\n        const isLong = t.quote.length > EXCERPT_LEN + 20;\n        const isExpanded = !!expanded[t.id];\n        const excerpt = isLong && !isExpanded ? `${t.quote.slice(0, EXCERPT_LEN).trimEnd()}…` : t.quote;\n        return (\n          <figure\n            key={t.id}\n            ref={(el) => {\n              cardRefs.current[t.id] = el;\n            }}\n            data-expanded={isExpanded}\n            className=\"absolute rounded-md border border-border bg-surface p-5 will-change-transform\"\n            style={{ width: colW || undefined }}\n          >\n            <blockquote className=\"m-0 text-sm leading-relaxed text-foreground\">{excerpt}</blockquote>\n            {isLong && (\n              <button\n                type=\"button\"\n                data-expand-toggle\n                aria-expanded={isExpanded}\n                aria-label={`${isExpanded ? \"Read less\" : \"Read more\"} of ${t.name}'s testimonial`}\n                onClick={() => toggle(t.id)}\n                className=\"mt-2 rounded-sm font-mono text-xs text-ns-muted underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n              >\n                {isExpanded ? \"read less\" : \"read more\"}\n              </button>\n            )}\n            <figcaption className=\"mt-4 flex items-center gap-2.5 border-t border-border pt-3\">\n              <span\n                aria-hidden\n                className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-background font-mono text-[10px] text-ns-muted\"\n              >\n                {t.name\n                  .split(/\\s+/)\n                  .map((w) => w[0] ?? \"\")\n                  .join(\"\")\n                  .slice(0, 2)\n                  .toUpperCase()}\n              </span>\n              <span className=\"min-w-0\">\n                <span className=\"block truncate text-xs font-medium text-foreground\">{t.name}</span>\n                <span className=\"block truncate text-xs text-ns-muted\">{t.role}</span>\n              </span>\n            </figcaption>\n          </figure>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/testimonial-wall-reflow.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "testimonial",
      "wall",
      "masonry",
      "layout",
      "flip",
      "reflow"
    ],
    "instruction": "Testimonials are packed into a shortest-column-first masonry (own arithmetic, not CSS `columns`, which flows top-to-bottom per column and can't be re-packed on demand): column count is derived from measured container width (1 below 560px, 2 below 900px, else 3), and for each item in order the next slot goes to whichever column currently has the least accumulated height. Each card is an absolutely positioned <figure> so its own height never affects siblings through normal document flow — position is entirely computed by the packing pass and written as inline left/top. Every card longer than ~150 characters gets a 'read more' button (aria-expanded, an accessible name naming both the action and the author); toggling it swaps the rendered excerpt for the full quote, which changes that one card's own height. THE REFLOW: a `useLayoutEffect` keyed on the expand/collapse state re-runs the packing pass with the new heights (read via `offsetHeight` after the content swap has already committed, so this always reads real post-toggle heights) and, for every card whose computed slot actually changed from the last pass, applies a FLIP transform: `before` is not read from a live `getBoundingClientRect` — it's the position this same packing function produced on the PREVIOUS pass, cached in a ref — the delta is applied as an instant untransitioned `translate`, then forced-reflowed and eased back to identity over 420ms (cubic-bezier(0.22,1,0.36,1)). Cards whose slot didn't move are left completely untouched, so a wall with one changed card only animates the ones that needed to. A `ResizeObserver` re-packs (unanimated — a viewport resize isn't 'reading') whenever the container width crosses a column-count breakpoint. `prefers-reduced-motion` still repacks and repositions correctly but skips the transform/transition entirely, snapping straight to the new layout. Each card exposes `data-expanded` for anyone probing its open state. Props: `items` (id/name/role/quote array, six built-in examples if omitted), className. Zero dependencies — refs, inline styles and CSS transitions only, matching this registry's direct-DOM-write convention for hot-path visual state."
  },
  "type": "registry:ui"
}