Spotlight Testimonial
Video testimonial panels: one expands into a spotlight with the quote, name and role, the rest fold into slim vertical strips, and play adds sound.
A free, copy-paste sliders component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Click (or tap) a strip to rotate that person into the spotlight
- Panels open on their poster — press play to watch with sound, again to pause
- The spotlight fans the rest into a symmetric, depth-shadowed deck
Dependency
This component uses motion for its animation. Install it, then drop the file in.
npm install motionPoint the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/spotlight-testimonial.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/spotlight-testimonialHow to use
Pass Spotlight Testimonial a list of people — each with a name, an optional role and quote, a video and a poster image — and it lays them out as a spotlight with the rest fanned out as slim strips. In the default center layout, picking a panel rotates it to the middle and re-sequences the rest around it; the row layout is a flat left-to-right strip that also takes hover. Touch always taps.
Each panel opens on its poster (thumbnail); press play to watch the spotlit clip with sound, press again to pause — set playOnActive to auto-play it muted instead. Accent tints the play button and caption, speed scales the transition. It's theme-aware, fully responsive (portrait on phones), respects prefers-reduced-motion, fills its parent (give the parent a height), and with autoPlay cycles the spotlight on its own for hands-off displays.
Basic usage
import { SpotlightTestimonial } from "@/components/harsh-ui/spotlight-testimonial";
const people = [
{ name: "Kaity", role: "Fashion Director", video: "/v/kaity.mp4", poster: "/p/kaity.jpg" },
{ name: "Mike", role: "Product Lead", video: "/v/mike.mp4", poster: "/p/mike.jpg" },
{ name: "Ryan", role: "Founder", video: "/v/ryan.mp4", poster: "/p/ryan.jpg" },
];
const Demo = () => (
<div className="h-[26rem]">
<SpotlightTestimonial items={people} />
</div>
);Click to spotlight, snappy
trigger="click" spotlights on tap (good for touch); the effect preset tunes the expand feel.
<SpotlightTestimonial
items={people}
trigger="click"
effect="snappy"
accent="#3b82f6"
/>Auto-cycling display
autoPlay cycles the spotlight with no pointer — how the grid-card preview animates.
<SpotlightTestimonial items={people} autoPlay />Demo
import { SpotlightTestimonialDemo } from "@/components/demos/spotlight-testimonial-demo";
const Demo = () => (
<div className="h-screen w-full">
<SpotlightTestimonialDemo />
</div>
);Props
Notes
- The center layout is a circular carousel: whichever panel you pick rotates to the middle and the rest re-sequence symmetrically around it, their heights stepping down with distance from centre for the fanned-deck silhouette.
- The size morph is GPU-only. Each centre panel is a fixed square scaled (scaleX/scaleY + translate) to its target box — never width/height — so nothing repaints per frame. The media and vertical name are counter-scaled by the exact inverse of the panel scale, read live off the same motion value, so the crop stays undistorted even mid-spring.
- Drop shadows live on fixed slot boxes behind the panels rather than on the panels themselves — a scaled box scales its shadow too — so the shadows stay crisp and true while the panels slide over them.
- Panels open on their poster (thumbnail); the play button reveals and plays the clip with sound, and pressing again pauses it. playOnActive instead auto-plays the spotlit clip muted, and speed is a 0.5×–2× multiplier on the transition.
- Only the spotlit panel mounts a <video> at all — the rest are cheap poster images — so decoding never piles up, even with a dozen clips.
- Center is click / autoplay driven (hover-to-spotlight is a row-only affordance) so rapid pointer moves never thrash the animation.
- Fully responsive off a ResizeObserver: the spotlight fills the free width once the strips are reserved, and on a narrow (phone) stage it turns portrait to fill the tall viewport — outer strips just peek and clip — instead of leaving a small square marooned in the middle.
- Falls back to tap on (hover: none) / (pointer: coarse) devices and drops the animation under prefers-reduced-motion.
- autoPlay cycles the spotlight every couple of seconds and is how the pointer-less grid card animates.
- Inspired by the video-testimonial section from Skiper UI (https://skiper-ui.com). The centred-carousel idea is theirs; the circular re-sequencing, the GPU counter-scale, the single-<video> optimisation, the fanned strips, the quote overlay and the fully theme-aware customisation are this version's.
Source code
The complete spotlight-testimonial.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
type MotionValue,
type Transition,
} from "motion/react";
import { Pause, Play } from "lucide-react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* SpotlightTestimonial
* A row of video testimonial panels: one is spotlit — expanded, showing the
* person's quote, name and role — while the rest collapse to slim strips with the
* name set vertically. Each panel opens on its poster (thumbnail); the video only
* plays when you press play, and pressing again pauses it.
*
* In "center" layout the spotlight is a big square in the middle and the strips fan
* out on both sides, their height stepping down the further they sit from centre;
* whichever you pick rotates to the middle and the rest re-sequence around it. The
* size morph is GPU-only — each centre panel is a fixed square scaled (translate +
* scaleX/scaleY) to its target box, never width/height, so nothing repaints. The
* media and vertical name are counter-scaled by the exact inverse of the panel
* scale (shared motion value) so the crop never distorts. Drop shadows live on
* fixed slot boxes behind the panels, so they stay crisp instead of scaling. Only
* the active clip is mounted. Built on Motion, no GSAP; honours prefers-reduced-
* motion and taps on touch.
*/
export type SpotlightItem = {
name: string;
role?: string;
quote?: string;
video: string;
poster?: string;
};
export type SpotlightTrigger = "hover" | "click";
export type SpotlightEffect = "spring" | "smooth" | "snappy";
export type SpotlightLayout = "center" | "row";
export type SpotlightTestimonialProps = {
items: SpotlightItem[];
layout?: SpotlightLayout;
trigger?: SpotlightTrigger;
defaultActive?: number;
autoPlay?: boolean;
playOnActive?: boolean;
showPlay?: boolean;
gap?: number;
radius?: number;
inset?: boolean;
overlay?: number;
effect?: SpotlightEffect;
speed?: number;
accent?: string;
className?: string;
};
const EFFECTS: Record<SpotlightEffect, Transition> = {
spring: { type: "spring", duration: 0.5, bounce: 0.12 },
smooth: { type: "tween", duration: 0.45, ease: [0.22, 1, 0.36, 1] },
snappy: { type: "spring", duration: 0.38, bounce: 0.22 },
};
export function SpotlightTestimonial({
items,
layout = "center",
trigger = "hover",
defaultActive,
autoPlay = false,
playOnActive = false,
showPlay = true,
gap = 14,
radius = 0,
inset = true,
overlay = 0.5,
effect = "spring",
speed = 1,
accent = "#ffffff",
className = "",
}: SpotlightTestimonialProps) {
const N = items.length;
const isCenter = layout === "center";
const initial = defaultActive ?? (isCenter ? Math.floor(N / 2) : 0);
const [active, setActive] = useState(initial);
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [dim, setDim] = useState({ w: 0, h: 0 });
const hoverCapable = useHoverCapable();
const reduce = useReducedMotion();
const sp = speed > 0 ? speed : 1;
const raw = EFFECTS[effect] || EFFECTS.spring;
const t: Transition = reduce ? { duration: 0 } : { ...raw, duration: (raw.duration ?? 0.5) / sp };
const pad = inset ? gap : 0;
// Hover-to-spotlight is a row-only affordance; the centre carousel is click /
// autoplay driven so rapid pointer moves never thrash the animation.
const useHover = !isCenter && trigger === "hover" && hoverCapable;
const activate = (i: number) => setActive(i);
const togglePlay = () => setPlaying((p) => !p);
// Show the poster by default; the video is only revealed when it should play.
const shouldPlay = playing || playOnActive;
// measure the box so the carousel can lay panels out in px (transform-immune)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const read = () => setDim({ w: el.clientWidth, h: el.clientHeight });
const ro = new ResizeObserver(read);
read();
ro.observe(el);
return () => ro.disconnect();
}, []);
// Only the spotlit clip is mounted, so drive playback from a single ref.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.muted = !playing; // sound only when the viewer pressed play themselves
if (shouldPlay) v.play().catch(() => {});
else v.pause();
}, [active, playing, shouldPlay]);
// A newly spotlit panel starts paused on its thumbnail.
useEffect(() => setPlaying(false), [active]);
useEffect(() => {
if (!autoPlay) return;
setActive(initial);
let i = initial;
const id = setInterval(() => {
i = (i + 1) % N;
setActive(i);
}, 2600);
return () => clearInterval(id);
}, [autoPlay, N, initial]);
// shared content for the flat ROW strips (full-height, no transform scaling)
const rowInner = (item: SpotlightItem, isActive: boolean, i: number): ReactNode => (
<>
<Media item={item} active={isActive} reveal={isActive && shouldPlay} t={t} videoRef={videoRef} />
<Overlays isActive={isActive} overlay={overlay} t={t} />
<VerticalName name={item.name} isActive={isActive} />
<Meta item={item} isActive={isActive} accent={accent} />
{showPlay && (
<PlayButton
name={item.name}
isActive={isActive}
playing={playing}
accent={accent}
reduce={!!reduce}
onActivate={() => activate(i)}
onTogglePlay={togglePlay}
/>
)}
</>
);
// ── CENTER: a big square spotlight, slim strips fanning out on both sides ──
if (isCenter) {
const W = Math.max(0, dim.w - 2 * pad);
const H = Math.max(0, dim.h - 2 * pad);
const half = Math.floor(N / 2);
const minO = -(N - 1 - half);
const narrow = W > 0 && W < 560;
const C = Math.max(narrow ? 38 : 42, Math.min(W * (narrow ? 0.075 : 0.055), 64));
const sideStrips = Math.max(half, N - 1 - half);
const freeW = W - 2 * sideStrips * (C + gap);
// The spotlight fills the width left once the widest run of strips is
// reserved (or a comfy fraction on a narrow stage, where the outer strips
// just peek + clip). On phones it goes portrait so it fills the tall
// viewport instead of leaving a square marooned in the middle; on desktop
// it stays a square. Capped so it never balloons.
const aspect = narrow ? 1.4 : 1;
let activeW = Math.min(Math.max(freeW, W * (narrow ? 0.68 : 0.5)), narrow ? 560 : 580);
let activeH = activeW * aspect;
const maxH = H * (narrow ? 0.72 : 0.9);
if (activeH > maxH) {
activeH = maxH;
activeW = activeH / aspect;
}
activeW = Math.max(0, activeW);
activeH = Math.max(0, activeH);
const panelBase = Math.max(activeW, activeH, 1);
const offsetOf = (i: number) => {
let o = (((i - active) % N) + N) % N;
if (o > half) o -= N;
return o;
};
// height falls off with distance from centre → the fanned-deck silhouette
const hScale = (k: number) => (k === 0 ? 1 : Math.max(0.5, 0.85 - (k - 1) * 0.09));
// horizontal centre of the panel at circular offset o (0 = box centre)
const centreOf = (o: number) => {
if (o === 0) return 0;
const span = activeW / 2 + gap + (Math.abs(o) - 1) * (C + gap) + C / 2;
return o > 0 ? span : -span;
};
const boxOf = (o: number) => {
const w = o === 0 ? activeW : C;
const h = activeH * hScale(Math.abs(o));
return { w, h, x: W / 2 + centreOf(o) - w / 2, y: H / 2 - h / 2 };
};
return (
<div ref={rootRef} className={`relative h-full w-full overflow-hidden ${className}`}>
<div className="absolute inset-0" style={{ padding: pad }}>
<div className="relative h-full w-full">
{activeW > 0 && (
<>
{/* Shadows live on fixed slot boxes — never scaled, so they stay
crisp while the panels slide over them. */}
{Array.from({ length: N }, (_, k) => {
const o = minO + k;
const b = boxOf(o);
return (
<div
key={`slot${o}`}
aria-hidden
className="absolute left-0 top-0"
style={{
transform: `translate(${b.x}px, ${b.y}px)`,
width: b.w,
height: b.h,
borderRadius: radius,
zIndex: o === 0 ? 1 : 0,
boxShadow:
o === 0
? "0 30px 70px -22px rgba(0,0,0,0.5)"
: "0 16px 34px -20px rgba(0,0,0,0.45)",
}}
/>
);
})}
{items.map((item, i) => {
const o = offsetOf(i);
const isActive = o === 0;
const b = boxOf(o);
return (
<CenterPanel
key={item.name}
item={item}
isActive={isActive}
base={panelBase}
x={b.x}
y={b.y}
sx={b.w / panelBase}
sy={b.h / panelBase}
t={t}
reduce={!!reduce}
radius={radius}
overlay={overlay}
accent={accent}
showPlay={showPlay}
reveal={isActive && shouldPlay}
playing={playing}
onActivate={() => activate(i)}
onTogglePlay={togglePlay}
videoRef={isActive ? videoRef : undefined}
/>
);
})}
</>
)}
</div>
</div>
</div>
);
}
// ── ROW: a flat left-to-right strip; the active grows in place ──
return (
<div
ref={rootRef}
onMouseLeave={() => {
if (useHover) setPlaying(false);
}}
className={`flex h-full w-full flex-col overflow-hidden sm:flex-row ${className}`}
style={{ gap, padding: pad }}
>
{items.map((item, i) => {
const isActive = i === active;
return (
<motion.div
key={item.name}
{...(useHover ? { onMouseEnter: () => activate(i) } : { onClick: () => activate(i) })}
animate={{ flexGrow: isActive ? 22 : 1 }}
transition={t}
style={{
flexBasis: "3.25rem",
borderRadius: radius,
boxShadow: isActive ? "0 24px 60px -18px rgba(0,0,0,0.5)" : "none",
zIndex: isActive ? 2 : 1,
willChange: "flex-grow",
}}
className="group relative min-w-0 cursor-pointer overflow-hidden bg-neutral-900"
role="button"
aria-label={item.name}
aria-pressed={isActive}
>
{rowInner(item, isActive, i)}
</motion.div>
);
})}
</div>
);
}
/**
* One centre panel: a fixed `base`×`base` square positioned + sized purely with
* transforms (x/y translate, scaleX/scaleY). The media and vertical name are
* counter-scaled by the exact inverse of the panel scale — read live off the same
* motion value — so the crop never distorts, even mid-spring, and nothing repaints.
*/
function CenterPanel({
item,
isActive,
base,
x,
y,
sx,
sy,
t,
reduce,
radius,
overlay,
accent,
showPlay,
reveal,
playing,
onActivate,
onTogglePlay,
videoRef,
}: {
item: SpotlightItem;
isActive: boolean;
base: number;
x: number;
y: number;
sx: number;
sy: number;
t: Transition;
reduce: boolean;
radius: number;
overlay: number;
accent: string;
showPlay: boolean;
reveal: boolean;
playing: boolean;
onActivate: () => void;
onTogglePlay: () => void;
videoRef?: React.RefObject<HTMLVideoElement | null>;
}) {
const mx = useMotionValue(x);
const my = useMotionValue(y);
const msx = useMotionValue(sx);
const msy = useMotionValue(sy);
// exact inverse of the panel scale, tracked live → media stays undistorted
const isx = useTransform(msx, (v) => (v ? 1 / v : 1));
const isy = useTransform(msy, (v) => (v ? 1 / v : 1));
useEffect(() => {
const opts: Transition = reduce ? { duration: 0 } : t;
const runs = [
animate(mx, x, opts),
animate(my, y, opts),
animate(msx, sx, opts),
animate(msy, sy, opts),
];
return () => runs.forEach((r) => r.stop());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [x, y, sx, sy, reduce]);
return (
<motion.div
onClick={onActivate}
role="button"
aria-label={item.name}
aria-pressed={isActive}
className="group absolute left-0 top-0 cursor-pointer overflow-hidden bg-neutral-900"
style={{
x: mx,
y: my,
scaleX: msx,
scaleY: msy,
width: base,
height: base,
transformOrigin: "0 0",
borderRadius: radius,
zIndex: isActive ? 3 : 2,
willChange: "transform",
}}
>
<Media item={item} active={isActive} reveal={reveal} t={t} videoRef={videoRef} counter={{ isx, isy }} />
<Overlays isActive={isActive} overlay={overlay} t={t} />
<VerticalName name={item.name} isActive={isActive} counter={{ isx, isy }} />
<Meta item={item} isActive={isActive} accent={accent} />
{showPlay && (
<PlayButton
name={item.name}
isActive={isActive}
playing={playing}
accent={accent}
reduce={reduce}
onActivate={onActivate}
onTogglePlay={onTogglePlay}
/>
)}
</motion.div>
);
}
type Counter = { isx: MotionValue<number>; isy: MotionValue<number> };
function Media({
item,
active,
reveal,
t,
videoRef,
counter,
}: {
item: SpotlightItem;
active: boolean;
reveal: boolean;
t: Transition;
videoRef?: React.RefObject<HTMLVideoElement | null>;
counter?: Counter;
}) {
return (
<motion.div
className="absolute inset-0"
style={counter ? { scaleX: counter.isx, scaleY: counter.isy, transformOrigin: "center" } : undefined}
>
{item.poster && (
<motion.img
src={item.poster}
alt={item.name}
draggable={false}
className="absolute inset-0 h-full w-full select-none object-cover"
animate={{ opacity: reveal ? 0 : 1 }}
transition={t}
/>
)}
{active && (
<video
ref={videoRef}
src={item.video}
poster={item.poster}
muted
loop
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: reveal ? 1 : 0, transition: "opacity .45s ease" }}
/>
)}
</motion.div>
);
}
function Overlays({ isActive, overlay, t }: { isActive: boolean; overlay: number; t: Transition }) {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: `linear-gradient(to top, rgba(0,0,0,${overlay + 0.28}) 0%, rgba(0,0,0,${overlay * 0.12}) 42%, rgba(0,0,0,${isActive ? 0.04 : overlay * 0.55}) 100%)`,
}}
/>
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0 bg-black"
animate={{ opacity: isActive ? 0 : 0.28 }}
transition={t}
/>
</>
);
}
function VerticalName({ name, isActive, counter }: { name: string; isActive: boolean; counter?: Counter }) {
return (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 grid select-none place-items-center whitespace-nowrap text-sm font-semibold uppercase tracking-[0.24em] text-white [writing-mode:vertical-rl]"
style={counter ? { scaleX: counter.isx, scaleY: counter.isy } : undefined}
animate={{ opacity: isActive ? 0 : 0.94 }}
transition={{ duration: 0.25 }}
>
{name}
</motion.span>
);
}
function Meta({ item, isActive, accent }: { item: SpotlightItem; isActive: boolean; accent: string }) {
return (
<motion.div
className="pointer-events-none absolute inset-x-0 bottom-0 p-5 sm:p-6"
animate={{ opacity: isActive ? 1 : 0, y: isActive ? 0 : 12 }}
transition={{ duration: 0.4, delay: isActive ? 0.12 : 0, ease: [0.22, 1, 0.36, 1] }}
>
{item.quote && (
<p className="mb-3 max-w-[26ch] text-balance text-sm font-medium leading-snug text-white/90 sm:text-base">
“{item.quote}”
</p>
)}
<span aria-hidden className="mb-2.5 block h-[3px] w-7 rounded-full" style={{ background: accent }} />
<p className="text-xl font-bold tracking-tight text-white sm:text-2xl">{item.name}</p>
{item.role && (
<p
className="mt-1 text-xs font-semibold uppercase tracking-[0.18em]"
style={{ color: accent, opacity: 0.85 }}
>
{item.role}
</p>
)}
</motion.div>
);
}
function PlayButton({
name,
isActive,
playing,
accent,
reduce,
onActivate,
onTogglePlay,
}: {
name: string;
isActive: boolean;
playing: boolean;
accent: string;
reduce: boolean;
onActivate: () => void;
onTogglePlay: () => void;
}) {
const isPlaying = isActive && playing;
return (
<motion.button
type="button"
aria-label={isPlaying ? `Pause ${name}` : `Play ${name}`}
onClick={(e) => {
e.stopPropagation();
if (!isActive) onActivate();
else onTogglePlay();
}}
className="absolute left-1/2 top-1/2 grid size-14 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full"
style={{ background: "rgba(12,12,14,0.42)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.35)" }}
animate={{ opacity: isActive ? 1 : 0, scale: isActive ? 1 : 0.6 }}
transition={{ duration: 0.3, delay: isActive ? 0.12 : 0 }}
whileHover={reduce ? undefined : { scale: 1.08 }}
whileTap={reduce ? undefined : { scale: 0.92 }}
>
{isPlaying ? (
<Pause className="size-5" style={{ color: accent }} fill="currentColor" />
) : (
<Play className="size-5 translate-x-0.5" style={{ color: accent }} fill="currentColor" />
)}
</motion.button>
);
}
function useHoverCapable() {
const [ok, setOk] = useState(true);
useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setOk(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
return ok;
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.




