FreeIn reviewv1.0.0
Honest Testimonials
Real quotes only, as cards, a featured quote or a spotlight; designed fill-ins until the client sends them.
npx shadcn@latest add @vibebox/testimonials-honestControls
Variant
<TestimonialsHonest />About
Rebuilds the site kit's review slots, which already refused to invent reviews. The block keeps the rule: quotes, names and details come from the client.
Use it when
- The business has, or will collect, real customer words.
Skip it when
- There are no quotes and none are coming: leave the section out rather than ship fill-ins.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/testimonials-honest - 3Use it:
import { TestimonialsHonest } from "@/components/vibebox/testimonials-honest"; import { home } from "@/content/pages/home"; <TestimonialsHonest content={home.testimonials} variant="cards" id="reviews" />
Code
2 filescomponents/vibebox/testimonials-honest/index.tsx
/**
* VibeBox Motion · Honest Testimonials
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { Container, headingId, initials, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { isFillIn } from "@/lib/vibebox/content/runtime";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* Real words from real customers, and nothing else. Quotes and names are facts: until the client
* sends them, each one is a designed fill-in, so a new site never shows an invented review.
*/
type Item = Content["items"][number];
function QuoteMark({ className }: { className?: string }) {
return (
<svg aria-hidden="true" viewBox="0 0 40 32" className={cn("w-8 fill-[color-mix(in_oklab,var(--spectrum-3)_55%,var(--foreground))] opacity-80", className)}>
<path d="M0 32V18C0 7 6 1 16 0v6c-5 2-8 6-8 12h8v14Zm24 0V18c0-11 6-17 16-18v6c-5 2-8 6-8 12h8v14Z" />
</svg>
);
}
function Quote({ item, index, size }: { item: Item; index: number; size: "lg" | "md" | "sm" }) {
return (
<figure className={cn("m-0 flex h-full flex-col gap-5 rounded-xl bg-card p-6 text-card-foreground ring-1 ring-border sm:p-7", size === "lg" && "gap-7 sm:p-10", REVEAL)}>
<QuoteMark className={size === "lg" ? "w-10" : undefined} />
<SlotText
as="blockquote"
value={item.quote}
path={`items.${index}.quote`}
className={cn("m-0 flex-1 text-pretty", size === "lg" ? "font-display text-[clamp(1.35rem,1rem+1.2vw,2rem)] leading-snug font-medium tracking-[-0.015em]" : "text-[1.05rem] leading-relaxed")}
/>
<figcaption className="flex min-w-0 items-center gap-3">
<span aria-hidden="true" className="inline-grid size-10 shrink-0 place-items-center rounded-full bg-muted text-small font-semibold text-muted-foreground">
{isFillIn(item.name) ? "" : initials(item.name)}
</span>
<span className="grid min-w-0 gap-0.5">
<SlotText value={item.name} path={`items.${index}.name`} className="font-semibold" />
<SlotText value={item.detail} path={`items.${index}.detail`} className="text-small text-muted-foreground" />
</span>
</figcaption>
</figure>
);
}
export type TestimonialsHonestProps = BlockProps<Content, Variant>;
export function TestimonialsHonest({ content, variant = "cards", id, tone, className }: TestimonialsHonestProps) {
const [first, ...rest] = content.items;
if (!first) return null;
return (
<Section slug="testimonials-honest" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
<Container className="grid gap-[clamp(32px,4vw,56px)]">
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} align={variant === "spotlight" ? "center" : "start"} />
{variant === "featured" && rest.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<Quote item={first} index={0} size="lg" />
<ul className="grid gap-4">
{rest.map((item, index) => (
<li key={index}>
<Quote item={item} index={index + 1} size="sm" />
</li>
))}
</ul>
</div>
) : variant === "spotlight" ? (
<div className="grid gap-4">
<div className="mx-auto w-full max-w-4xl">
<Quote item={first} index={0} size="lg" />
</div>
{rest.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{rest.map((item, index) => (
<li key={index}>
<Quote item={item} index={index + 1} size="sm" />
</li>
))}
</ul>
)}
</div>
) : (
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{content.items.map((item, index) => (
<li key={index}>
<Quote item={item} index={index} size="md" />
</li>
))}
</ul>
)}
</Container>
</Section>
);
}components/vibebox/testimonials-honest/schema.ts
/**
* VibeBox Motion · Honest Testimonials
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { sections, type z } from "@/lib/vibebox/content";
export const variants = ["cards", "featured", "spotlight"] as const;
export type Variant = (typeof variants)[number];
export const content = sections.testimonials;
export type Content = z.infer<typeof content>;Content
Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| lead | Intro | text | 160 chars | — |
| items | Quotes | list | up to 6 | — |
| items[].quote | Quote | fact | 280 chars | Yes, a fill-in until given |
| items[].name | Name | fact | 48 chars | Yes, a fill-in until given |
| items[].detail | Detail | text | 48 chars | Can be a fill-in |
Variants
Cardsdefault
cards
Up to three cards across.
Featured
featured
The first quote large on the left, the rest stacked right.
Spotlight
spotlight
The first quote large and centred, the rest in a row beneath.
Accessibility and motion
- Keyboard
- Nothing to focus.
- Screen readers
- Each quote is a figure with a blockquote and a caption naming the person.
- Touch
- Nothing to touch.
- Reduced motion
- Quotes appear without their reveal.
Edge cases and limits
- Six 280-character quotes make tall cards; the layout holds.
- A name still a fill-in shows an empty disc, never invented initials.
Related

Customers' real words as the messages they sent: chat bubbles in a thread, or a wall of them.
Testimonials block · Server