Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, TrendingDown, TrendingUp } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  CaseStudyGridData,
  CaseStudyGridItem,
  CaseStudyLogo,
  CaseStudyMetric,
  CaseStudySize,
} from "./case-study-grid.contract"

/** 规模是闭集,所以 chip 顺序永远小→大,而不是数据碰巧的出现顺序。 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/case-study-grid.json

Prompt

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-auto sends every spare pixel into one gap above the footer; justify-between spreads 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.

On This Page