Release Timeline
A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.
A grid of case-study cards that lead with the metric rather than the company name, each one linking out to the full write-up — the block a customer-stories section or a home page needed and only had a full case-study page for.
components/case-study-grid.tsxnpx hoverlab add case-study-grid
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Three teams, three different problems, one number each that a colleague can check.
“Reconciliation stopped being the thing that ate the first week of every month.”
“Our auditors got read-only access instead of a folder of screenshots. They noticed.”
“We automated the boring 80% and kept the exceptions in front of a human, on purpose.”
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <CaseStudyGrid> — a grid of case-study cards, each leading with the number.
*
* The catalog already has a full case-study PAGE (`case-study-page.tsx`) and
* a table for the results section inside one (`<StatsComparison>`). What was
* missing is the block that links a reader to several of those pages at
* once — the "customer stories" section a marketing site puts on its home
* page or its own `/customers` index, one card per company.
*
* Every card leads with the metric, not the company name or a photo,
* because a card in a grid is competing with five others for a glance: a
* reader deciding which of six case studies to open is scanning for the
* biggest number, not the prettiest logo. The quote and the company come
* after, as the evidence for the number rather than the headline.
*
* `window` — a measurement period, authored per card — is required by the
* type rather than optional, for the reason `case-study-page.tsx` and
* `customer-outcome-band.tsx` both give: "-63%" with no period attached is
* not a checkable claim, and a component that makes the field optional
* teaches the person filling it in that it is fine to leave out.
*
* The whole card is the link, matching `<BlogPostGrid>` next door — a metric
* and a quote that are not clickable read as clickable and are the most
* common card papercut in this catalog.
*/
import * as React from 'react'
import { ArrowRight } from 'lucide-react'
export interface CaseStudy {
slug: string
company: string
industry: string
/** The headline figure, already formatted: "68% faster", "3.2x", "$1.2M saved". */
metric: string
/** What the metric measures, in a few words: "Time to close the books". */
metricLabel: string
/** The period it was measured over — see the note above. */
window: string
quote: string
attribution: string
}
export interface CaseStudyGridProps {
heading?: string
standfirst?: string
studies?: CaseStudy[]
className?: string
}
const DEFAULT_STUDIES: CaseStudy[] = [
{
slug: '/customers/lumen-finance',
company: 'Lumen Finance',
industry: 'Fintech · 210 employees',
metric: '68% faster',
metricLabel: 'Days to close the month',
window: 'over two full quarters',
quote: 'Reconciliation stopped being the thing that ate the first week of every month.',
attribution: 'VP of Finance',
},
{
slug: '/customers/northbridge-logistics',
company: 'Northbridge Logistics',
industry: 'Logistics · 1,400 employees',
metric: '9 days → 2',
metricLabel: 'Year-end audit prep',
window: 'measured across two audit cycles',
quote: 'Our auditors got read-only access instead of a folder of screenshots. They noticed.',
attribution: 'Head of Compliance',
},
{
slug: '/customers/hearthside-retail',
company: 'Hearthside Retail',
industry: 'E-commerce · 60 employees',
metric: '31% lower',
metricLabel: 'Cost per resolved ticket',
window: 'over the first six months',
quote: 'We automated the boring 80% and kept the exceptions in front of a human, on purpose.',
attribution: 'Head of Support',
},
]
export function CaseStudyGrid({
heading = 'What changed for them',
standfirst = 'Three teams, three different problems, one number each that a colleague can check.',
studies = DEFAULT_STUDIES,
className = '',
}: CaseStudyGridProps) {
return (
<section className={`mx-auto w-full max-w-7xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}>
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">{heading}</h2>
<p className="mt-3 text-pretty text-muted-foreground">{standfirst}</p>
</div>
<ul className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{studies.map((study) => (
<li key={study.slug}>
<a
href={study.slug}
className="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-6 transition-shadow hover:shadow-lg"
>
<div>
<div className="min-w-0 break-words text-3xl font-bold tracking-tight text-primary tabular-nums">
{study.metric}
</div>
<div className="mt-1 min-w-0 break-words text-sm font-medium text-foreground">{study.metricLabel}</div>
<div className="min-w-0 break-words text-xs text-muted-foreground">{study.window}</div>
</div>
<p className="mt-4 min-w-0 flex-1 break-words text-pretty text-sm leading-relaxed text-muted-foreground">
“{study.quote}”
</p>
<div className="mt-5 flex flex-wrap items-end justify-between gap-x-3 gap-y-2 border-t border-border/60 pt-4">
<div className="min-w-0">
<div className="truncate text-sm font-semibold transition-colors group-hover:text-primary">
{study.company}
</div>
<div className="truncate text-xs text-muted-foreground">{study.industry}</div>
<div className="truncate text-xs text-muted-foreground/80">{study.attribution}</div>
</div>
<span className="inline-flex max-w-full items-center gap-1 text-xs font-semibold text-primary">
Read the case study
<ArrowRight aria-hidden className="h-3.5 w-3.5 transition-transform rtl:rotate-180 group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5" />
</span>
</div>
</a>
</li>
))}
</ul>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/case-study-grid.tsx and import it where you need the section:
import { CaseStudyGrid } from '@/components/case-study-grid'1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
heading | string | 'What changed for them' |
standfirst | string | — |
studies | CaseStudy[] | DEFAULT_STUDIES |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
<!--
Case Study Card Grid — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block has no interactive behaviour, so nothing is missing.
-->
<section class="mx-auto w-full max-w-7xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="mx-auto max-w-2xl text-center">
<h2 class="text-balance text-3xl font-bold tracking-tight sm:text-4xl">What changed for them</h2>
<p class="mt-3 text-pretty text-muted-foreground">Three teams, three different problems, one number each that a colleague can check.</p>
</div>
<ul class="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<li>
<a href="/customers/lumen-finance" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-6 transition-shadow hover:shadow-lg">
<div>
<div class="min-w-0 break-words text-3xl font-bold tracking-tight text-primary tabular-nums">68% faster</div>
<div class="mt-1 min-w-0 break-words text-sm font-medium text-foreground">Days to close the month</div>
<div class="min-w-0 break-words text-xs text-muted-foreground">over two full quarters</div>
</div>
<p class="mt-4 min-w-0 flex-1 break-words text-pretty text-sm leading-relaxed text-muted-foreground">“Reconciliation stopped being the thing that ate the first week of every month.”</p>
<div class="mt-5 flex flex-wrap items-end justify-between gap-x-3 gap-y-2 border-t border-border/60 pt-4">
<div class="min-w-0">
<div class="truncate text-sm font-semibold transition-colors group-hover:text-primary">Lumen Finance</div>
<div class="truncate text-xs text-muted-foreground">Fintech · 210 employees</div>
<div class="truncate text-xs text-muted-foreground/80">VP of Finance</div>
</div>
<span class="inline-flex max-w-full items-center gap-1 text-xs font-semibold text-primary">Read the case study<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right h-3.5 w-3.5 transition-transform rtl:rotate-180 group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg></span>
</div>
</a>
</li>
<li>
<a href="/customers/northbridge-logistics" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-6 transition-shadow hover:shadow-lg">
<div>
<div class="min-w-0 break-words text-3xl font-bold tracking-tight text-primary tabular-nums">9 days → 2</div>
<div class="mt-1 min-w-0 break-words text-sm font-medium text-foreground">Year-end audit prep</div>
<div class="min-w-0 break-words text-xs text-muted-foreground">measured across two audit cycles</div>
</div>
<p class="mt-4 min-w-0 flex-1 break-words text-pretty text-sm leading-relaxed text-muted-foreground">“Our auditors got read-only access instead of a folder of screenshots. They noticed.”</p>
<div class="mt-5 flex flex-wrap items-end justify-between gap-x-3 gap-y-2 border-t border-border/60 pt-4">
<div class="min-w-0">
<div class="truncate text-sm font-semibold transition-colors group-hover:text-primary">Northbridge Logistics</div>
<div class="truncate text-xs text-muted-foreground">Logistics · 1,400 employees</div>
<div class="truncate text-xs text-muted-foreground/80">Head of Compliance</div>
</div>
<span class="inline-flex max-w-full items-center gap-1 text-xs font-semibold text-primary">Read the case study<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right h-3.5 w-3.5 transition-transform rtl:rotate-180 group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg></span>
</div>
</a>
</li>
<li>
<a href="/customers/hearthside-retail" class="group flex h-full flex-col rounded-2xl border border-border/60 bg-card/80 p-6 transition-shadow hover:shadow-lg">
<div>
<div class="min-w-0 break-words text-3xl font-bold tracking-tight text-primary tabular-nums">31% lower</div>
<div class="mt-1 min-w-0 break-words text-sm font-medium text-foreground">Cost per resolved ticket</div>
<div class="min-w-0 break-words text-xs text-muted-foreground">over the first six months</div>
</div>
<p class="mt-4 min-w-0 flex-1 break-words text-pretty text-sm leading-relaxed text-muted-foreground">“We automated the boring 80% and kept the exceptions in front of a human, on purpose.”</p>
<div class="mt-5 flex flex-wrap items-end justify-between gap-x-3 gap-y-2 border-t border-border/60 pt-4">
<div class="min-w-0">
<div class="truncate text-sm font-semibold transition-colors group-hover:text-primary">Hearthside Retail</div>
<div class="truncate text-xs text-muted-foreground">E-commerce · 60 employees</div>
<div class="truncate text-xs text-muted-foreground/80">Head of Support</div>
</div>
<span class="inline-flex max-w-full items-center gap-1 text-xs font-semibold text-primary">Read the case study<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right h-3.5 w-3.5 transition-transform rtl:rotate-180 group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg></span>
</div>
</a>
</li>
</ul>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Content & BlogCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.
Shipped / in progress / planned in three columns, with status carried by icon as well as colour.
A featured post at full width above a card grid, with drawn covers built from tokens — a blog index with no image pipeline and no broken-thumbnail state.
Kicker, headline, standfirst, byline and share row, with the opening prose and a pull quote included — the type scale of a post, decided once instead of per-article.
An about-page team section on initials avatars rather than photos, so shipping it never waits on headshots and there is no broken-image state to design.
Openings grouped by department with location, type and salary on every row — candidates self-select by craft first, and a flat list makes them scan for it.
Sidebar, article and on-this-page rail — each column answers a different question, and they collapse on mobile in the order that keeps the article alive.
Method badge, path, parameter table and a request/response pair kept side by side — an example request whose response is a scroll away gets pasted blind.
One install command behind npm/pnpm/yarn/bun tabs with a working copy button — every reader uses exactly one package manager, so showing all four wastes three lines.
A CV as a document rather than a timeline of cards — one column, dates on the end edge, and a print layer that puts it on paper without a separate PDF to keep in step.
The watch screen with no playback in it: a ratio-locked stage, a control bar drawn down to the buffered layer, and an up-next rail. Drop a video element in and wire your own player.