Skip to content

Collection 01 / Interactive

Testimonial Deck.

A quiet, manually controlled quote deck. Give the people behind your work a voice.

Live playground

Accent

Kind words

Good work. Better company.

The rare kind of collaborator who makes the idea better, then makes it happen.

Jamie Chen

Founder, Forma · Example testimonial

1 / 3
import { TestimonialDeck } from "@/components/gear5/testimonial-deck";

export default function Example() {
  return <TestimonialDeck accent={"#d9fc87"} />;
}

Make it yours.

npx shadcn@latest add https://gear5-ui.vercel.app/r/testimonial-deck.json

Copies editable source into your project. Setup guide · React and Tailwind CSS 4 required.

Put it to work.

import { TestimonialDeck } from "@/components/gear5/testimonial-deck";
export default function Testimonials() {
  return <TestimonialDeck testimonials={[
    { id: "one", quote: "Replace this with a real, permitted quote.",
      name: "Your client", role: "Their role, Company" },
  ]} />;
}

Good to know.

  • Previous and next wrap around. Navigation stays manual; there is no autoplay.
  • The active quote is announced without moving keyboard focus.
  • Default quotes are labelled examples. Replace them with real quotes you have permission to publish.

Source & props.

Installer-ready imports. For manual installation, copy this file into components/gear5/testimonial-deck.tsx and add the helpers below at their shown paths.

"use client";

import { useState, type CSSProperties } from "react";

export interface DeckTestimonial {
  id: string;
  quote: string;
  name: string;
  role: string;
}
export const deckTestimonials: DeckTestimonial[] = [
  {
    id: "a",
    quote:
      "The rare kind of collaborator who makes the idea better, then makes it happen.",
    name: "Jamie Chen",
    role: "Founder, Forma · Example testimonial",
  },
  {
    id: "b",
    quote:
      "Every interaction feels considered. Our product finally looks as good as it works.",
    name: "Morgan Lee",
    role: "Product lead, Mono · Example testimonial",
  },
  {
    id: "c",
    quote:
      "From the first conversation to the last detail, the whole process was a joy.",
    name: "Sam Rivera",
    role: "Editor, Terrain · Example testimonial",
  },
];
export interface TestimonialDeckProps {
  testimonials?: DeckTestimonial[];
  title?: string;
  eyebrow?: string;
  previousLabel?: string;
  nextLabel?: string;
  positionLabel?: (current: number, total: number) => string;
  accent?: string;
  className?: string;
}

/** Manual navigation; nothing auto-plays or moves focus when the quote changes. */
export function TestimonialDeck({
  testimonials = deckTestimonials,
  title = "Good work. Better company.",
  eyebrow = "Kind words",
  previousLabel = "Previous testimonial",
  nextLabel = "Next testimonial",
  positionLabel = (current, total) => `${current} / ${total}`,
  accent = "#d9fc87",
  className = "",
}: TestimonialDeckProps) {
  const [selected, setSelected] = useState(0);
  const active = Math.min(selected, Math.max(0, testimonials.length - 1));
  const item = testimonials[active];
  return (
    <section
      style={{ "--g5-accent": accent } as CSSProperties}
      className={`rounded-3xl bg-[#111214] p-6 text-[#f4f4ef] sm:p-10 ${className}`}
    >
      <p className="font-mono text-[11px] tracking-[.12em] text-[#b4b6af] uppercase">
        {eyebrow}
      </p>
      <h2 className="mt-3 text-3xl font-medium tracking-[-.05em] sm:text-4xl">
        {title}
      </h2>
      {item && (
        <>
          <div
            className="relative mt-8 min-h-64 rounded-2xl border border-white/15 bg-[#181a1c] p-6 transition-transform duration-300 motion-safe:hover:-translate-y-1 motion-reduce:transition-none sm:p-8"
            aria-live="polite"
            aria-atomic="true"
          >
            <span
              aria-hidden="true"
              className="font-serif text-6xl leading-none text-[var(--g5-accent)]"
            >
              “
            </span>
            <blockquote className="mt-2 max-w-2xl text-xl leading-relaxed tracking-[-.02em] sm:text-2xl">
              {item.quote}
            </blockquote>
            <div className="mt-7 flex items-center gap-3">
              <span
                aria-hidden="true"
                className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[var(--g5-accent)] text-sm font-medium text-[#111214]"
              >
                {item.name.slice(0, 1)}
              </span>
              <div>
                <p className="text-sm font-medium">{item.name}</p>
                <p className="mt-1 text-xs text-[#b4b6af]">{item.role}</p>
              </div>
            </div>
          </div>
          <div className="mt-5 flex items-center justify-between">
            <span className="font-mono text-xs text-[#b4b6af]">
              {positionLabel(active + 1, testimonials.length)}
            </span>
            <div className="flex gap-2">
              <button
                type="button"
                aria-label={previousLabel}
                disabled={testimonials.length < 2}
                onClick={() =>
                  setSelected(
                    (active - 1 + testimonials.length) % testimonials.length,
                  )
                }
                className="flex size-11 items-center justify-center rounded-full border border-white/20 hover:bg-white/10 disabled:opacity-40"
              >
                ←
              </button>
              <button
                type="button"
                aria-label={nextLabel}
                disabled={testimonials.length < 2}
                onClick={() => setSelected((active + 1) % testimonials.length)}
                className="flex size-11 items-center justify-center rounded-full border border-white/20 hover:bg-white/10 disabled:opacity-40"
              >
                →
              </button>
            </div>
          </div>
        </>
      )}
    </section>
  );
}

VERIFIED: AXE FIXTURES / SERVER RENDER / SOURCE SCANS / 3300 B GZIP CEILING
Test your own content and screen-reader flows before shipping.