Testimonials
Customers in their own words, with the face and the company behind each quote.
Featured quote
testimonials-01One customer's words set large, the clause they turn on in the ink and the rest a step back, with their face, name, role and company, and a link to the whole story.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { LogoMark } from "@/components/ui/logo-mark"
import { QUOTE, STORY, TITLE } from "./testimonials-01.data"
/**
* One customer, and nothing else on the band: the company's mark, their words
* set large — the clause the quote turns on in the ink, the rest a step back
* in the muted ink, so the eye lands on the claim first — then their face,
* name and role, and a link to the whole story.
*
* The quote is a `figure`: the words a `blockquote`, the person — name, role
* and company — its `figcaption`. The face and the mark are decoration,
* because the caption says both in words. The band's h2 is read, not shown:
* the mark and the quote say what the band is. A server component; every
* word is in `./testimonials-01.data`.
*/
export function Testimonials01() {
const titleId = React.useId()
return (
<section
data-section="testimonials-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<h2 id={titleId} className="sr-only">
{TITLE}
</h2>
<figure className="mx-auto flex max-w-4xl flex-col items-center text-center">
<LogoMark name={QUOTE.company} size="lg" aria-hidden="true" />
<blockquote className="mt-8 text-2xl/9 font-medium tracking-heading text-pretty text-muted-foreground md:mt-10 md:text-3xl/11">
<p>
“
{QUOTE.parts.map((part) =>
part.emphasis ? (
<strong key={part.text} className="font-medium text-foreground">
{part.text}
</strong>
) : (
<React.Fragment key={part.text}>{part.text}</React.Fragment>
)
)}
”
</p>
</blockquote>
<figcaption className="mt-8 flex items-center gap-4 text-left md:mt-10">
<Avatar className="size-12">
<AvatarImage src={avatarFor(QUOTE.name)} alt="" />
<AvatarFallback>{getInitials(QUOTE.name)}</AvatarFallback>
</Avatar>
<div className="text-sm">
<p className="font-medium">{QUOTE.name}</p>
<p className="text-muted-foreground">
{QUOTE.role}, {QUOTE.company}
</p>
</div>
</figcaption>
</figure>
<p className="mt-8 flex justify-center">
<Link href={STORY.href} className={cn(buttonVariants({ variant: "link" }), "group/story h-auto px-0")}>
{STORY.label}
<ArrowRightIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/story:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</p>
</div>
</section>
)
}
export default Testimonials01/**
* Every word `testimonials-01` sets: one customer's words, set large, with
* the clause they turn on marked, then who said them and where the rest of
* the story is. The company is one of the kit's fictional ones, drawn by
* `LogoMark`, and the person gets the kit's face for their name; replace both
* with a customer who agreed to be quoted, never one who did not.
*/
import type { LogoName } from "@/components/ui/logo-mark"
/** A run of the quote. `emphasis` marks the words the quote turns on, set in the ink. */
export type QuotePart = { text: string; emphasis?: true }
/** Names the band for a screen reader; the quote and the mark say the rest. */
export const TITLE = "What Tidewater's finance team says about Northwind"
export const QUOTE: { parts: readonly QuotePart[]; name: string; role: string; company: LogoName } = {
parts: [
{ text: "The first week of every month went on arguing about whose revenue number was right. " },
{ text: "Now billing, refunds and usage land in one place overnight,", emphasis: true },
{ text: " and the close meeting starts with the decision instead of the reconciliation." },
],
name: "Jonas Eklund",
role: "Group Controller",
company: "Tidewater",
}
export const STORY = { label: "Read how Tidewater closes in three days", href: "/customers/tidewater" }npx shadcn@latest add @vibra/testimonials-01Wall of quotes
testimonials-02Twelve customers in their own words and at their own length, packed into columns that hold still; a phone shows four and a button brings the rest.
import * as React from "react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { LogoMark } from "@/components/ui/logo-mark"
import { QuoteWall } from "./testimonials-02.client"
import { EYEBROW, HEADLINE, LEDE, PHONE_COUNT, QUOTES, moreLabel, type WallQuote } from "./testimonials-02.data"
/**
* One card on the wall: the company's mark, the words, and the person —
* their face, name, role and company — as the caption. The mark and the face
* are decoration, because the caption says both in words.
*/
function WallCard({ quote }: { quote: WallQuote }) {
return (
<figure className="flex flex-col gap-5 panel p-6">
<LogoMark name={quote.company} size="sm" aria-hidden="true" className="text-muted-foreground" />
<blockquote className="text-sm/6 text-pretty">
<p>“{quote.text}”</p>
</blockquote>
<figcaption className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={avatarFor(quote.name)} alt="" />
<AvatarFallback>{getInitials(quote.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-sm">
<p className="font-medium">{quote.name}</p>
<p className="text-muted-foreground">
{quote.role}, {quote.company}
</p>
</div>
</figcaption>
</figure>
)
}
/**
* A wall of customers: twelve quotes of their own lengths, packed into
* columns — one on a phone, two from `sm`, three from `lg` — so the band
* reads as many voices rather than one card repeated. It holds still: a wall
* that scrolls itself is one a reader cannot pause and a screen reader cannot
* follow.
*
* The cards are drawn here and handed to the island (`./testimonials-02.client`),
* whose only job is the phone: the first four, then a button for the rest.
* Every word is in `./testimonials-02.data`.
*/
export function Testimonials02() {
const titleId = React.useId()
return (
<section
data-section="testimonials-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<QuoteWall phoneCount={PHONE_COUNT} moreLabel={moreLabel(QUOTES.length - PHONE_COUNT)}>
{QUOTES.map((quote) => (
<WallCard key={quote.name} quote={quote} />
))}
</QuoteWall>
</div>
</div>
</section>
)
}
export default Testimonials02"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
/**
* The wall's one piece of state. From `sm` every quote shows, in two columns
* and then three; on a phone, where twelve cards would be a long scroll
* before the band ends, the first `phoneCount` show and a button brings the
* rest. Pressing it hands focus to the first quote it revealed, so a
* keyboard or screen reader carries on reading where the wall grew rather
* than back at the button, which has gone.
*
* The quotes arrive drawn, as children, from the server file; this only
* decides which of them a phone shows. A collapsed quote carries
* `data-collapsed`, and is hidden below `sm` only, so a wide screen never
* waits for this island to see the whole wall.
*/
export function QuoteWall({
phoneCount,
moreLabel,
children,
}: {
phoneCount: number
/** The button's words, which say how many quotes it will show. */
moreLabel: string
children: React.ReactNode
}) {
const listId = React.useId()
const [expanded, setExpanded] = React.useState(false)
const firstRevealed = React.useRef<HTMLLIElement>(null)
const quotes = React.Children.toArray(children)
React.useEffect(() => {
if (expanded) firstRevealed.current?.focus()
}, [expanded])
return (
<>
<ul id={listId} className="columns-1 gap-4 sm:columns-2 lg:columns-3 lg:gap-6">
{quotes.map((quote, index) => {
const collapsed = !expanded && index >= phoneCount
return (
<li
key={index}
ref={index === phoneCount ? firstRevealed : undefined}
tabIndex={index === phoneCount ? -1 : undefined}
data-collapsed={collapsed || undefined}
className={cn("mb-4 break-inside-avoid rounded-xl focus-ring lg:mb-6", collapsed && "max-sm:hidden")}
>
{quote}
</li>
)
})}
</ul>
{!expanded && quotes.length > phoneCount ? (
<Button
variant="outline"
size="lg"
aria-controls={listId}
onClick={() => setExpanded(true)}
className="h-10 w-full sm:hidden"
>
{moreLabel}
</Button>
) : null}
</>
)
}/**
* Every word `testimonials-02` sets: twelve customers in their own words, of
* different lengths on purpose, so the wall packs into uneven columns the way
* a pinboard does. Each company is one of the kit's fictional ones, drawn by
* `LogoMark`, and each person gets the kit's face for their name; replace
* them with customers who agreed to be quoted.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type WallQuote = { text: string; name: string; role: string; company: LogoName }
export const EYEBROW = "Customers"
export const HEADLINE = "Teams that stopped rebuilding the Monday report"
export const LEDE =
"Finance, operations and support leads on their first quarter with Northwind, in their own words and at their own length."
/** How many quotes a phone shows before the reader asks for the rest; every quote shows from `sm`. */
export const PHONE_COUNT = 4
/** The button that shows the rest on a phone, given how many are still to come. */
export function moreLabel(count: number): string {
return `Show ${count} more ${count === 1 ? "quote" : "quotes"}`
}
export const QUOTES: readonly WallQuote[] = [
{
text: "Refund requests used to sit in three inboxes. Now each one shows up against the order and the customer's plan, and we clear them the same afternoon.",
name: "Aisha Bello",
role: "Head of Operations",
company: "Harbour",
},
{
text: "I stopped exporting trial sign-ups to a spreadsheet every Friday. The cohort report answers the question before anyone asks it.",
name: "Theo Marchetti",
role: "Head of Growth",
company: "Lumen",
},
{
text: "We are eleven people. Northwind is the analyst we could not afford to hire yet.",
name: "Ben Okonkwo",
role: "Founder",
company: "Alder",
},
{
text: "Our board pack used to take three people three days. It is one afternoon now, and the numbers in it match the ones finance closed the month on.",
name: "Rosa Delgado",
role: "CFO",
company: "Copperline",
},
{
text: "Pipeline, bookings and churn finally agree with each other. That alone ended a standing Tuesday meeting.",
name: "Nikhil Rao",
role: "Revenue Operations Lead",
company: "Fieldstone",
},
{
text: "We can see which accounts wrote to support in the month before they cancelled. Two of our five largest renewals were saved by a call that report prompted.",
name: "Camille Laurent",
role: "Head of Support",
company: "Kestrel",
},
{
text: "Deferred revenue lived in a spreadsheet nobody dared rename. It is a report with an owner now.",
name: "Mei Tanaka",
role: "Finance Manager",
company: "Riverside",
},
{
text: "Usage events arrive already joined to the account and the plan, so I spend my week on questions instead of on joins.",
name: "Sofia Petrov",
role: "Product Analyst",
company: "Wavelength",
},
{
text: "When a metric moves, the alert names the five accounts that moved it. I forward that message to the team that owns them, and that is the whole process.",
name: "Ewan MacLeod",
role: "COO",
company: "Northgate",
},
{
text: "We retired four dashboards and a scheduled export on the first day. Nobody has asked for them back.",
name: "Zara Ahmed",
role: "Head of Data",
company: "Brightwater",
},
{
text: "Renewal forecasts used to be a feeling in a meeting. Now every account carries the usage trend behind its number, and the conversation starts there.",
name: "Lukas Brandt",
role: "VP Customer Success",
company: "Solstice",
},
{
text: "Month-end went from nine days to five in our first quarter, and this year's audit asked us for fewer schedules.",
name: "Hannah Weiss",
role: "Head of Finance",
company: "Ironbark",
},
]npx shadcn@latest add @vibra/testimonials-02Quote carousel
testimonials-03Five quotes that turn over one at a time, with named previous and next buttons and a rotation switch; rotation holds still under reduced motion, under the pointer and while focus is inside.
import * as React from "react"
import { LayoutDashboardIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { LogoMark } from "@/components/ui/logo-mark"
import { QuoteCarousel } from "./testimonials-03.client"
import {
ADVANCE_EVERY_MS,
CONTROLS,
EYEBROW,
HEADLINE,
LEDE,
REPORT_LABEL,
SLIDES,
type Slide,
} from "./testimonials-03.data"
/**
* One slide: the company's mark, the quote, and a caption holding the person
* — face, name, role and company — and the report their team opens first.
* The mark and the face are decoration; the caption says both in words.
*/
function QuoteSlide({ slide }: { slide: Slide }) {
return (
// The ring is drawn inside the panel: the carousel's viewport clips
// whatever overflows it, and an outside ring loses its first slide's edge.
<figure className="flex h-full flex-col panel p-6 ring-inset sm:p-8 lg:p-10">
<LogoMark name={slide.company} aria-hidden="true" className="text-muted-foreground" />
<blockquote className="mt-6 text-lg/8 font-medium text-pretty sm:text-xl/8 lg:mt-8 lg:text-2xl/9 lg:tracking-heading">
<p>“{slide.quote}”</p>
</blockquote>
<figcaption className="mt-auto flex flex-col gap-4 pt-8 sm:flex-row sm:items-center sm:justify-between lg:pt-10">
<div className="flex items-center gap-3">
<Avatar className="size-11">
<AvatarImage src={avatarFor(slide.name)} alt="" />
<AvatarFallback>{getInitials(slide.name)}</AvatarFallback>
</Avatar>
<div className="text-sm">
<p className="font-medium">{slide.name}</p>
<p className="text-muted-foreground">
{slide.role}, {slide.company}
</p>
</div>
</div>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<LayoutDashboardIcon aria-hidden="true" className="size-4 shrink-0" />
<span>
{REPORT_LABEL}: <span className="font-medium text-foreground">{slide.report}</span>
</span>
</p>
</figcaption>
</figure>
)
}
/**
* Five customers, one at a time, turning over on their own: the heading on
* the left, the carousel's controls on the right of its row, then the quote
* in front with the next one peeking in from the edge, and a count under it.
*
* The band is the carousel region — a `section` described as a carousel and
* named by its h2, the way the pattern asks. The slides are drawn here and
* handed to the island (`./testimonials-03.client`), which owns the turning:
* which quote is in front, the rotation and when it holds still. Every word
* is in `./testimonials-03.data`.
*/
export function Testimonials03() {
const titleId = React.useId()
return (
<section
data-section="testimonials-03"
data-slot="section"
aria-labelledby={titleId}
aria-roledescription="carousel"
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<QuoteCarousel
controls={CONTROLS}
interval={ADVANCE_EVERY_MS}
header={
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
}
>
{SLIDES.map((slide) => (
<QuoteSlide key={slide.name} slide={slide} />
))}
</QuoteCarousel>
</div>
</section>
)
}
export default Testimonials03"use client"
import * as React from "react"
import { ArrowLeftIcon, ArrowRightIcon, PauseIcon, PlayIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from "@/components/ui/carousel"
import type { CarouselControls } from "./testimonials-03.data"
/**
* The quotes as a carousel on the kit's own `Carousel`: one quote in front,
* the next one peeking from `md`, and three named controls in the heading's
* row — the rotation button first, then previous and next.
*
* Rotation is on until the reader says otherwise, with three exceptions that
* each hold it still: a reader who prefers reduced motion (it starts stopped,
* and a quote they move to arrives without sliding), a pointer resting on the
* carousel, and focus anywhere inside it — so a quote never moves while it is
* being read or while a control is in hand. The rotation button is the
* reader's own switch and outlasts the other two. The band itself is the
* carousel region (`aria-roledescription` on the section), so the kit's
* root drops its own region role rather than nesting a second, unnamed one.
*
* Each slide is a group named "2 of 5"; the ones behind are hidden from
* assistive technology and inert. While rotation runs the slides are not a
* live region — a quote changing every eight seconds would talk over the
* page — and once it stops, a quote a reader moves to is announced.
*/
export function QuoteCarousel({
header,
controls,
interval,
children,
}: {
/** The band's eyebrow, h2 and lede, drawn by the server file; the controls sit in its row. */
header: React.ReactNode
controls: CarouselControls
/** How long a quote stays, in milliseconds, while rotation runs. */
interval: number
/** One slide per child. */
children: React.ReactNode
}) {
const slides = React.Children.toArray(children)
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
const reduced = useReducedMotion()
// null until the reader presses the rotation button: until then rotation
// follows the motion setting.
const [choice, setChoice] = React.useState<boolean | null>(null)
const [hovered, setHovered] = React.useState(false)
const [focused, setFocused] = React.useState(false)
const playing = choice ?? !reduced
const rotating = playing && !hovered && !focused
React.useEffect(() => {
if (!api) return
const sync = () => setCurrent(api.selectedScrollSnap())
sync()
api.on("select", sync)
api.on("reInit", sync)
return () => {
api.off("select", sync)
api.off("reInit", sync)
}
}, [api])
// A reader who asked for less motion gets the next quote without the slide.
useInterval(() => api?.scrollNext(reduced), rotating && api ? interval : null)
return (
<div
data-slot="quote-carousel"
data-rotating={rotating || undefined}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false)
}}
>
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between md:gap-12">
{header}
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="icon-lg"
aria-label={playing ? controls.stop : controls.start}
onClick={() => setChoice(!playing)}
>
{playing ? <PauseIcon aria-hidden="true" /> : <PlayIcon aria-hidden="true" />}
</Button>
<Button variant="outline" size="icon-lg" aria-label={controls.previous} onClick={() => api?.scrollPrev(reduced)}>
<ArrowLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button variant="outline" size="icon-lg" aria-label={controls.next} onClick={() => api?.scrollNext(reduced)}>
<ArrowRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
</div>
<Carousel opts={{ loop: true, align: "start" }} setApi={setApi} region={false} className="mt-10 md:mt-12">
<CarouselContent aria-live={rotating ? "off" : "polite"}>
{slides.map((slide, index) => {
const hidden = index !== current
return (
<CarouselItem
key={index}
aria-label={`${index + 1} of ${slides.length}`}
aria-hidden={hidden || undefined}
inert={hidden}
data-current={!hidden || undefined}
className={cn(
"basis-full transition-opacity duration-(--duration-base) ease-(--ease-standard) md:basis-[84%] lg:basis-[76%]",
hidden && "opacity-40"
)}
>
{slide}
</CarouselItem>
)
})}
</CarouselContent>
</Carousel>
<div aria-hidden="true" className="mt-6 flex items-center gap-4">
<div className="flex gap-1.5">
{slides.map((_, index) => (
<span
key={index}
className={cn(
"h-1 w-6 rounded-full bg-border transition-colors duration-(--duration-base) ease-(--ease-standard)",
index === current && "bg-foreground"
)}
/>
))}
</div>
<p className="text-sm text-muted-foreground tabular-nums">
{current + 1} / {slides.length}
</p>
</div>
</div>
)
}/**
* Every word `testimonials-03` sets: five customers, one to a slide, each
* with the report their team opens first; the names of the carousel's own
* controls, so a translation reaches them too; and how long a quote stays
* before the next one comes round. Each company is one of the kit's
* fictional ones, drawn by `LogoMark`, and each person gets the kit's face
* for their name; replace them with customers who agreed to be quoted.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type Slide = {
quote: string
name: string
role: string
company: LogoName
/** The report the team opens first on a Monday, named the way the product names it. */
report: string
}
export const EYEBROW = "Customer stories"
export const HEADLINE = "Five teams, and the report each one opens first"
export const LEDE =
"Every team starts somewhere different. These are the reports five customers open before their first meeting of the week."
/** What the slide's footer calls the report a team opens first. */
export const REPORT_LABEL = "Opens first"
/** The names of the carousel's three controls; the rotation button wears `stop` or `start`. */
export type CarouselControls = { previous: string; next: string; stop: string; start: string }
export const CONTROLS: CarouselControls = {
previous: "Previous quote",
next: "Next quote",
stop: "Stop rotating quotes",
start: "Start rotating quotes",
}
/** How long each quote stays before the next comes round, when rotation is on. */
export const ADVANCE_EVERY_MS = 8000
export const SLIDES: readonly Slide[] = [
{
quote:
"Our Monday meeting was an hour of people reading numbers aloud to each other. It takes twenty minutes now, because everyone has seen the numbers before they sit down.",
name: "Dara Whitfield",
role: "VP Finance",
company: "Lumen",
report: "Weekly revenue review",
},
{
quote:
"Every ticket shows the account's plan and what it pays us. We answer the accounts at risk first, and for the first time we can show that it worked.",
name: "Tomás Reyes",
role: "Support Operations Manager",
company: "Kestrel",
report: "Tickets by account value",
},
{
quote:
"Churn used to be a number we learned at the end of the quarter. Now it is a list of names on a Tuesday, while there is still time to call them.",
name: "Anneke de Vries",
role: "Head of Revenue Operations",
company: "Copperline",
report: "Accounts at risk",
},
{
quote:
"Refunds, credits and disputes are matched against billing before I log in. I review the exceptions instead of the rows.",
name: "Olu Adeyemi",
role: "Financial Controller",
company: "Fieldstone",
report: "Refund exceptions",
},
{
quote:
"We tie every launch to the usage it moved. The roadmap meeting argues about evidence now, and the evidence is on the screen.",
name: "Maja Nordin",
role: "Head of Product",
company: "Solstice",
report: "Feature adoption",
},
]npx shadcn@latest add @vibra/testimonials-03Quotes with results
testimonials-04Three customers in rows divided by rules, each led by the one figure they moved — where it stands now set large, the change and where it started — beside what they said about it.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { LogoMark } from "@/components/ui/logo-mark"
import { MetricDelta } from "@/components/ui/metric-delta"
import { EYEBROW, FROM, HEADLINE, LEDE, OUTCOMES, change, type Outcome } from "./testimonials-04.data"
/**
* One customer's result: on the left the figure they moved — what it
* measures, where it stands now set large, and the change from where it
* stood, in the kit's MetricDelta, which picks its tone from whether down is
* the win — and on the right what they said about it, who said it and where
* the rest of the story is.
*/
function OutcomeRow({ outcome }: { outcome: Outcome }) {
return (
<li className="grid gap-8 border-t border-rule py-10 last:pb-0 md:py-12 md:last:pb-0 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-4">
<LogoMark name={outcome.company} aria-hidden="true" className="text-muted-foreground" />
<dl className="mt-6">
<dt className="text-sm text-muted-foreground">{outcome.metric}</dt>
<dd className="type-numeral mt-2 text-4xl md:text-5xl">{outcome.after.text}</dd>
<dd className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<MetricDelta value={change(outcome)} positiveIsGood={!outcome.lowerIsBetter} variant="pill" size="sm" />
<span>
{FROM} <span className="tabular-nums">{outcome.before.text}</span>
</span>
</dd>
</dl>
</div>
<figure className="flex flex-col lg:col-span-8">
<blockquote className="text-lg/8 font-medium text-pretty md:text-xl/8">
<p>“{outcome.quote}”</p>
</blockquote>
<figcaption className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between md:mt-8">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={avatarFor(outcome.name)} alt="" />
<AvatarFallback>{getInitials(outcome.name)}</AvatarFallback>
</Avatar>
<div className="text-sm">
<p className="font-medium">{outcome.name}</p>
<p className="text-muted-foreground">
{outcome.role}, {outcome.company}
</p>
</div>
</div>
<Link
href={outcome.story.href}
className={cn(buttonVariants({ variant: "link" }), "group/story h-auto self-start px-0 sm:self-auto")}
>
{outcome.story.label}
<ArrowRightIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/story:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</figcaption>
</figure>
</li>
)
}
/**
* Quotes that carry their proof: three rows divided by rules, each leading
* with the one figure a customer set out to move — before and after, and the
* change between them — beside what they said once it had. The figure is a
* description list, so it is read as what it measures, then the value, then
* the change and where it started. A server component; every word and figure
* is in `./testimonials-04.data`.
*/
export function Testimonials04() {
const titleId = React.useId()
return (
<section
data-section="testimonials-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<ul className="mt-10 md:mt-12">
{OUTCOMES.map((outcome) => (
<OutcomeRow key={outcome.name} outcome={outcome} />
))}
</ul>
</div>
</section>
)
}
export default Testimonials04/**
* Every word and figure `testimonials-04` sets: three customers, the one
* figure each set out to move in its first quarter — where it stood before
* and where it stands now — and what they said once it moved. The change
* printed beside each figure is worked out from the two values by `change()`,
* so it can never disagree with them. The companies are the kit's fictional
* ones, drawn by `LogoMark`, and the people get the kit's faces; replace them
* with customers who agreed to be quoted, and with figures they measured.
*/
import type { LogoName } from "@/components/ui/logo-mark"
/** A figure as it reads and as it counts: `{ value: 8, text: "8 days" }`. */
export type Figure = { value: number; text: string }
export type Outcome = {
company: LogoName
/** What the figure measures, e.g. "Month-end close". */
metric: string
before: Figure
after: Figure
/** True where going down is the win — days to close, churn, hours spent. */
lowerIsBetter: boolean
quote: string
name: string
role: string
story: { label: string; href: string }
}
export const EYEBROW = "Results"
export const HEADLINE = "The one number each team set out to move"
export const LEDE =
"Three customers, the figure they chose in their first week, where it stood before Northwind and where it stood a quarter later."
/** The words between the change and the old figure: "−62.5% from 8 days". */
export const FROM = "from"
/** The change from `before` to `after` as a ratio: 8 days to 3 is −0.625. */
export function change(outcome: Outcome): number {
return (outcome.after.value - outcome.before.value) / outcome.before.value
}
export const OUTCOMES: readonly Outcome[] = [
{
company: "Northgate",
metric: "Month-end close",
before: { value: 8, text: "8 days" },
after: { value: 3, text: "3 days" },
lowerIsBetter: true,
quote:
"We set one goal for the first quarter: close the books in three days. We made it in the second month, and we have not missed it since.",
name: "Mira Castellanos",
role: "CFO",
story: { label: "Read Northgate's story", href: "/customers/northgate" },
},
{
company: "Brightwater",
metric: "Monthly revenue churn",
before: { value: 3.4, text: "3.4%" },
after: { value: 1.9, text: "1.9%" },
lowerIsBetter: true,
quote:
"The accounts that were about to leave had been telling us for weeks, in their tickets and in their usage. Northwind put both on one screen, and we called them in time.",
name: "Kwame Mensah",
role: "Head of Customer Success",
story: { label: "Read Brightwater's story", href: "/customers/brightwater" },
},
{
company: "Wavelength",
metric: "Hours a week on reporting",
before: { value: 14, text: "14 hours" },
after: { value: 2, text: "2 hours" },
lowerIsBetter: true,
quote:
"Fourteen hours a week went on stitching exports together. That time goes on the accounts now, and the report is better than the one we built by hand.",
name: "Rohan Mehta",
role: "Head of Revenue Operations",
story: { label: "Read Wavelength's story", href: "/customers/wavelength" },
},
]npx shadcn@latest add @vibra/testimonials-04Rating summary with reviews
testimonials-05The average set large with its stars and how every review spread across the five scores, beside four reviews that include a four and a three.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { formatDate, formatNumber, formatPercent, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { RankList } from "@/components/ui/rank-list"
import { Rating } from "@/components/ui/rating"
import {
ALL_REVIEWS,
DISTRIBUTION,
EYEBROW,
HEADLINE,
LEDE,
MAX_STARS,
REVIEWS,
starsLabel,
summary,
type Review,
} from "./testimonials-05.data"
/** One review: the stars and their figure beside the day it was written, the title the reviewer gave it, the words, and who wrote it. */
function ReviewCard({ review }: { review: Review }) {
return (
<figure className="flex flex-col panel p-6">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="flex items-center gap-2">
<Rating value={review.rating} max={MAX_STARS} readOnly size="sm" />
<span aria-hidden="true" className="text-xs font-medium text-muted-foreground tabular-nums">
{review.rating}.0
</span>
</div>
<time dateTime={review.date} className="text-xs text-muted-foreground tabular-nums">
{formatDate(review.date, "medium", { timeZone: "UTC" })}
</time>
</div>
<h3 className="mt-4 text-base font-semibold text-balance">{review.title}</h3>
<blockquote className="mt-2 text-sm/6 text-pretty text-muted-foreground">
<p>{review.body}</p>
</blockquote>
<figcaption className="mt-auto flex items-center gap-3 pt-6">
<Avatar>
<AvatarImage src={avatarFor(review.name)} alt="" />
<AvatarFallback className="text-xs">{getInitials(review.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-xs">
<p className="text-sm font-medium">{review.name}</p>
<p className="text-muted-foreground">
{review.role}, {review.company}
</p>
</div>
</figcaption>
</figure>
)
}
/**
* The score first, then the people behind it: a summary on the surface plane
* — the average set large with its stars, how many reviews it averages, and
* how those reviews spread across the five scores — beside four of the
* reviews, one of them a three.
*
* The average and the total are worked out from the spread in the data file,
* so the figure and the bars cannot disagree; the bars are the kit's
* RankList, each row a score and its share. Every star rating is drawn and
* also said: each is one image named in words ("4 out of 5 stars"), and the
* figure beside it repeats it for the eye. A server component; every word and
* figure is in `./testimonials-05.data`.
*/
export function Testimonials05() {
const titleId = React.useId()
const { total, average } = summary()
const totalText = formatNumber(total)
return (
<section
data-section="testimonials-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="mt-10 grid gap-6 md:mt-12 lg:grid-cols-12 lg:gap-8">
<div className="flex flex-col frame p-6 md:p-8 lg:col-span-4 lg:self-start">
<p className="flex items-baseline gap-2">
<span className="type-numeral text-6xl">{average}</span>
<span className="text-sm text-muted-foreground">out of {MAX_STARS}</span>
</p>
<div className="mt-3">
{/* The average sits between two whole stars: four and most of a fifth, one image named with the score. */}
<Rating value={average} max={MAX_STARS} readOnly fractional />
</div>
<p className="mt-2 text-sm text-muted-foreground">
From <span className="font-medium text-foreground tabular-nums">{totalText}</span> reviews
</p>
<RankList
className="mt-6 border-t border-border pt-6"
showRank={false}
size="sm"
max={total}
format={(count) => formatPercent(count / total, { maximumFractionDigits: 1 })}
items={DISTRIBUTION.map((row) => ({ label: starsLabel(row.stars), value: row.count }))}
/>
<Link
href={ALL_REVIEWS.href}
className={cn(buttonVariants({ variant: "link" }), "group/all mt-6 h-auto self-start px-0")}
>
{ALL_REVIEWS.label(totalText)}
<ArrowRightIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/all:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-8 lg:gap-6">
{REVIEWS.map((review) => (
<ReviewCard key={review.name} review={review} />
))}
</div>
</div>
</div>
</section>
)
}
export default Testimonials05/**
* Every word and figure `testimonials-05` sets: how many reviews gave each
* score, and four of the reviews themselves — two of them short of five
* stars, because a page that only shows the fives is not believed. The
* average and the total are worked out from the counts by `summary()`, so
* the number at the top can never disagree with the bars under it. The
* companies are the kit's fictional ones and the people get the kit's faces;
* replace every figure with your own reviews, and every review with one a
* customer wrote.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type Review = {
/** Whole stars, 1 to 5. */
rating: number
title: string
body: string
name: string
role: string
company: LogoName
/** The day it was written, as an ISO date, on or before the sample world's "now" (September 4, 2026). */
date: string
}
export const EYEBROW = "Reviews"
export const HEADLINE = "What teams say thirty days in"
export const LEDE =
"We ask every paying workspace for a review thirty days after its first report, and we publish the low scores with the high ones."
/** The top of the scale the reviews are given on. */
export const MAX_STARS = 5
/** How many reviews gave each score, highest first. */
export const DISTRIBUTION: readonly { stars: number; count: number }[] = [
{ stars: 5, count: 1042 },
{ stars: 4, count: 149 },
{ stars: 3, count: 31 },
{ stars: 2, count: 12 },
{ stars: 1, count: 6 },
]
/** The total and the average of `DISTRIBUTION`, the average to one decimal as the page prints it. */
export function summary(distribution: readonly { stars: number; count: number }[] = DISTRIBUTION) {
const total = distribution.reduce((sum, row) => sum + row.count, 0)
const points = distribution.reduce((sum, row) => sum + row.stars * row.count, 0)
return { total, average: total === 0 ? 0 : Math.round((points / total) * 10) / 10 }
}
/** A row of the spread: "5 stars", "1 star". */
export function starsLabel(stars: number): string {
return `${stars} ${stars === 1 ? "star" : "stars"}`
}
/** Where every review is published. */
export const ALL_REVIEWS = { href: "/customers/reviews", label: (total: string) => `Read all ${total} reviews` }
export const REVIEWS: readonly Review[] = [
{
rating: 5,
title: "One version of the numbers at month end",
body: "Billing, refunds and usage reconcile overnight, and every figure in the board pack links back to the rows behind it. The auditors spent two days with us this year instead of five.",
name: "Ingrid Haugen",
role: "Finance Director",
company: "Meridian",
date: "2026-08-28",
},
{
rating: 5,
title: "Connected before lunch",
body: "We connected billing, the CRM and the help desk in a morning. The first cohort report was waiting when we came back, and it matched the spreadsheet we had spent a year maintaining.",
name: "Marcus Oyelaran",
role: "Operations Lead",
company: "Harbour",
date: "2026-08-19",
},
{
rating: 4,
title: "Excellent reports, slow custom fields",
body: "The standard reports cover almost everything we need. Custom fields from our CRM took a support ticket and two days to appear, which is the only reason this is not five stars.",
name: "Julia Brenner",
role: "Revenue Operations Manager",
company: "Copperline",
date: "2026-08-12",
},
{
rating: 3,
title: "Forecasts want more history than we had",
body: "Dashboards and alerts are very good. Forecasts need about a year of history to be useful and we had seven months, so for now we read them as a rough guide. Support told us so before we paid.",
name: "Sam Achterberg",
role: "Founder",
company: "Alder",
date: "2026-07-30",
},
]npx shadcn@latest add @vibra/testimonials-05Quote per logo
testimonials-06A row of customer marks that are tabs: choose a company and its team's quote comes up under the row, with the person who said it and two facts about the account.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { LogoMark } from "@/components/ui/logo-mark"
import { LogoTabs } from "./testimonials-06.client"
import { CUSTOMERS, EYEBROW, HEADLINE, LEDE, TABS_LABEL, type Customer } from "./testimonials-06.data"
/**
* What one company said: the quote set large with the person under it, and
* beside it two facts about the account and the way to the whole story.
*/
function CustomerPanel({ customer }: { customer: Customer }) {
return (
<div className="grid gap-8 lg:grid-cols-12 lg:gap-16">
<figure className="lg:col-span-8">
<blockquote className="text-xl/8 font-medium text-pretty md:text-2xl/9 md:tracking-heading">
<p>“{customer.quote}”</p>
</blockquote>
<figcaption className="mt-8 flex items-center gap-3">
<Avatar className="size-11">
<AvatarImage src={avatarFor(customer.name)} alt="" />
<AvatarFallback>{getInitials(customer.name)}</AvatarFallback>
</Avatar>
<div className="text-sm">
<p className="font-medium">{customer.name}</p>
<p className="text-muted-foreground">
{customer.role}, {customer.company}
</p>
</div>
</figcaption>
</figure>
<div className="flex flex-col gap-6 border-t border-rule pt-6 lg:col-span-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-10">
<dl className="grid grid-cols-2 gap-6">
{customer.facts.map((fact) => (
<div key={fact.label} className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">{fact.label}</dt>
<dd className="type-numeral text-2xl">{fact.value}</dd>
</div>
))}
</dl>
<Link
href={customer.story.href}
className={cn(buttonVariants({ variant: "link" }), "group/story h-auto self-start px-0")}
>
{customer.story.label}
<ArrowRightIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/story:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
</div>
)
}
/**
* Customers as a row of marks, each one a way to hear from that company:
* choose a mark and its team's quote comes up under the row, with the person
* who said it and two facts about the account. The marks are the kit's
* LogoMark, so the row doubles as the logo cloud a landing page wants, and
* each quote is one choice away instead of one scroll.
*
* The marks and the panels are drawn here and handed to the island
* (`./testimonials-06.client`), which makes them tabs. Every word is in
* `./testimonials-06.data`.
*/
export function Testimonials06() {
const titleId = React.useId()
const items = CUSTOMERS.map((customer) => ({
value: customer.company.toLowerCase(),
label: <LogoMark name={customer.company} />,
panel: <CustomerPanel customer={customer} />,
}))
return (
<section
data-section="testimonials-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-2xl text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<LogoTabs items={items} label={TABS_LABEL} />
</div>
</div>
</section>
)
}
export default Testimonials06"use client"
import * as React from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
/** One customer as the island receives it: its tab's face (the mark) and its panel, both drawn by the server file. */
export type LogoTab = { value: string; label: React.ReactNode; panel: React.ReactNode }
/**
* The row of marks is a tab list, because that is what it is: one choice
* drives one region. The arrow keys move between companies and bring each
* quote up as they land, Home and End reach the ends, and a reader hears
* which company is selected and which panel it controls. Each tab is named by
* its mark, which is an image named by the company.
*
* The chosen mark sits on the accent's tint, moves from the muted ink to the
* ink and carries the accent's rail under it — three cues, and never a
* stroke. Everything arrives drawn, as plain props, from the section.
*/
export function LogoTabs({ items, label }: { items: readonly LogoTab[]; label: string }) {
return (
<Tabs defaultValue={items[0]?.value} className="gap-0">
<TabsList
variant="line"
aria-label={label}
activateOnFocus
className="grid w-full grid-cols-2 gap-2 p-0 group-data-horizontal/tabs:h-auto sm:grid-cols-3 lg:grid-cols-6"
>
{items.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
className="h-16 rounded-lg px-3 text-muted-foreground ring-1 ring-border after:rounded-full hover:bg-muted/50 hover:text-foreground data-active:bg-brand-muted data-active:text-foreground data-active:ring-transparent data-active:hover:bg-brand-muted group-data-horizontal/tabs:after:inset-x-6 group-data-horizontal/tabs:after:bottom-0"
>
{item.label}
</TabsTrigger>
))}
</TabsList>
{items.map((item) => (
<TabsContent key={item.value} value={item.value} className="mt-8 rounded-xl focus-ring md:mt-12">
{item.panel}
</TabsContent>
))}
</Tabs>
)
}/**
* Every word `testimonials-06` sets: six customers, each behind its mark —
* what its team said, who said it, two facts about the account, and where
* the rest of the story is. The companies are the kit's fictional ones,
* drawn by `LogoMark`, and the people get the kit's faces; replace them with
* customers who agreed to be quoted.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type Customer = {
company: LogoName
quote: string
name: string
role: string
/** Two short facts about the account, each a label and its value. */
facts: readonly { label: string; value: string }[]
story: { label: string; href: string }
}
export const EYEBROW = "Customers"
export const HEADLINE = "Six companies, in their own words"
export const LEDE = "Choose a company to read what its team says about running on Northwind."
/** Names the row of marks, which is a set of tabs. */
export const TABS_LABEL = "Choose a customer"
export const CUSTOMERS: readonly Customer[] = [
{
company: "Keystone",
quote:
"Our forecast used to live in a model only two people could open. Now the whole leadership team reads the same projection, with the assumptions written underneath it.",
name: "Leonie Fischer",
role: "Head of FP&A",
facts: [
{ label: "Customer since", value: "2022" },
{ label: "Seats", value: "64" },
],
story: { label: "Read Keystone's story", href: "/customers/keystone" },
},
{
company: "Meridian",
quote:
"We run eleven warehouses. Each one's refunds, delays and margin sit side by side every morning, and the site leads fix their own numbers before I ask.",
name: "Hamza Qureshi",
role: "VP Operations",
facts: [
{ label: "Customer since", value: "2023" },
{ label: "Seats", value: "38" },
],
story: { label: "Read Meridian's story", href: "/customers/meridian" },
},
{
company: "Lumen",
quote:
"The switch took a fortnight. The reporting stack it replaced cost us more each month than a year of Northwind does.",
name: "Carys Llewellyn",
role: "Head of Finance",
facts: [
{ label: "Customer since", value: "2024" },
{ label: "Seats", value: "12" },
],
story: { label: "Read Lumen's story", href: "/customers/lumen" },
},
{
company: "Harbour",
quote:
"Every renewal call starts from the account's usage over the last ninety days. The customer can tell we did the reading before we dialled.",
name: "Diego Alvarez",
role: "Head of Customer Success",
facts: [
{ label: "Customer since", value: "2023" },
{ label: "Seats", value: "27" },
],
story: { label: "Read Harbour's story", href: "/customers/harbour" },
},
{
company: "Riverside",
quote:
"My team stopped maintaining pipelines and started answering questions. That was the reason we bought it, and it happened in the first month.",
name: "Nadia Rahman",
role: "Data Lead",
facts: [
{ label: "Customer since", value: "2025" },
{ label: "Seats", value: "19" },
],
story: { label: "Read Riverside's story", href: "/customers/riverside" },
},
{
company: "Ironbark",
quote:
"The board asked for revenue by cohort, by region and by plan in the same meeting. It was three filters, not three weeks of work.",
name: "Pieter van Dijk",
role: "CFO",
facts: [
{ label: "Customer since", value: "2022" },
{ label: "Seats", value: "85" },
],
story: { label: "Read Ironbark's story", href: "/customers/ironbark" },
},
]npx shadcn@latest add @vibra/testimonials-06Case study cards
testimonials-07Three case studies as framed cards told in words and figures, not video: the company, the headline, two results and a line from the person who led the change, each card one link.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { LogoMark } from "@/components/ui/logo-mark"
import { ALL_STORIES, CASE_STUDIES, EYEBROW, HEADLINE, LEDE, readingTime, type CaseStudy } from "./testimonials-07.data"
/**
* One case study as a framed card: the company's mark and what kind of
* company it is on the strip; the headline, the two results and one line
* from the person who led the change on the sheet; the reading time on the
* footer strip. The whole card is one link, named by the company and the
* headline — one stop in the tab order — and the focus ring is drawn on the
* frame, where the rounded clip cannot cut it off.
*/
function CaseStudyCard({ study }: { study: CaseStudy }) {
return (
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardHeader>
<LogoMark name={study.company} size="sm" />
<CardAction className="text-xs text-muted-foreground">{study.sector}</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5 md:p-6">
<h3 className="text-lg font-semibold text-balance">
<Link href={study.href} className="outline-none after:absolute after:inset-0">
{/* The space is a text node of its own: inside the hidden span it
would be trimmed, and the name would run "Copperline:Closing". */}
<span className="sr-only">{study.company}:</span> {study.title}
</Link>
</h3>
<dl className="grid grid-cols-2 gap-4">
{study.results.map((result) => (
// Figure over label to the eye, label then figure to a screen
// reader; both figures sit on the top line whatever their labels wrap to.
<div key={result.label} className="flex flex-col gap-1">
<dt className="text-xs text-balance text-muted-foreground">{result.label}</dt>
<dd className="type-numeral order-first text-2xl">{result.value}</dd>
</div>
))}
</dl>
<figure className="mt-auto border-t border-border pt-5">
<blockquote className="text-sm/6 text-pretty">
<p>“{study.quote}”</p>
</blockquote>
<figcaption className="mt-4 flex items-center gap-2.5">
<Avatar>
<AvatarImage src={avatarFor(study.name)} alt="" />
<AvatarFallback className="text-xs">{getInitials(study.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-xs">
<p className="text-sm font-medium">{study.name}</p>
<p className="text-muted-foreground">
{study.role}, {study.company}
</p>
</div>
</figcaption>
</figure>
</CardContent>
<CardFooter className="justify-between text-xs text-muted-foreground">
<span className="tabular-nums">{readingTime(study.minutes)}</span>
<ArrowRightIcon
aria-hidden="true"
className="size-4 text-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/card:translate-x-0.5 motion-reduce:transition-none"
/>
</CardFooter>
</Card>
)
}
/**
* Case studies as a row of cards, told with words and figures instead of a
* video thumbnail: each card leads with the company, then the headline, the
* two results the story is about and one line from the person who led the
* change, with how long the story takes to read. Three up from `lg`, one
* above the other below it; the cards share their row's height, so their
* quotes and footers line up.
*
* A server component; every word and figure is in `./testimonials-07.data`.
*/
export function Testimonials07() {
const titleId = React.useId()
return (
<section
data-section="testimonials-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between md:gap-12">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<Link
href={ALL_STORIES.href}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "group/all h-10 self-start px-4 md:self-auto")}
>
{ALL_STORIES.label}
<ArrowRightIcon
data-icon="inline-end"
aria-hidden="true"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/all:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
<ul className="mt-10 grid gap-4 md:mt-12 lg:grid-cols-3 lg:gap-6">
{CASE_STUDIES.map((study) => (
<li key={study.company} className="flex">
<CaseStudyCard study={study} />
</li>
))}
</ul>
</div>
</section>
)
}
export default Testimonials07/**
* Every word and figure `testimonials-07` sets: three case studies as their
* cards show them — the company and what kind of company it is, the headline,
* the two results the story is about, one line from the person who tells it,
* and how long the whole story takes to read. The companies are the kit's
* fictional ones, drawn by `LogoMark`, and the people get the kit's faces;
* replace them with stories your customers agreed to tell.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type CaseStudy = {
company: LogoName
/** What kind of company, and how big: the strip's meta line. */
sector: string
title: string
/** One line from the person who tells the story. */
quote: string
/** The two results the story is about, figure first. */
results: readonly { value: string; label: string }[]
name: string
role: string
/** Minutes to read the whole story. */
minutes: number
href: string
}
export const EYEBROW = "Case studies"
export const HEADLINE = "How three teams rebuilt their reporting"
export const LEDE =
"A manufacturer with nine entities, a software company losing its largest accounts and an eleven-person startup, each told by the person who led the change."
export const ALL_STORIES = { label: "All customer stories", href: "/customers" }
/** The reading time as the card's footer prints it. */
export function readingTime(minutes: number): string {
return `${minutes} min read`
}
export const CASE_STUDIES: readonly CaseStudy[] = [
{
company: "Copperline",
sector: "Manufacturing · 1,200 staff",
title: "Closing nine entities in four days",
quote: "Nine subsidiaries, three currencies and six of us. We stopped consolidating by hand in the first month.",
results: [
{ value: "4 days", label: "to close, down from 11" },
{ value: "9", label: "entities in one report" },
],
name: "Helena Sato",
role: "Group Financial Controller",
minutes: 7,
href: "/customers/copperline",
},
{
company: "Kestrel",
sector: "Travel software · 340 staff",
title: "Calling the accounts before they cancel",
quote: "Once tickets sat beside what each account pays and how much it uses us, we knew who to call first.",
results: [
{ value: "31%", label: "fewer cancellations" },
{ value: "2 of 5", label: "largest renewals saved" },
],
name: "Camille Laurent",
role: "Head of Support",
minutes: 5,
href: "/customers/kestrel",
},
{
company: "Alder",
sector: "Software · 11 staff",
title: "Board reporting without an analyst",
quote: "The board pack, the investor update and our weekly numbers all come out of one place, and none of us writes reports full time.",
results: [
{ value: "20 min", label: "to prepare the board update" },
{ value: "0", label: "analysts hired to do it" },
],
name: "Ben Okonkwo",
role: "Founder",
minutes: 4,
href: "/customers/alder",
},
]npx shadcn@latest add @vibra/testimonials-07Posts from the community
testimonials-08Three posts drawn the way a feed draws them — the author, the words with their mentions, the day and the counts — with no network's name or mark anywhere.
import * as React from "react"
import { HeartIcon, MessageCircleIcon, Repeat2Icon, type LucideIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { formatDate, formatNumber, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { COUNT_NOUNS, EYEBROW, HEADLINE, LEDE, POSTS, type Post } from "./testimonials-08.data"
/** A post's words with each @mention set apart, the way a feed shows them. */
function PostText({ text }: { text: string }) {
return text.split(/(@[\w.]+\w)/).map((part, index) =>
part.startsWith("@") ? (
<span key={index} className="font-medium text-foreground">
{part}
</span>
) : (
<React.Fragment key={index}>{part}</React.Fragment>
)
)
}
const COUNTS: { key: keyof typeof COUNT_NOUNS; icon: LucideIcon }[] = [
{ key: "replies", icon: MessageCircleIcon },
{ key: "reposts", icon: Repeat2Icon },
{ key: "likes", icon: HeartIcon },
]
/**
* One post as a feed draws it: the author's face, name, handle and what they
* do, the day, the words, and the replies, reposts and likes — each count
* read with its noun, and each icon a plain lucide glyph rather than any
* network's mark. The post is a `figure`: the words a `blockquote`, the
* author its caption, first as a feed puts it.
*/
function PostCard({ post }: { post: Post }) {
return (
<figure className="flex w-full flex-col panel p-5 md:p-6">
<figcaption className="flex items-start gap-3">
<Avatar size="lg">
<AvatarImage src={avatarFor(post.name)} alt="" />
<AvatarFallback>{getInitials(post.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 text-sm">
<p className="flex flex-wrap items-baseline justify-between gap-x-3">
<span className="font-medium">{post.name}</span>
<time dateTime={post.date} className="text-xs text-muted-foreground tabular-nums">
{formatDate(post.date, "medium", { timeZone: "UTC" })}
</time>
</p>
<p className="text-pretty text-muted-foreground">
{post.handle} · {post.role}
</p>
</div>
</figcaption>
<blockquote className="mt-4 text-base/7 text-pretty">
<p>
<PostText text={post.text} />
</p>
</blockquote>
<p className="mt-auto flex gap-5 pt-5 text-sm text-muted-foreground">
{COUNTS.map(({ key, icon: Icon }) => {
const count = post[key]
const [one, many] = COUNT_NOUNS[key]
return (
<span key={key} className="inline-flex items-center gap-1.5">
<Icon aria-hidden="true" className="size-4" />
<span className="tabular-nums">{formatNumber(count)}</span>
<span className="sr-only">{count === 1 ? one : many}</span>
</span>
)
})}
</p>
</figure>
)
}
/**
* What people post: three posts in a row, each drawn the way a feed draws
* one — the author on top, then the words, then the counts — without a
* network's name or mark anywhere, so the band never borrows a brand it has
* no licence to show. Three up from `lg`, one above the other below it, the
* way a feed stacks them.
*
* A server component; every word and count is in `./testimonials-08.data`.
*/
export function Testimonials08() {
const titleId = React.useId()
return (
<section
data-section="testimonials-08"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<ul className="mt-10 grid gap-4 md:mt-12 lg:grid-cols-3 lg:gap-6">
{POSTS.map((post) => (
<li key={post.handle} className="flex">
<PostCard post={post} />
</li>
))}
</ul>
</div>
</section>
)
}
export default Testimonials08/**
* Every word and figure `testimonials-08` sets: three posts people wrote
* about Northwind on their own feeds, as the posts read — who wrote them,
* their handle and what they do, the words with their mentions, the day and
* the replies, reposts and likes. No network is named and no network's mark
* is drawn: a post is a person's words, wherever they wrote them. The people
* and their handles are fictional; replace them with posts whose authors
* agreed to be shown, with the counts as they stood when you asked.
*/
export type Post = {
name: string
/** Their handle, with the @. */
handle: string
/** What they do and where, so a reader knows why to listen. */
role: string
/** The post as written; an @name in it is a mention. */
text: string
/** The day it was posted, as an ISO date, on or before the sample world's "now" (September 4, 2026). */
date: string
replies: number
reposts: number
likes: number
}
export const EYEBROW = "From the community"
export const HEADLINE = "What people post after their first month"
export const LEDE =
"Unprompted posts from people who use Northwind at work. We asked each of them before putting their words on this page."
/** The nouns read after each count, so "318" is heard as "318 likes". */
export const COUNT_NOUNS = {
replies: ["reply", "replies"],
reposts: ["repost", "reposts"],
likes: ["like", "likes"],
} as const
export const POSTS: readonly Post[] = [
{
name: "Femi Adebayo",
handle: "@femi.builds",
role: "Controller at Riverside",
text: "Closed August in 3 days. Last year it took 8. The only change was moving reconciliation into @northwind and deleting a 40-tab spreadsheet I was frankly scared of.",
date: "2026-09-03",
replies: 14,
reposts: 22,
likes: 318,
},
{
name: "Clara Nieminen",
handle: "@claranieminen",
role: "Head of Growth at Lumen",
text: "Asked for churn by cohort and plan at 9:40. Had it at 9:41, because I built it myself. Did not expect @northwind to make me this independent of our data team, who are also relieved.",
date: "2026-08-30",
replies: 9,
reposts: 11,
likes: 204,
},
{
name: "Joaquín Ortega",
handle: "@jortega_ops",
role: "COO at Fieldstone",
text: "Our weekly review used to open with twenty minutes of which number is right. This week it opened with the decision. Small change, better Monday.",
date: "2026-08-26",
replies: 6,
reposts: 4,
likes: 127,
},
]npx shadcn@latest add @vibra/testimonials-08