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.
npx shadcn add https://design.helpmarq.com /r/testimonial-wall-reflow.jsonregistry/core/testimonial-wall-reflow/component.tsx"use client";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// TestimonialWallReflow — a testimonial wall where reading one card visibly
// moves the others. Cards are packed into the shortest-column-first masonry
// order every layout pass (own math, not CSS `columns`, because CSS columns
// flow top-to-bottom per column and can't be re-packed on demand). Expanding
// a card's quote via "read more" changes that card's own height; every OTHER
// card then reflows to its new packed slot via a FLIP transform (position is
// pure arithmetic from the packing pass, so the "before" position for the
// FLIP delta is read from a ref of the LAST computed layout, never from a
// live getBoundingClientRect — the packing math is deterministic, so there
// is nothing to race). Only actual position deltas ever animate; a card
// whose slot doesn't move is untouched. No dependency: refs + inline
// transforms, matching the direct-DOM-write convention this registry uses
// for hot-path visual state.
// ---------------------------------------------------------------------------
export interface Testimonial {
id: string;
name: string;
role: string;
quote: string;
}
const GAP = 16;
const DURATION = 420;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const EXCERPT_LEN = 130;
const DEFAULT_ITEMS: Testimonial[] = [
{
id: "t1",
name: "Priya Nair",
role: "VP Engineering, Fathom",
quote:
"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.",
},
{
id: "t2",
name: "Marcus Ohl",
role: "Founder, Driftwood",
quote: "Shipped a working demo before lunch. That never happens.",
},
{
id: "t3",
name: "Elena Voss",
role: "Design Lead, Northbound",
quote:
"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.",
},
{
id: "t4",
name: "Tomasz Krol",
role: "Staff Engineer, Ledgerly",
quote:
"The accessibility audit we were dreading came back clean. Focus states, live regions, reduced motion — all handled before we asked.",
},
{
id: "t5",
name: "Aiyana Redcloud",
role: "Product, Kestrel",
quote: "Fast, honest docs, and the demos actually match the shipped behavior.",
},
{
id: "t6",
name: "Jonah Petrov",
role: "CTO, Millrace",
quote:
"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.",
},
];
function columnsFor(width: number): number {
if (width < 560) return 1;
if (width < 900) return 2;
return 3;
}
type Pos = { x: number; y: number };
function pack(
order: string[],
heightOf: (id: string) => number,
cols: number,
colW: number
): { pos: Record<string, Pos>; maxH: number } {
const colH = new Array(cols).fill(0);
const pos: Record<string, Pos> = {};
for (const id of order) {
let target = 0;
for (let c = 1; c < cols; c++) if (colH[c] < colH[target]) target = c;
pos[id] = { x: target * (colW + GAP), y: colH[target] };
colH[target] += heightOf(id) + GAP;
}
const maxH = Math.max(0, ...colH.map((h) => Math.max(0, h - GAP)));
return { pos, maxH };
}
export function TestimonialWallReflow({
items = DEFAULT_ITEMS,
className = "",
}: {
items?: Testimonial[];
className?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<Record<string, HTMLElement | null>>({});
const prevPosRef = useRef<Record<string, Pos>>({});
const reducedRef = useRef(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [containerHeight, setContainerHeight] = useState(0);
const [colW, setColW] = useState(0);
const ids = useMemo(() => items.map((t) => t.id), [items]);
const layout = useCallback(
(animate: boolean) => {
const container = containerRef.current;
if (!container) return;
const width = container.getBoundingClientRect().width;
if (width < 4) return;
const cols = columnsFor(width);
const w = (width - GAP * (cols - 1)) / cols;
setColW(w);
const heightOf = (id: string) => cardRefs.current[id]?.offsetHeight ?? 0;
const { pos, maxH } = pack(ids, heightOf, cols, w);
const prev = prevPosRef.current;
for (const id of ids) {
const el = cardRefs.current[id];
if (!el) continue;
const after = pos[id]!;
const before = prev[id];
el.style.left = `${after.x}px`;
el.style.top = `${after.y}px`;
if (
animate &&
!reducedRef.current &&
before &&
(Math.round(before.x) !== Math.round(after.x) ||
Math.round(before.y) !== Math.round(after.y))
) {
const dx = before.x - after.x;
const dy = before.y - after.y;
el.style.transition = "none";
el.style.transform = `translate(${dx}px, ${dy}px)`;
// force layout so the "before" transform actually paints before
// the transition below is armed
void el.getBoundingClientRect();
el.style.transition = `transform ${DURATION}ms ${EASE}`;
el.style.transform = "translate(0, 0)";
} else {
el.style.transition = "";
el.style.transform = "";
}
}
prevPosRef.current = pos;
setContainerHeight(maxH);
},
[ids]
);
useLayoutEffect(() => {
reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
layout(false);
const container = containerRef.current;
if (!container) return;
const ro = new ResizeObserver(() => layout(false));
ro.observe(container);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const mountedRef = useRef(false);
useLayoutEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
// re-pack on every expand/collapse toggle, animated: this is the reflow
layout(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded]);
const toggle = (id: string) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<div
ref={containerRef}
className={`relative w-full ${className}`}
style={{ height: containerHeight || undefined }}
>
{items.map((t) => {
const isLong = t.quote.length > EXCERPT_LEN + 20;
const isExpanded = !!expanded[t.id];
const excerpt = isLong && !isExpanded ? `${t.quote.slice(0, EXCERPT_LEN).trimEnd()}…` : t.quote;
return (
<figure
key={t.id}
ref={(el) => {
cardRefs.current[t.id] = el;
}}
data-expanded={isExpanded}
className="absolute rounded-md border border-border bg-surface p-5 will-change-transform"
style={{ width: colW || undefined }}
>
<blockquote className="m-0 text-sm leading-relaxed text-foreground">{excerpt}</blockquote>
{isLong && (
<button
type="button"
data-expand-toggle
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Read less" : "Read more"} of ${t.name}'s testimonial`}
onClick={() => toggle(t.id)}
className="mt-2 rounded-sm font-mono text-xs text-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-accent"
>
{isExpanded ? "read less" : "read more"}
</button>
)}
<figcaption className="mt-4 flex items-center gap-2.5 border-t border-border pt-3">
<span
aria-hidden
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-background font-mono text-[10px] text-muted"
>
{t.name
.split(/\s+/)
.map((w) => w[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase()}
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-medium text-foreground">{t.name}</span>
<span className="block truncate text-xs text-muted">{t.role}</span>
</span>
</figcaption>
</figure>
);
})}
</div>
);
}
a testimonial/quote wall where expanding a card visibly reflows the rest of the layout around it; pick a static quote grid instead if every quote is a fixed, equal height and nothing ever needs to reveal more text.
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.