The customer testimonial section on the homepage (WhyChooseMatters) renders through a single
component, TestimonialCarousel , which supports two looks selected
by a prop:
variant="carousel" (default) — the original single-slide, auto-advancing quote carousel.
Unchanged; absent the prop, behavior is byte-for-byte identical to before.variant="video" — a horizontally-scrolling row of portrait poster cards. Cards backed
by a video show a subtle play affordance; clicking one opens a centered ~16:9 player modal.
Playback works with both Mux and YouTube from the same data field.// components/homepage/WhyChooseMatters.tsx
<TestimonialCarousel data={testimonials} variant="video" autoPlayInterval={8000} />
interface TestimonialCarouselProps {
data: Testimonial[];
autoPlayInterval?: number; // carousel variant only
variant?: "carousel" | "video"; // default "carousel"
backlight?: boolean; // default true — glow behind the modal player
}
A testimonial declares its video via an optional video field on the shared Testimonial
type (lib/testimonials.ts). Three authoring shapes are accepted:
export type TestimonialVideo =
// 1) bare string — auto-detected: YouTube URL, Mux URL, "mux:<id>", or a bare playback id
| string
// 2) single explicit source — `id` wins, else `url` is parsed
| { provider: "mux" | "youtube"; id?: string; url?: string }
// 3) dual source — both providers; `prefer` selects (default "mux"), with fallback
| { mux?: string; youtube?: string; prefer?: "mux" | "youtube" };
Testimonials without a video still appear on the stage (as a non-video cutout that can be
brought to the active slot) but show no play button and open no modal.
Each testimonial also has an optional cutout field — a transparent head-to-chest PNG used
by the stage row, falling back to image when absent.
Each of the three video testimonials carries both a Mux playback id and a YouTube URL, so
both playback paths are exercised. Mux is preferred by default (custom controls, no cookie
banner, the backlight reads better); set prefer: "youtube" to flip an individual entry.
// lib/testimonials.ts
{
slug: "dhivya-raghuraman-creditsaison",
// …name, position, company, image, message, highlightText…
video: {
mux: "1zlcyTu5lw00GHWJ00QbrbmVyUAZN78J9fdtDuTR4JJMI",
youtube: "https://www.youtube.com/watch?v=CxneWOX5fzw",
// prefer: "youtube", // optional — default is "mux"
},
}
Currently wired: CreditSaison (Dhivya Raghuraman), Groww (Prajal Kulkarni), PayU (Ravi Bushan). The other three testimonials are static.
lib/video-source.ts#A pure, React-free, unit-tested module (tests/lib/video-source.test.ts, 23 cases) normalizes
any authoring shape into a discriminated union:
type ResolvedVideo =
| { kind: "mux"; playbackId: string }
| { kind: "youtube"; videoId: string };
resolveVideoSource(v: TestimonialVideo | undefined | null): ResolvedVideo | null;
videoPosterUrl(source: ResolvedVideo | null, fallback): string | fallback;
Detection rules for string input:
| Input | Result |
|---|---|
youtube.com/watch?v=<id>, youtu.be/<id>, /embed/<id>, /shorts/<id> | { kind: "youtube", videoId } (11-char id) |
stream.mux.com/<id>.m3u8, image.mux.com/<id>/… | { kind: "mux", playbackId } |
mux:<id> | { kind: "mux", playbackId } |
bare token matching ^[A-Za-z0-9]{20,}$ | { kind: "mux", playbackId } |
anything else / empty / null | null |
For the dual { mux, youtube, prefer } shape, the preferred provider is tried first, then the
other as fallback. videoPosterUrl returns the provider thumbnail when a source exists, else
the supplied fallback (the testimonial headshot) — the shipped UI uses headshots as posters, so
this helper exists for completeness/future use.
components/homepage/)#| File | Role |
|---|---|
TestimonialVideoRow.tsx | Custom transform carousel (×3 loop) — simultaneous width+slide, rAF autoplay/countdown, drag; owns the open-modal state. |
TestimonialVideoCard.tsx | Self-contained dark card: cutout + per-card head glow + top-right play-stroke + active overlay + countdown. |
TestimonialVideoModal.tsx | Portal player: dimmed backdrop, brand backlight, Mux/YouTube, focus trap. |
TestimonialCarousel.tsx | Adds variant/backlight props; branches to the row when variant="video". |
Each testimonial is its own self-contained dark card holding a transparent head-to-chest cutout. The row's parent is transparent (the cards sit on the page's light section, like Mind.IO), and every card owns its own radial brand glow behind the head — there is no shared stage background. The active card is wider; neighbors are narrower and dimmed.
It's a lightweight custom transform carousel (no Embla) — the only way to make the active card's width and the row's slide one simultaneous motion (Embla owns horizontal scroll and can't change slot widths + scroll in lockstep without a geometry desync).
flex-basis — lg
50% vs 27% inactive; md 58/34; mobile 86/60) and the track's translateX both
transition with the same DUR/EASE, so the incoming card expands while it glides to the
left as a single eased motion. (Math: the incoming card's on-screen x resolves to
(Wactive+gap)·(1−ease(t)) — perfectly smooth.) Cards fill their slot + gap-4, so gaps are
even; fixed height (420 → 500px); inactive dim to opacity-0.62.translateX is linear in the index — calc(-active · (Wn% + gap)) — because every card
before the active one is inactive-width. The % resolves against the viewport (the track fills
it), so it's SSR-safe and responsive via the per-breakpoint FRACTIONS.object-contain object-bottom).requestAnimationFrame clock. It advances every autoScrollDelay
(default 5000ms) and fills the active card's progress bar (scaleX = elapsed/delay). Freezes
while paused — hover, drag, modal open, or off-screen (IntersectionObserver) — and is disabled
under prefers-reduced-motion.movedRef distinguishes a drag from a tap so card clicks still work). Clicking a non-active
card advances to it; clicking the active card (if it has a video) opens the modal.
Keyboard-focusable prev/next controls.A rounded slate-grey panel (rounded-[20px], border + shadow, #3b3f49→#262931 gradient — a
cool neutral that suits the brand, not near-black) holding the
transparent cutout (cutout field, falls back to image) object-contain object-bottom with
a per-card head glow behind it.
message over a bottom scrim, with highlightText wrapped in
a brand <mark>, plus the countdown line at the very bottom.The whole card is a <button> (aria-label reflects play vs view).
document.body; the player mounts only while open (zero playback
weight in the row).bg-brand-black/70 backdrop (click-outside closes), centered min(960px,92vw) 16:9
frame, framer-motion enter/exit (minimized under reduced motion).backlight, default true): a blurred brand-gradient layer
(brand-blue → brand-sky-blue → brand-plum, blur(44px)) behind the player.@mux/mux-player-react loaded via dynamic(…, { ssr: false }) (it registers a
custom element — client-only), autoPlay with sound + native controls, accentColor = brand
blue. YouTube path: a youtube-nocookie.com/embed iframe with autoplay=1&rel=0.role="dialog" aria-modal="true".Playback relies on CSP directives already present in next.config.ts: frame-src (YouTube
iframe), media-src + connect-src + child-src (*.mux.com, *.litix.io, wss://*.mux.com
for Mux streaming/analytics). No CSP changes were required.
The stage uses transparent head-to-chest cutout PNGs (assets/testimonials/<name>-cutout.png),
1080×1080, alpha background, subject centered with consistent framing so they align on a shared
ground line at any scale. They're rendered object-contain object-bottom inside each dark card.
To add/replace a person, drop a same-spec cutout in and point the testimonial's cutout field at
it. The older opaque studio headshots remain on image (used as the cutout fallback and by the
default carousel variant).
WhyChooseMatters passes variant="video". To revert to the original quote carousel, remove
that one prop — everything else stays.