Dark Portrait Spotlight
A React testimonial section that pairs a large portrait with the quote on a dark layout; a thumbnail row below switches customers, crossfading the portrait from greyscale into colour as the name and role slide in.
Lays customer testimonials out as a dark editorial spread: a large vertical portrait on the left (a warm two-tone gradient and an abstract bust silhouette stand in when there is no photo), and an accent-coloured quote mark, the quote and the attribution on the right — side by side, never overlapping. A row of 56px rounded-square thumbnails below stands for each customer: unselected ones are greyscale and half transparent, while the selected one turns to colour with an accent ring and a small dot, and the ring springs over to its new position. On each switch the new portrait fades in from greyscale to colour while easing back from 1.03 to its normal size, and the old one fades out back into greyscale; the quote rises from below, then the name and role slide in from the left one after another. Switching is manual by default, with optional autoplay.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add dark-portrait-spotlight.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DarkPortraitSpotlight/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/dark-portrait-spotlight.en.jsonOr, once registries are configured in components.json, install it as @webberui/dark-portrait-spotlight.
Usage
import { DarkPortraitSpotlight } from "@/components/ui/dark-portrait-spotlight";
<DarkPortraitSpotlight
eyebrow="客戶怎麼說"
title="換上新介面之後"
testimonials={[
{
quote: "改版後的訂位頁,讓週末候位少了一半。",
name: "林語晴",
role: "營運主理人",
company: "青嶼咖啡",
highlight: "少了一半",
},
{
quote: "司機打開派車畫面就知道下一站,新人第一天就能獨立跑線。",
name: "陳柏宇",
role: "調度主管",
company: "北辰物流",
photoSrc: "/images/team/chen.jpg",
},
]}
onValueChange={(i) => console.log(`切到第 ${i + 1} 則`)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
testimonials | DarkPortraitSpotlightItem[] | — | Testimonials |
eyebrow | string | — | Small label above the title |
title | string | — | Section title; used as the section's aria-labelledby when provided |
subtitle | string | — | Supporting text below the title |
value | number | — | Controlled current index; managed internally when omitted |
defaultValue | number | 0 | Initial index in uncontrolled mode |
onValueChange | (index: number) => void | — | Called when the index changes |
surface | "dark" | "auto" | "dark" | dark is always dark; auto follows the site theme and is white in light mode |
autoplay | boolean | false | Whether to autoplay; always off when the user prefers reduced motion |
interval | number | 7000 | Autoplay interval (ms) |
duration | number | 700 | Portrait crossfade duration (ms); reads --wb-dark-portrait-spotlight-duration when omitted |
grayscale | boolean | true | Whether to use the greyscale effect: the portrait goes greyscale to colour and unselected thumbnails are greyscale |
className | string | — | Passed through to the root section |
DarkPortraitSpotlightItem
| Field | Type | Default | Description |
|---|---|---|---|
quote | string | — | Quote text |
name | string | — | Name, also used for the thumbnail initials (last two characters for CJK names, first letters for Latin names) |
role | string | — | Job title |
company | string | — | Company |
rating | number | — | Rating (0–5); not shown by this component, kept so data can be shared with other testimonial components |
avatarHue | number | — | Hue for the placeholder portrait and thumbnail; derived from the index by the golden angle (137.508°) when omitted |
highlight | string | — | Fragment of the quote to mark in the accent colour |
metric | { value: string; label: string } | — | Key figure shown with the quote |
photoSrc | string | — | Portrait photo URL; falls back to the gradient and silhouette placeholder, and the thumbnail uses initials |
How it works
- Timing variable:
--wb-dark-portrait-spotlight-duration(default700ms) drives the portrait crossfade and the greyscale-to-colour change. Passingdurationwrites it into that variable, and the component reads it back from computed style after mounting, so overriding the variable from outside works too. The quote, name and role enter a little faster than the portrait, with the name delayed 150ms and the role a further 80ms. - Hovering a thumbnail drops its greyscale from 100% to 50%, timed by
--wb-dark-portrait-spotlight-hover-duration(default150ms). - The selected ring shares a
layoutIdlayout animation with a stiffness 400, damping 32 spring, so it slides onto the new thumbnail on each switch. - The accent colour comes only from
--wb-dark-portrait-spotlight-accent(default#2dd4bfon the dark surface;#0d9488in light mode withsurface="auto"), used for the eyebrow, the large quote mark, the highlighted fragment, the metric and the selected ring. - With
autoplayon, each testimonial stays forintervalmilliseconds; hovering, keyboard focus inside the block, the pause button or a background tab all pause it, and resuming restarts a full interval.
Accessibility
- The root is a
section, pointing at the title witharia-labelledbywhen there is one; the quote is rendered asfigure,blockquoteandfigcaption - The thumbnail row is a
role="tablist"; each thumbnail is arole="tab"with the name as itsaria-labelandaria-selectedfor the current one, using roving tabindex; with focus on a thumbnail, ← → cycle through customers and Home / End jump to the first or last - The quote area is a
role="tabpanel"labelled by the current thumbnail viaaria-labelledby; the portrait is arole="img"labelled "Portrait of …" aria-live="off"while autoplaying so screen readers are not interrupted on a timer, switching topoliteon manual switches or when paused; whenever autoplay is on there is a visible pause button (WCAG 2.2.2)- Text on the dark surface uses neutral-100 / neutral-400, both above 4.5:1 against the neutral-950 background
- When the user turns on Reduce Motion: autoplay is off, there is no scaling, movement or greyscale transition, the portrait and text fade in over 150ms, the ring jumps straight to its new position, and thumbnails still switch content
Social Post Reviews
Testimonials styled as generic social posts in a two-column feed; new posts slide in from the top at intervals and like counts tick up with a small bounce.
Magazine Pull Quote
A magazine-spread React testimonial with an oversized quotation mark, a large serif pull quote, a drop cap and two-column body copy; on entry the mark scales into place and the text reveals line by line, with page turning for multiple testimonials.