Skip to content
Motion
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-honest

Controls

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/testimonials-honest
  3. 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 files
components/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.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
itemsQuoteslistup to 6—
items[].quoteQuotefact280 charsYes, a fill-in until given
items[].nameNamefact48 charsYes, a fill-in until given
items[].detailDetailtext48 charsCan 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.
  • Three customer quotes as chat bubbles beside a heading

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

    Testimonials block · Server

↑ ↓ to move↵ to openesc to close