Case Study Grid
A four-state customer-story grid where the quantified result is the first-class field: every metric carries value, unit, direction and a required baseline (or says it has none), industry and company-size facets really filter with truthful counts, and logos of any aspect ratio are optically area-matched.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/case-study-grid.jsonPrompt
Build a React + TypeScript + Tailwind "CaseStudyGrid" block (zod, lucide-react).
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; heading?; subheading?;
items: {
id; company; industry; // industry is free text
size: "startup" | "mid-market" | "enterprise";
logo?: { src; darkSrc?; width; height }; // intrinsic size = ratio only
outcome; // the one-line result, card title
metrics: Metric[]; // .min(1) — see below
quote?: { text; attribution?: { name; title } };
href; // refined: rejects "" and "#"
}[] }
- Metric = { id; label; value: nonnegative; unit?; prefix?;
direction: "up" | "down" | "flat";
baseline: { kind: "comparison"; label } | { kind: "none" } }
- THE BASELINE IS REQUIRED, and that is the point of this block. "+38%" with
nothing to compare against is a meaningless marketing number. When there is
genuinely no prior measurement, the only legal value is { kind: "none" } and
the component itself renders "No baseline captured". Deliberately give that
branch NO free-text field: the moment it accepts a string, someone fills it
with a sentence that sounds like a baseline.
- `value` is a MAGNITUDE (nonnegative); the sign is rendered from `direction`,
so the data cannot contradict itself with "−-62%".
- `unit` is printed verbatim right after the number, so the data decides
whether it hugs: "$1.4M" hugs, "18 pts" needs a leading NBSP. There is no
rule that gets both right automatically — both units are alphabetic.
- metrics is .min(1): a customer story with no number belongs in a
testimonial wall, not here.
- Quote attribution is all-or-nothing ({ name, title } together). Half an
attribution is as weak as none but looks real. The company is NOT repeated
in the quote — it is read off the card's own `company`.
- Props = z.infer of the schema plus onRetry?, locale? (default "en-US"),
className. The selected facets are internal state; the consumer ships data.
Behavior
- Four first-class branches: loading (skeletons mirroring the facet bar and
the card anatomy, with a deliberately uneven 1/3/2 metric strip, aria-hidden
plus one sr-only role=status), empty, error (+ "Try again" only when onRetry
is passed), ready. `ready` with an empty array renders the empty panel, not
a facet bar full of zeroes.
- Two facets, both DERIVED from the data, so a chip that yields nothing cannot
exist: industry in first-appearance order, company size in the closed enum's
small→large order (never the accidental data order).
- Every chip count is computed under the OTHER facet's current selection, so
the number on a chip is exactly what you get after clicking it. "Showing n
of N case studies" lives in a role=status region and is recomputed from the
same array the grid maps over.
- If `items` changes and the selected facet value disappears, selection falls
back to All instead of stranding the user on an unselectable empty set.
- TWO DISTINCT EMPTY STATES: "No case studies yet" (nothing published) vs
"No case studies match these filters" (dashed border, names the active
facets, offers Clear filters). Collapsing them is a defect — the reader
cannot tell "come back later" from "you over-filtered".
- Metric strip: a `dt` (label) / `dd` (value + baseline) pair per metric in a
`<dl>`, laid out with grid-cols-[repeat(auto-fill,minmax(min(7rem,100%),1fr))].
auto-fill (not auto-fit) keeps a one-metric card's tile the same width as
the first tile of a three-metric card, so numbers line up across the row.
- Uneven metric counts must not damage the row. The card is
`flex h-full flex-col justify-between`, NOT a footer with mt-auto: an auto
margin swallows all the free space into one hole above the link, while
justify-between spreads it across every gap. Largest internal gap, same
script over two bundles, mt-auto → justify-between: 116px → 49px on real
data at 1440px/3 columns; 126px → 44px at the worst of 375→1600 in 25px
steps; 305px → 112px in the pathological case of a nearly empty card beside
a full one. Single-column widths never stretch, so both read 16px. Card
heights, footer baselines and clipping are identical either way — this is
purely about where the slack lands.
- `flat` means "an absolute level" (NPS 72), not a movement: no sign and NO
glyph. A dash icon next to the number reads as a minus sign — measured on a
real screenshot, "72" became "−72". Keep a same-size invisible spacer so the
numbers still share a left edge.
- Logos arrive at wildly different aspect ratios. Sizing by a fixed height
equalises the wrong quantity: at a shared height a 4:1 wordmark covers four
times the area of a 1:1 mark and reads as louder. Normalise AREA instead —
with w/h = r, take w = √r and h = 1/√r, so area is constant. Clamp absurd
ratios, cap width and height, and always scale both axes together so the
artwork is never distorted. Measured: 1:1 / 4:1 / 1:2 render to 1024 /
1024 / 1023.8 px², a 1.0002x spread; across 1:1 … 5.5:1 the spread is
1.001x. Missing artwork (or an image that fails) degrades to an initials
tile — never a broken image. Add a ref-callback probe for the cached image
that failed before hydration attached onError.
- Exactly one link per card: "Read the full story" with an ::after overlay
covering the card. Not `<a>` wrapping the card, not `<a>` around a
`<button>`. Add an sr-only " about {company}" so a screen-reader link list
is not six identical entries.
- An unattributed quote is visibly demoted: it loses the accent border and
gains an explicit "Unattributed — no named source" line. Never invent a
name or a title to fill the gap.
Rendering & styling
- Semantic tokens only: bg-card cards, bg-muted initials tile, border /
border-primary for the quote rule, text-primary for the direction glyph and
the link, text-muted-foreground for labels and baselines. No hex, no brand
colour.
- The card grid is container-driven —
grid-cols-[repeat(auto-fill,minmax(min(22rem,100%),1fr))] — so the same
block works in a doc column, a sidebar or a full-bleed page with no
breakpoints, and one surviving card after filtering does not stretch to a
full row.
- Long unbroken strings are held by `wrap-anywhere` (overflow-wrap: anywhere),
which is the only thing that lowers min-content; `break-words` does not, and
`min-w-0` buys nothing in a column-stacked card (both verified by removing
them separately and re-measuring).
- Numbers use tabular-nums and Intl.NumberFormat with an EXPLICIT locale, so
server and client never format differently.
- Skeleton pulses carry motion-reduce:animate-none; the arrow's hover nudge is
disabled under reduced motion and nothing functional depends on animation.
Focus-visible rings on every chip, button and link; cn() merges className.
Customization levers
- Size bands: SIZE_LABELS / the enum is the one place to swap headcount for
ARR bands or seat counts — the contract and the ordering logic stay put.
- Facets: drop one chip row, or add a third facet (region, product line) by
repeating the derive → cross-count → filter trio; or lift the facet state
into props and let a server query do the filtering.
- Density: remove the quote block for a compact proof wall, or add a
"verified {date}" line under the metrics; the card is a flex column, nothing
is position-locked.
- Metric tile width: the 7rem minimum is the single number that decides when a
strip wraps to a second row; the 22rem card minimum decides column count.
- Logo scale: --case-study-logo is the only size knob. For marks that are
optically airy or ink-heavy, port the per-logo `scale` nudge from
blocks/logo-cloud, which uses the same formula with a tunable `balance`.
- Sentiment: this block does not colour-code good vs bad — in a customer story
every listed metric is a claim, and the label plus baseline carry the
meaning. If you need regressions, add a `tone` field and map it to
text-destructive rather than reusing `direction`.Concepts
- Baseline as a required field — a percentage without a reference point is decoration. Making the baseline part of the metric type, with an explicit "none" branch that carries no text, is what stops the gap from being filled with something that merely sounds like a comparison.
- Truthful facet counts — each chip's number is computed under the other facet's current selection, so it is a promise about what clicking will show, not a global total that stops being true the moment a second filter is on.
- Filtered-empty ≠ never-published — one says "clear a filter", the other says "come back later"; a single shared panel leaves the reader unable to tell which, and with no way out.
- Slack distribution over auto margins — when a row of cards is stretched to equal height,
mt-autosends every spare pixel into one gap above the footer;justify-betweenspreads it across all of them (measured largest gap 116px → 49px on real data, 305px → 112px in the pathological case). - Optical equal-area sizing — logos read as "the same size" when they cover the same area, not when they share a height; sizing by height makes a wide wordmark shout and a square mark whisper.
- All-or-nothing attribution — a quote with a name but no role is as unusable as an anonymous one while looking authentic; forcing both together makes the missing case visible, and the component then demotes it on purpose.