Case Study Cards
Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote.
- Testimonials
- Fintech
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers (IntersectionObserver, scroll-snap). Reduced motion shows final numbers.
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/case-study-cards.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/testimonials/case-study-cards.tsx
1/**2 * CaseStudyCards, customer stories as cards: a typographic company3 * wordmark, one headline metric that counts up when the card scrolls into4 * view, a before/after bar, a short quote with portrait and a link to the5 * full story. Asymmetric 7/5 grid on desktop, swipeable snap row on mobile.6 *7 * Props: stories (Story[]), eyebrow, title, intro, className.8 * Story: { company, logo (ReactNode wordmark), sector, city, value, decimals?,9 * prefix?, suffix?, metric, before, after, ratio (after/before, 0..1),10 * quote, name, role, portrait, href }11 *12 * Browser support: all evergreen browsers (IntersectionObserver, scroll-snap).13 * Accessibility: each card is an <article>; the animated number has the final14 * value as its accessible text from the first render; the mobile row is a15 * labelled, keyboard-scrollable region with a live position counter.16 * Reduced motion: numbers and bars render at their final values, no count-up.17 */18import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";19 20export interface Story {21 company: string;22 logo: ReactNode;23 sector: string;24 city: string;25 value: number;26 decimals?: number;27 prefix?: string;28 suffix?: string;29 metric: string;30 before: string;31 after: string;32 /** after / before, used for the bar (0..1). Use >1 for growth metrics. */33 ratio: number;34 quote: string;35 name: string;36 role: string;37 portrait: string;38 href: string;39}40 41export interface CaseStudyCardsProps {42 stories?: Story[];43 eyebrow?: string;44 title?: string;45 intro?: string;46 className?: string;47}48 49const P = "/img/people/";50const SANS = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;51const MONO = `ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace`;52const SERIF = `"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif`;53 54/* ── wordmarks: real type + a small mark, no grey rectangles ── */55export const Northvane = () => (56 <span className="fd-csc-wm" style={{ gap: 10 }} aria-label="Northvane Freight">57 <svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><path d="M11 1l5 14h-10z" fill="currentColor" /><path d="M11 15v6M7 21h8" stroke="currentColor" strokeWidth="1.6" /></svg>58 <span aria-hidden="true" style={{ font: `800 15px/1 ${SANS}`, letterSpacing: ".22em" }}>NORTHVANE</span>59 <span aria-hidden="true" style={{ font: `400 10px/1 ${MONO}`, letterSpacing: ".12em", opacity: 0.6, alignSelf: "flex-end", paddingBottom: 3 }}>FREIGHT</span>60 </span>61);62export const HaldenRoe = () => (63 <span className="fd-csc-wm" aria-label="Halden and Roe">64 <span aria-hidden="true" style={{ font: `400 21px/1 ${SERIF}`, letterSpacing: "-.01em" }}>Halden <i style={{ font: `italic 400 23px/1 ${SERIF}`, color: "var(--fd-csc-brass)" }}>&</i> Roe</span>65 </span>66);67export const Kiruna = () => (68 <span className="fd-csc-wm" style={{ gap: 9 }} aria-label="Kiruna Cold Chain">69 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1v18M2.2 5.5l15.6 9M2.2 14.5l15.6-9" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /><circle cx="10" cy="10" r="2.6" fill="var(--fd-csc-card)" stroke="currentColor" strokeWidth="1.7" /></svg>70 <span aria-hidden="true" style={{ font: `600 16px/1 ${MONO}`, letterSpacing: "-.02em" }}>kiruna<span style={{ opacity: 0.55 }}>/cold</span></span>71 </span>72);73export const MossPay = () => (74 <span className="fd-csc-wm" style={{ gap: 6 }} aria-label="Moss Pay">75 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="8" fill="currentColor" /><path d="M5 11.5c1.2-3.6 2.3-5 4-5s2.8 1.4 4 5" fill="none" stroke="var(--fd-csc-card)" strokeWidth="1.8" strokeLinecap="round" /></svg>76 <span aria-hidden="true" style={{ font: `800 20px/1 ${SANS}`, letterSpacing: "-.055em" }}>moss<span style={{ fontWeight: 400 }}>pay</span></span>77 </span>78);79 80export const DEFAULT_STORIES: Story[] = [81 { company: "Northvane Freight", logo: <Northvane />, sector: "Logistics", city: "Rotterdam", value: 38, prefix: "−", suffix: "%", metric: "month-end close time", before: "9.5 days", after: "5.9 days", ratio: 0.62,82 quote: "Carrier invoices lived in a 40-tab spreadsheet that only Pieter understood. Pieter is on holiday this week. Nobody has noticed, which I mean as a compliment to him.", name: "Joris van Dam", role: "Group Controller", portrait: P + "p7.webp", href: "#northvane" },83 { company: "Halden & Roe", logo: <HaldenRoe />, sector: "Payments", city: "London", value: 2.3, decimals: 1, prefix: "£", suffix: "M", metric: "duplicate payouts caught, year one", before: "£0.4M found", after: "£2.3M found", ratio: 1,84 quote: "The first week it flagged 61 duplicate payouts we had already written off as timing. I printed the report and walked it to the CFO myself.", name: "Aisha Bello", role: "Head of Payment Ops", portrait: P + "p3.webp", href: "#halden-roe" },85 { company: "Kiruna Cold Chain", logo: <Kiruna />, sector: "Logistics", city: "Gothenburg", value: 71, prefix: "−", suffix: "%", metric: "unmatched freight invoices", before: "1,240 / mo", after: "360 / mo", ratio: 0.29,86 quote: "Unmatched invoices went from a weekly fire drill to a line on Thursday's agenda. Our auditors asked what changed, the nicest thing an auditor has ever said to me.", name: "Daniel Cho", role: "Finance Systems Lead", portrait: P + "p8.webp", href: "#kiruna" },87 { company: "Moss Pay", logo: <MossPay />, sector: "Fintech", city: "Berlin", value: 11, suffix: " hrs", metric: "saved per analyst, every close", before: "26 hrs", after: "15 hrs", ratio: 0.58,88 quote: "Every vendor says automated. This is the first one where my team actually deleted the old macros, and nobody asked for them back after the March close.", name: "Ananya Iyer", role: "VP Finance", portrait: P + "p6.webp", href: "#moss-pay" },89];90 91function useCountUp(target: number, decimals: number, el: RefObject<HTMLElement | null>) {92 const [v, setV] = useState(target);93 /** "done" = final value shown, "wait" = armed, waiting to scroll in, "run" = counting */94 const [phase, setPhase] = useState<"done" | "wait" | "run">("done");95 useLayoutEffect(() => {96 if (matchMedia("(prefers-reduced-motion: reduce)").matches || typeof IntersectionObserver === "undefined") return;97 setV(0);98 setPhase("wait");99 }, []);100 useEffect(() => {101 const node = el.current;102 if (!node || phase !== "wait") return;103 const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) setPhase("run"); }, { threshold: 0.45 });104 io.observe(node);105 return () => io.disconnect();106 }, [phase, el]);107 useEffect(() => {108 if (phase !== "run") return;109 let raf = 0;110 const t0 = performance.now();111 const D = 1500;112 const tick = (t: number) => {113 const k = Math.min(1, (t - t0) / D);114 setV(+(target * (1 - Math.pow(1 - k, 4))).toFixed(decimals));115 if (k < 1) raf = requestAnimationFrame(tick);116 };117 raf = requestAnimationFrame(tick);118 return () => cancelAnimationFrame(raf);119 }, [phase, target, decimals]);120 return [v, phase !== "wait"] as const;121}122 123function Card({ s, i }: { s: Story; i: number }) {124 const ref = useRef<HTMLElement>(null);125 const d = s.decimals ?? 0;126 const [v, live] = useCountUp(s.value, d, ref);127 const shown = `${s.prefix ?? ""}${v.toFixed(d)}${s.suffix ?? ""}`;128 const final = `${s.prefix ?? ""}${s.value.toFixed(d)}${s.suffix ?? ""}`;129 const grow = s.ratio >= 1;130 return (131 <article ref={ref} className="fd-csc-card" data-wide={i % 3 === 0 || undefined} data-live={live || undefined} data-grow={grow || undefined} aria-labelledby={`fd-csc-h-${i}`}>132 <header className="fd-csc-top">133 <span className="fd-csc-logo">{s.logo}</span>134 <span className="fd-csc-tag">{s.sector} · {s.city}</span>135 </header>136 <div className="fd-csc-metric">137 <p className="fd-csc-num" id={`fd-csc-h-${i}`} aria-label={`${final} ${s.metric}, ${s.company}`}>138 <span aria-hidden="true">{shown}</span>139 </p>140 <p className="fd-csc-label">{s.metric}</p>141 </div>142 <div className="fd-csc-bars" aria-hidden="true">143 <div className="fd-csc-bar"><span>Before</span><i style={{ ["--w" as string]: grow ? 0.18 : 1 }} /><b>{s.before}</b></div>144 <div className="fd-csc-bar" data-after=""><span>After</span><i style={{ ["--w" as string]: grow ? 1 : s.ratio }} /><b>{s.after}</b></div>145 </div>146 <blockquote className="fd-csc-quote"><p>{s.quote}</p></blockquote>147 <footer className="fd-csc-foot">148 <img className="fd-csc-av" src={s.portrait} alt="" width={40} height={40} loading="lazy" decoding="async" />149 <span className="fd-csc-who"><b>{s.name}</b><span>{s.role}, {s.company}</span></span>150 <a className="fd-csc-link" href={s.href}>151 Read the story<span className="fd-csc-sr"> of {s.company}</span>152 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>153 </a>154 </footer>155 </article>156 );157}158 159export function CaseStudyCards({160 stories = DEFAULT_STORIES,161 eyebrow = "Customer ledger · 2026",162 title = "Finance teams that close on day two, and what it took.",163 intro = "340 finance teams run their close on Ledgerline. Four of them agreed to show the numbers, before and after, with nothing rounded in our favour.",164 className,165}: CaseStudyCardsProps) {166 const row = useRef<HTMLDivElement>(null);167 const [pos, setPos] = useState(1);168 const onScroll = () => {169 const el = row.current;170 if (!el) return;171 const w = (el.firstElementChild as HTMLElement | null)?.offsetWidth ?? el.clientWidth;172 setPos(Math.min(stories.length, Math.round(el.scrollLeft / (w + 12)) + 1));173 };174 return (175 <section className={`fd-csc${className ? " " + className : ""}`} aria-label="Customer stories">176 <style>{css}</style>177 <div className="fd-csc-head">178 <p className="fd-csc-eyebrow"><i aria-hidden="true" />{eyebrow}</p>179 <h2 className="fd-csc-title">{title}</h2>180 <p className="fd-csc-intro">{intro}</p>181 </div>182 <div className="fd-csc-grid" ref={row} onScroll={onScroll} role="region" aria-label="Customer story cards" tabIndex={0}>183 {stories.map((s, i) => <Card key={s.company} s={s} i={i} />)}184 </div>185 <p className="fd-csc-pager" aria-live="polite"><b>{String(pos).padStart(2, "0")}</b> / {String(stories.length).padStart(2, "0")}<span>Swipe for more</span></p>186 </section>187 );188}189 190const css = `191.fd-csc,.fd-csc *,.fd-csc *::before,.fd-csc *::after{box-sizing:border-box}192.fd-csc{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;193 --fd-csc-bg:#0B1626;--fd-csc-card:#102036;--fd-csc-fg:#EEF0EA;--fd-csc-mut:#8E9CAE;--fd-csc-line:rgba(238,240,234,.1);--fd-csc-brass:#D2AE63;194 display:block;max-width:1240px;margin:0 auto;padding:88px 32px 96px;background:var(--fd-csc-bg);color:var(--fd-csc-fg);195 font:400 15px/1.5 ${SANS};letter-spacing:-.003em;-webkit-font-smoothing:antialiased}196.fd-csc-head{display:grid;grid-template-columns:7fr 5fr;column-gap:48px;row-gap:20px;align-items:end;margin:0 0 56px;padding:0}197.fd-csc-eyebrow{grid-column:1 / -1;display:flex;align-items:center;gap:12px;margin:0;padding:0;font:500 12px/1 ${MONO};letter-spacing:.12em;text-transform:uppercase;color:var(--fd-csc-brass)}198.fd-csc-eyebrow i{display:block;width:28px;height:1px;background:currentColor}199.fd-csc-title{margin:0;padding:0;max-width:16ch;font:600 clamp(2.25rem,4.4vw,3.5rem)/1.02 ${SANS};letter-spacing:-.035em;color:var(--fd-csc-fg);text-wrap:balance}200.fd-csc-intro{margin:0;padding:0 0 6px;max-width:40ch;font:400 1.0625rem/1.55 ${SANS};color:var(--fd-csc-mut)}201.fd-csc-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px;margin:0;padding:0;outline:none}202.fd-csc-grid:focus-visible{outline:2px solid rgba(210,174,99,.7);outline-offset:4px;border-radius:4px}203.fd-csc-card{grid-column:span 5;position:relative;display:flex;flex-direction:column;margin:0;padding:28px 28px 22px;border-radius:20px;background:var(--fd-csc-card);color:var(--fd-csc-fg);box-shadow:inset 0 0 0 1px var(--fd-csc-line);204 transition:box-shadow var(--fd-dur-3) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}205.fd-csc-card[data-wide]{grid-column:span 7}206.fd-csc-card:hover{box-shadow:inset 0 0 0 1px rgba(210,174,99,.45);transform:translateY(-2px)}207.fd-csc-top{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0;padding:0 0 20px;border-bottom:1px solid var(--fd-csc-line)}208.fd-csc-logo{display:flex;align-items:center;min-height:24px;color:var(--fd-csc-fg)}209.fd-csc-wm{display:inline-flex;align-items:center;white-space:nowrap;color:inherit}210.fd-csc-wm svg{display:block}211.fd-csc-tag{font:500 11px/1 ${MONO};letter-spacing:.1em;text-transform:uppercase;color:var(--fd-csc-mut);white-space:nowrap}212.fd-csc-metric{margin:0;padding:32px 0 20px}213.fd-csc-num{margin:0;padding:0;font:300 clamp(3.5rem,6.4vw,5.5rem)/.92 ${SANS};letter-spacing:-.05em;color:var(--fd-csc-fg);font-variant-numeric:tabular-nums;white-space:nowrap}214.fd-csc-card[data-wide] .fd-csc-num{color:var(--fd-csc-brass)}215.fd-csc-label{margin:12px 0 0;padding:0;font:500 0.9375rem/1.3 ${SANS};color:var(--fd-csc-fg)}216.fd-csc-bars{display:grid;gap:6px;margin:0 0 24px;padding:0}217.fd-csc-bar{display:grid;grid-template-columns:52px 1fr 96px;align-items:center;gap:12px;font:400 11px/1 ${MONO};letter-spacing:.04em;color:var(--fd-csc-mut)}218.fd-csc-bar span{text-transform:uppercase}219.fd-csc-bar i{display:block;height:6px;border-radius:9999px;background:rgba(238,240,234,.18);transform-origin:0 50%;transform:scaleX(var(--w));transition:transform 1500ms var(--fd-ease-out) 120ms}220.fd-csc-bar[data-after] i{background:var(--fd-csc-brass)}221.fd-csc-card:not([data-live]) .fd-csc-bar[data-after] i{transform:scaleX(1)}222.fd-csc-card[data-grow]:not([data-live]) .fd-csc-bar[data-after] i{transform:scaleX(.18)}223.fd-csc-bar b{font-weight:500;text-align:right;color:var(--fd-csc-fg);font-variant-numeric:tabular-nums}224.fd-csc-quote{flex:1;margin:0;padding:0}225.fd-csc-quote p{margin:0;padding:0;max-width:52ch;font:400 0.9375rem/1.6 ${SANS};color:#C9D0D9;text-wrap:pretty}226.fd-csc-quote p::before{content:"\\201C";color:var(--fd-csc-brass)}227.fd-csc-quote p::after{content:"\\201D";color:var(--fd-csc-brass)}228.fd-csc-foot{display:flex;align-items:center;gap:12px;margin:24px 0 0;padding:18px 0 0;border-top:1px solid var(--fd-csc-line)}229.fd-csc-av{display:block;flex:none;width:40px;height:40px;border-radius:50%;object-fit:cover;object-position:50% 25%;background:#1B2C44}230.fd-csc-who{display:flex;flex-direction:column;min-width:0;flex:1}231.fd-csc-who b{font:600 14px/1.3 ${SANS};letter-spacing:-.01em;color:var(--fd-csc-fg)}232.fd-csc-who span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font:400 13px/1.35 ${SANS};color:var(--fd-csc-mut)}233.fd-csc-link{display:inline-flex;align-items:center;gap:8px;flex:none;height:36px;margin:0;padding:0 4px 0 14px;border-radius:9999px;font:500 13px/1 ${SANS};letter-spacing:0;color:var(--fd-csc-fg);text-decoration:none;box-shadow:inset 0 0 0 1px var(--fd-csc-line);234 transition:background-color var(--fd-dur-2),color var(--fd-dur-2),box-shadow var(--fd-dur-2)}235.fd-csc-link svg{display:block;width:28px;height:28px;padding:6px;border-radius:50%;background:rgba(238,240,234,.08);transition:transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-2)}236.fd-csc-card:hover .fd-csc-link svg{transform:translateX(2px);background:var(--fd-csc-brass);color:var(--fd-csc-bg)}237.fd-csc-link:hover{box-shadow:inset 0 0 0 1px rgba(210,174,99,.6)}238.fd-csc-link:active{transform:scale(.96)}239.fd-csc-link:focus-visible{outline:2px solid rgba(210,174,99,.7);outline-offset:2px}240.fd-csc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}241.fd-csc-pager{display:none}242@media (max-width:900px){243 .fd-csc{padding:64px 0 72px}244 .fd-csc-head{grid-template-columns:1fr;margin-bottom:36px;padding:0 16px}245 .fd-csc-grid{display:flex;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;padding:0 16px 4px;scroll-padding:0 16px;scrollbar-width:none;-webkit-overflow-scrolling:touch}246 .fd-csc-grid::-webkit-scrollbar{display:none}247 .fd-csc-card,.fd-csc-card[data-wide]{flex:none;width:min(86vw,420px);scroll-snap-align:start;padding:24px 22px 18px}248 .fd-csc-card:hover{transform:none}249 .fd-csc-num{font-size:4rem}250 .fd-csc-top{flex-wrap:wrap;row-gap:10px}251 .fd-csc-tag{flex-basis:100%}252 .fd-csc-link{padding:0;gap:0;width:36px;justify-content:center;font-size:0;box-shadow:none}253 .fd-csc-link svg{width:36px;height:36px;padding:9px}254 .fd-csc-pager{display:flex;align-items:center;gap:6px;margin:20px 0 0;padding:0 16px;font:400 12px/1 ${MONO};letter-spacing:.08em;color:var(--fd-csc-mut)}255 .fd-csc-pager b{font-weight:600;color:var(--fd-csc-fg)}256 .fd-csc-pager span{margin-left:auto;text-transform:uppercase}257}258@media (prefers-reduced-motion:reduce){.fd-csc *{transition-duration:1ms!important}.fd-csc-card:hover{transform:none}}259@media (prefers-contrast:more){.fd-csc{--fd-csc-line:rgba(238,240,234,.5);--fd-csc-mut:#B5C0CD}}260`;261 262export function Demo() {263 return (264 <div className="fd-cscd">265 <style>{demoCss}</style>266 <header className="fd-cscd-bar">267 <span className="fd-cscd-logo">268 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M2 4h16M2 10h10M2 16h16" stroke="#D2AE63" strokeWidth="2" strokeLinecap="square" /></svg>269 Ledgerline270 </span>271 <span className="fd-cscd-crumb">Customers</span>272 <a className="fd-cscd-cta" href="#demo">Book a close review</a>273 </header>274 <CaseStudyCards />275 </div>276 );277}278 279const demoCss = `280.fd-cscd,.fd-cscd *{box-sizing:border-box}281.fd-cscd{min-height:100vh;margin:0;padding:0;background:#0B1626;font:400 14px/1.4 ${SANS}}282.fd-cscd-bar{display:flex;align-items:center;gap:20px;max-width:1240px;height:64px;margin:0 auto;padding:0 32px;border-bottom:1px solid rgba(238,240,234,.08)}283.fd-cscd-logo{display:inline-flex;align-items:center;gap:10px;font:650 17px/1 ${SANS};letter-spacing:-.03em;color:#EEF0EA}284.fd-cscd-logo svg{display:block}285.fd-cscd-crumb{padding-left:20px;border-left:1px solid rgba(238,240,234,.14);font:400 13px/1 ${SANS};color:#8E9CAE}286.fd-cscd-cta{margin-left:auto;display:inline-flex;align-items:center;height:36px;padding:0 16px;border-radius:9999px;background:#EEF0EA;color:#0B1626;font:600 13px/1 ${SANS};text-decoration:none}287.fd-cscd-cta:hover{background:#D2AE63}288.fd-cscd-cta:focus-visible{outline:2px solid rgba(210,174,99,.7);outline-offset:2px}289@media (max-width:900px){.fd-cscd-bar{padding:0 16px}.fd-cscd-crumb{display:none}}290`;More Testimonials. Same file contract.
Questions about Case Study Cards
Run npx shadcn@latest add https://freedesignmd.com/r/case-study-cards.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote. It is a free, MIT-licensed React testimonials component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers (IntersectionObserver, scroll-snap). Reduced motion shows final numbers.
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


