Skip to content

Portrait Quote Carousel

Testimonial carousel: one large serif guest quote at a time over an arched room photo, with a portrait filmstrip.

  • Testimonials
  • Travel & hospitality
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Autoplay pauses on hover, focus, off-screen and with reduced motion.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/portrait-quote-carousel.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/testimonials/portrait-quote-carousel.tsx
1/**
2 * PortraitQuoteCarousel, one large serif pull quote at a time, the guest's
3 * portrait over a photo of the room they stayed in, stay details set as a
4 * small ledger, and a filmstrip of portraits to jump between guests.
5 *
6 * Props: guests (Guest[]), title, eyebrow, interval (ms, 0 disables autoplay),
7 * className.
8 * Guest: { name, from, quote, room, nights, when, portrait, photo, photoAlt }
9 *
10 * Behaviour: the room photo crossfades; the quote reveals line by line (each
11 * line rises out of its own mask). Arrows, the filmstrip and the keyboard
12 * (Left/Right/Home/End while the carousel has focus) change the guest.
13 * Autoplay pauses on hover, on focus and when off-screen, and never runs
14 * with reduced motion.
15 * Browser support: all evergreen browsers.
16 * Accessibility: role="region" with aria-roledescription="carousel"; the
17 * filmstrip buttons use aria-current; the quote is announced politely when
18 * changed by the user (not during autoplay).
19 */
20import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
21
22export interface Guest {
23 name: string;
24 from: string;
25 quote: string;
26 room: string;
27 nights: number;
28 when: string;
29 portrait: string;
30 photo: string;
31 photoAlt: string;
32}
33
34export interface PortraitQuoteCarouselProps {
35 guests?: Guest[];
36 eyebrow?: string;
37 title?: string;
38 interval?: number;
39 className?: string;
40}
41
42const H = "/img/halcyon/";
43const P = "/img/people/";
44
45export const DEFAULT_GUESTS: Guest[] = [
46 { name: "Ingrid Solberg", from: "Bergen", portrait: P + "p1.webp", room: "Limonaia Suite", nights: 5, when: "May 2026", photo: H + "suite-900.webp", photoAlt: "The Limonaia Suite, linen bed facing an open window over the bay",
47 quote: "I booked two nights and stayed five. On the third morning Rosa left a lemon cake outside the door with a note saying the ferry was cancelled anyway. It wasn’t. I didn’t check." },
48 { name: "Omar Farouk", from: "Amman", portrait: P + "p2.webp", room: "Room 7, Sea Terrace", nights: 4, when: "June 2026", photo: H + "pool-lemons-900.webp", photoAlt: "Terrace pool under lemon trees above the sea",
49 quote: "We arrived after eleven hours of travel and one lost suitcase. By dinner someone had found me a linen shirt in my size, ironed. I still don’t know whose it was." },
50 { name: "Amara Nwosu", from: "London", portrait: P + "p3.webp", room: "Garden Room", nights: 3, when: "September 2026", photo: H + "courtyard-pool-900.webp", photoAlt: "Stone courtyard with a small pool and two loungers",
51 quote: "I’m a terrible sleeper and I have complained in hotels all over Italy. Here the only sound at night was the pool filter, and they switched it off at ten without being asked." },
52 { name: "Daniel Ortega", from: "Mexico City", portrait: P + "p5.webp", room: "Limonaia Suite", nights: 6, when: "October 2026", photo: H + "positano-sunset-900.webp", photoAlt: "The coast at sunset seen from the villa's upper terrace",
53 quote: "Honeymoon. We had a list of nine restaurants. We went to two, then ate on the terrace every night because Paolo’s anchovies were better and nobody had to put shoes on." },
54 { name: "Priya Raman", from: "Singapore", portrait: P + "p6.webp", room: "Room 3, Courtyard", nights: 2, when: "April 2026", photo: H + "courtyard-pool-900.webp", photoAlt: "Stone courtyard with lemon trees and a pool",
55 quote: "Two nights was a mistake. I have tried to make the ricotta and fig breakfast at home four times now. It is not the same, and Rosa will not tell me why." },
56 { name: "Mae Thompson", from: "Atlanta", portrait: P + "p9.webp", room: "Torre Room", nights: 7, when: "March 2026", photo: H + "suite-900.webp", photoAlt: "Bright room with a sea view and a wide bed",
57 quote: "My daughter booked it for my seventieth. I was sure the 212 steps down to the water would finish me. By day four I was doing them twice before lunch." },
58];
59
60function useReduced() {
61 const [r, setR] = useState(false);
62 useEffect(() => {
63 const m = matchMedia("(prefers-reduced-motion: reduce)");
64 setR(m.matches);
65 const f = () => setR(m.matches);
66 m.addEventListener("change", f);
67 return () => m.removeEventListener("change", f);
68 }, []);
69 return r;
70}
71
72function SplitQuote({ text }: { text: string }) {
73 const ref = useRef<HTMLParagraphElement>(null);
74 useLayoutEffect(() => {
75 const el = ref.current;
76 if (!el) return;
77 const ws = Array.from(el.querySelectorAll<HTMLElement>("[data-w]"));
78 let line = -1;
79 let top = -Infinity;
80 for (const w of ws) {
81 if (w.offsetTop > top + 4) { line++; top = w.offsetTop; }
82 w.style.setProperty("--ln", String(line));
83 }
84 el.setAttribute("data-ready", "");
85 }, [text]);
86 const words = text.split(" ");
87 return (
88 <p ref={ref} className="fd-pqc-quote">
89 <span className="fd-pqc-sr">{text}</span>
90 <span aria-hidden="true">
91 {words.map((w, i) => (
92 <span key={i} data-w="" className="fd-pqc-w"><span className="fd-pqc-wi">{w}</span>{i < words.length - 1 ? " " : ""}</span>
93 ))}
94 </span>
95 </p>
96 );
97}
98
99export function PortraitQuoteCarousel({
100 guests = DEFAULT_GUESTS,
101 eyebrow = "From the guest book",
102 title = "Fourteen rooms, mostly rebooked.",
103 interval = 9000,
104 className,
105}: PortraitQuoteCarouselProps) {
106 const [i, setI] = useState(0);
107 const [hold, setHold] = useState(false);
108 const [seen, setSeen] = useState(false);
109 const [byUser, setByUser] = useState(false);
110 const reduced = useReduced();
111 const root = useRef<HTMLElement>(null);
112 const sx = useRef(0);
113 const n = guests.length;
114 const g = guests[i];
115 const go = useCallback((to: number, user = true) => { setI(((to % n) + n) % n); if (user) setByUser(true); }, [n]);
116
117 useEffect(() => {
118 const el = root.current;
119 if (!el || typeof IntersectionObserver === "undefined") return;
120 const io = new IntersectionObserver(([e]) => setSeen(e.isIntersecting), { threshold: 0.35 });
121 io.observe(el);
122 return () => io.disconnect();
123 }, []);
124
125 const auto = interval > 0 && !reduced && !hold && seen;
126 useEffect(() => {
127 if (!auto) return;
128 const t = setTimeout(() => go(i + 1, false), interval);
129 return () => clearTimeout(t);
130 }, [auto, i, interval, go]);
131
132 const onKey = (e: KeyboardEvent) => {
133 const k = e.key;
134 if (k === "ArrowRight") go(i + 1);
135 else if (k === "ArrowLeft") go(i - 1);
136 else if (k === "Home") go(0);
137 else if (k === "End") go(n - 1);
138 else return;
139 e.preventDefault();
140 };
141
142 return (
143 <section
144 ref={root}
145 className={`fd-pqc${className ? " " + className : ""}`}
146 role="region"
147 aria-roledescription="carousel"
148 aria-label="Guest stories"
149 tabIndex={0}
150 onKeyDown={onKey}
151 onPointerEnter={() => setHold(true)}
152 onPointerLeave={() => setHold(false)}
153 onFocus={() => setHold(true)}
154 onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setHold(false); }}
155 data-auto={auto || undefined}
156 >
157 <style>{css}</style>
158 <div className="fd-pqc-top">
159 <p className="fd-pqc-eyebrow">{eyebrow}</p>
160 <h2 className="fd-pqc-title">{title}</h2>
161 <p className="fd-pqc-count" aria-hidden="true"><b>{String(i + 1).padStart(2, "0")}</b> / {String(n).padStart(2, "0")}</p>
162 </div>
163
164 <div className="fd-pqc-stage">
165 <div
166 className="fd-pqc-media"
167 onPointerDown={(e) => { sx.current = e.clientX; }}
168 onPointerUp={(e) => { const d = e.clientX - sx.current; if (Math.abs(d) > 48) go(i + (d < 0 ? 1 : -1)); }}
169 >
170 <div className="fd-pqc-arch">
171 {guests.map((x, k) => (
172 <img key={k} className="fd-pqc-photo" data-on={k === i || undefined} src={x.photo} alt={k === i ? x.photoAlt : ""} aria-hidden={k !== i || undefined} width={900} height={900} loading={k === 0 ? "eager" : "lazy"} decoding="async" />
173 ))}
174 </div>
175 <div className="fd-pqc-portrait" key={"p" + i}>
176 <img src={g.portrait} alt={`Portrait of ${g.name}`} width={328} height={328} decoding="async" />
177 </div>
178 <p className="fd-pqc-caption" aria-hidden="true">{g.room}</p>
179 </div>
180
181 <div className="fd-pqc-copy" aria-live={byUser ? "polite" : "off"}>
182 <svg className="fd-pqc-mark" width="56" height="44" viewBox="0 0 56 44" aria-hidden="true"><path d="M0 44V27C0 11.6 8.2 2.6 22 0l2.4 5.6C16 8.2 12.2 13.4 11.8 20H22v24H0zm32 0V27C32 11.6 40.2 2.6 54 0l2 5.6C47.9 8.2 44.2 13.4 43.8 20H54v24H32z" fill="currentColor" /></svg>
183 <SplitQuote key={i} text={g.quote} />
184 <div className="fd-pqc-by" key={"b" + i}>
185 <p className="fd-pqc-name">{g.name}<span>, {g.from}</span></p>
186 <dl className="fd-pqc-ledger">
187 <div><dt>Room</dt><dd>{g.room}</dd></div>
188 <div><dt>Nights</dt><dd>{g.nights}</dd></div>
189 <div><dt>Stayed</dt><dd>{g.when}</dd></div>
190 </dl>
191 </div>
192 </div>
193 </div>
194
195 <div className="fd-pqc-nav">
196 <ol className="fd-pqc-strip" aria-label="Choose a guest">
197 {guests.map((x, k) => (
198 <li key={k}>
199 <button type="button" className="fd-pqc-thumb" aria-current={k === i || undefined} aria-label={`${x.name}, ${x.room}`} onClick={() => go(k)}>
200 <img src={x.portrait} alt="" width={64} height={80} loading="lazy" decoding="async" />
201 <span className="fd-pqc-bar" aria-hidden="true"><i key={k === i ? "on" + i : "off"} style={{ animationDuration: interval + "ms" }} /></span>
202 </button>
203 </li>
204 ))}
205 </ol>
206 <div className="fd-pqc-arrows">
207 <button type="button" className="fd-pqc-arrow" aria-label="Previous guest" onClick={() => go(i - 1)}>
208 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M16 10H4M9 5l-5 5 5 5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
209 </button>
210 <button type="button" className="fd-pqc-arrow" aria-label="Next guest" onClick={() => go(i + 1)}>
211 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h12M11 5l5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
212 </button>
213 </div>
214 </div>
215 </section>
216 );
217}
218
219const SERIF = `"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif`;
220const SANS = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;
221
222const css = `
223.fd-pqc,.fd-pqc *,.fd-pqc *::before,.fd-pqc *::after{box-sizing:border-box}
224.fd-pqc{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
225 --fd-pqc-bg:#EFE9DE;--fd-pqc-ink:#23201A;--fd-pqc-mut:#756C5F;--fd-pqc-line:rgba(35,32,26,.16);--fd-pqc-terra:#A3472A;
226 display:block;max-width:1240px;margin:0 auto;padding:72px 32px 64px;background:var(--fd-pqc-bg);color:var(--fd-pqc-ink);outline:none;
227 font:400 15px/1.5 ${SANS};letter-spacing:0;-webkit-font-smoothing:antialiased}
228.fd-pqc:focus-visible{outline:2px solid rgba(163,71,42,.7);outline-offset:4px}
229.fd-pqc-top{display:grid;grid-template-columns:5fr 7fr;align-items:end;column-gap:64px;margin:0 0 48px;padding:0 0 20px;border-bottom:1px solid var(--fd-pqc-line)}
230.fd-pqc-eyebrow{grid-column:1;margin:0;padding:0;font:italic 400 1.0625rem/1.3 ${SERIF};color:var(--fd-pqc-terra)}
231.fd-pqc-title{grid-column:1;margin:6px 0 0;padding:0;font:400 1.5rem/1.2 ${SERIF};letter-spacing:-.01em;color:var(--fd-pqc-ink)}
232.fd-pqc-count{grid-column:2;grid-row:1 / span 2;justify-self:end;margin:0;padding:0;font:400 13px/1 ${SANS};letter-spacing:.14em;color:var(--fd-pqc-mut);font-variant-numeric:tabular-nums}
233.fd-pqc-count b{font-weight:600;color:var(--fd-pqc-ink)}
234.fd-pqc-stage{display:grid;grid-template-columns:5fr 7fr;column-gap:64px;align-items:center}
235.fd-pqc-media{position:relative;margin:0;padding:0 0 28px;touch-action:pan-y;user-select:none;-webkit-user-select:none}
236.fd-pqc-arch{position:relative;aspect-ratio:4/5;overflow:hidden;border-radius:9999px 9999px 4px 4px;background:#DCD3C3}
237.fd-pqc-photo{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:cover;opacity:0;transform:scale(1.06);transition:opacity var(--fd-dur-4) var(--fd-ease-in-out),transform 1400ms var(--fd-ease-out)}
238.fd-pqc-photo[data-on]{opacity:1;transform:scale(1)}
239.fd-pqc-portrait{position:absolute;right:-28px;bottom:0;width:38%;max-width:168px;aspect-ratio:4/5;overflow:hidden;margin:0;padding:6px;background:#FBF8F2;box-shadow:0 1px 0 rgba(35,32,26,.08),0 18px 40px -18px rgba(35,32,26,.45);animation:fd-pqc-drop var(--fd-dur-4) var(--fd-ease-out) both}
240.fd-pqc-portrait img{display:block;width:100%;height:100%;object-fit:cover;object-position:50% 30%}
241@keyframes fd-pqc-drop{from{opacity:0;transform:translateY(14px) rotate(2deg)}to{opacity:1;transform:none}}
242.fd-pqc-caption{position:absolute;left:0;bottom:0;margin:0;padding:0;font:italic 400 0.8125rem/1 ${SERIF};color:var(--fd-pqc-mut)}
243.fd-pqc-copy{position:relative;min-height:440px;display:flex;flex-direction:column;justify-content:center;padding:0}
244.fd-pqc-mark{display:block;width:44px;height:auto;margin:0 0 28px;color:var(--fd-pqc-terra)}
245.fd-pqc-quote{margin:0;padding:0;font:400 clamp(1.75rem,3.1vw,2.75rem)/1.18 ${SERIF};letter-spacing:-.018em;color:var(--fd-pqc-ink);text-wrap:pretty}
246.fd-pqc-w{display:inline-block;overflow:hidden;vertical-align:top;padding-bottom:.08em;margin-bottom:-.08em;white-space:pre}
247.fd-pqc-wi{display:inline-block;transform:translateY(105%)}
248.fd-pqc-quote[data-ready] .fd-pqc-wi{animation:fd-pqc-rise var(--fd-dur-4) var(--fd-ease-out) both;animation-delay:calc(var(--ln,0) * 90ms + 60ms)}
249@keyframes fd-pqc-rise{from{transform:translateY(105%)}to{transform:translateY(0)}}
250.fd-pqc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
251.fd-pqc-by{margin:40px 0 0;padding:0;animation:fd-pqc-fade var(--fd-dur-4) var(--fd-ease-out) 380ms both}
252@keyframes fd-pqc-fade{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
253.fd-pqc-name{margin:0 0 16px;padding:0;font:600 13px/1.3 ${SANS};letter-spacing:.14em;text-transform:uppercase;color:var(--fd-pqc-ink)}
254.fd-pqc-name span{font-weight:400;color:var(--fd-pqc-mut)}
255.fd-pqc-ledger{display:grid;grid-template-columns:2fr 1fr 1.4fr;margin:0;padding:0;border-top:1px solid var(--fd-pqc-line)}
256.fd-pqc-ledger div{margin:0;padding:12px 16px 0 0;border-right:1px solid var(--fd-pqc-line)}
257.fd-pqc-ledger div+div{padding-left:16px}
258.fd-pqc-ledger div:last-child{border-right:0}
259.fd-pqc-ledger dt{margin:0;padding:0;font:500 11px/1.2 ${SANS};letter-spacing:.12em;text-transform:uppercase;color:var(--fd-pqc-mut)}
260.fd-pqc-ledger dd{margin:6px 0 0;padding:0;font:400 1.0625rem/1.3 ${SERIF};color:var(--fd-pqc-ink)}
261.fd-pqc-nav{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:48px 0 0;padding:24px 0 0;border-top:1px solid var(--fd-pqc-line)}
262.fd-pqc-strip{display:flex;gap:10px;margin:0;padding:0;list-style:none;overflow-x:auto;scrollbar-width:none}
263.fd-pqc-strip::-webkit-scrollbar{display:none}
264.fd-pqc-strip li{margin:0;padding:0;list-style:none;flex:none}
265.fd-pqc-thumb{appearance:none;-webkit-appearance:none;position:relative;display:block;width:64px;margin:0;padding:0 0 8px;border:0;background:transparent;cursor:pointer;color:inherit;font:inherit;border-radius:0}
266.fd-pqc-thumb img{display:block;width:64px;height:80px;object-fit:cover;object-position:50% 28%;filter:grayscale(1) contrast(.95);opacity:.55;transition:filter var(--fd-dur-3) var(--fd-ease-out),opacity var(--fd-dur-3) var(--fd-ease-out),transform var(--fd-dur-2) var(--fd-spring)}
267.fd-pqc-thumb:hover img{opacity:.9;filter:grayscale(.3)}
268.fd-pqc-thumb:active img{transform:scale(.96);transition-duration:var(--fd-dur-1)}
269.fd-pqc-thumb[aria-current] img{filter:none;opacity:1}
270.fd-pqc-thumb:focus-visible,.fd-pqc-arrow:focus-visible{outline:2px solid rgba(163,71,42,.7);outline-offset:2px}
271.fd-pqc-bar{position:absolute;left:0;right:0;bottom:0;display:block;height:2px;background:transparent;overflow:hidden}
272.fd-pqc-thumb[aria-current] .fd-pqc-bar{background:var(--fd-pqc-line)}
273.fd-pqc-bar i{display:block;height:100%;width:100%;background:var(--fd-pqc-terra);transform-origin:0 50%;transform:scaleX(0)}
274.fd-pqc-thumb[aria-current] .fd-pqc-bar i{transform:scaleX(1)}
275.fd-pqc[data-auto] .fd-pqc-thumb[aria-current] .fd-pqc-bar i{animation:fd-pqc-prog linear both}
276@keyframes fd-pqc-prog{from{transform:scaleX(0)}to{transform:scaleX(1)}}
277.fd-pqc-arrows{display:flex;gap:8px;flex:none}
278.fd-pqc-arrow{appearance:none;-webkit-appearance:none;display:grid;place-items:center;width:52px;height:52px;margin:0;padding:0;border:1px solid var(--fd-pqc-line);border-radius:9999px;background:transparent;color:var(--fd-pqc-ink);cursor:pointer;
279 transition:background-color var(--fd-dur-2),color var(--fd-dur-2),border-color var(--fd-dur-2),transform var(--fd-dur-2) var(--fd-spring)}
280.fd-pqc-arrow svg{display:block}
281.fd-pqc-arrow:hover{background:var(--fd-pqc-ink);border-color:var(--fd-pqc-ink);color:var(--fd-pqc-bg)}
282.fd-pqc-arrow:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
283@media (max-width:820px){
284 .fd-pqc{padding:48px 16px 40px}
285 .fd-pqc-top{grid-template-columns:1fr auto;column-gap:16px;margin-bottom:28px}
286 .fd-pqc-title{font-size:1.25rem}
287 .fd-pqc-stage{grid-template-columns:1fr;row-gap:32px}
288 .fd-pqc-media{width:78%;padding-bottom:24px}
289 .fd-pqc-portrait{right:-26%;width:44%}
290 .fd-pqc-copy{min-height:0;display:block}
291 .fd-pqc-mark{width:32px;margin-bottom:18px}
292 .fd-pqc-quote{font-size:1.625rem;line-height:1.22}
293 .fd-pqc-by{margin-top:28px}
294 .fd-pqc-ledger{grid-template-columns:1.6fr .8fr 1.3fr}
295 .fd-pqc-ledger div{padding-right:10px}.fd-pqc-ledger div+div{padding-left:10px}
296 .fd-pqc-ledger dd{font-size:0.9375rem}
297 .fd-pqc-nav{margin-top:32px;padding-top:20px}
298 .fd-pqc-strip{gap:8px}
299 .fd-pqc-thumb,.fd-pqc-thumb img{width:44px}.fd-pqc-thumb img{height:56px}
300 .fd-pqc-arrows{display:none}
301}
302@media (prefers-reduced-motion:reduce){
303 .fd-pqc *,.fd-pqc *::before{animation:none!important;transition-duration:1ms!important}
304 .fd-pqc-wi{transform:none}
305 .fd-pqc-photo{transform:none}
306}
307@media (prefers-contrast:more){.fd-pqc{--fd-pqc-line:rgba(35,32,26,.5);--fd-pqc-mut:#4F473C}}
308`;
309
310export function Demo() {
311 return (
312 <div className="fd-pqcd">
313 <style>{demoCss}</style>
314 <header className="fd-pqcd-bar">
315 <span className="fd-pqcd-logo">Villa Saracena<em>Praiano</em></span>
316 <nav className="fd-pqcd-nav" aria-label="Hotel">
317 <a href="#rooms">Rooms</a><a href="#table">The table</a><a href="#getting-here">Getting here</a>
318 </nav>
319 <a className="fd-pqcd-book" href="#book">Check dates</a>
320 </header>
321 <PortraitQuoteCarousel />
322 </div>
323 );
324}
325
326const demoCss = `
327.fd-pqcd,.fd-pqcd *{box-sizing:border-box}
328.fd-pqcd{min-height:100vh;margin:0;padding:0;background:#EFE9DE;color:#23201A;font:400 14px/1.4 ${SANS}}
329.fd-pqcd-bar{display:flex;align-items:center;gap:32px;max-width:1240px;height:64px;margin:0 auto;padding:0 32px}
330.fd-pqcd-logo{display:flex;align-items:baseline;gap:8px;margin-right:auto;font:400 1.25rem/1 ${SERIF};letter-spacing:.01em;color:#23201A}
331.fd-pqcd-logo em{font:italic 400 0.875rem/1 ${SERIF};color:#A3472A}
332.fd-pqcd-nav{display:flex;gap:28px}
333.fd-pqcd-nav a,.fd-pqcd-book{font:400 13px/1 ${SANS};letter-spacing:.06em;color:#23201A;text-decoration:none}
334.fd-pqcd-nav a:hover{text-decoration:underline;text-underline-offset:4px}
335.fd-pqcd-book{display:inline-flex;align-items:center;height:36px;padding:0 16px;border:1px solid #23201A;border-radius:9999px}
336.fd-pqcd-book:hover{background:#23201A;color:#EFE9DE}
337.fd-pqcd a:focus-visible{outline:2px solid rgba(163,71,42,.7);outline-offset:2px}
338@media (max-width:820px){.fd-pqcd-bar{padding:0 16px}.fd-pqcd-nav{display:none}}
339`;

More Testimonials. Same file contract.

Quote Wall Marquee, testimonials preview: Testimonial wall marquee: rows of developer posts with before and after numbers drifting in opposite…

Quote Wall Marquee

Testimonial wall marquee: rows of developer posts with before and after numbers drifting in opposite directions.

Case Study Cards, testimonials preview: Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote

Case Study Cards

Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote.

Review Grid Summary, testimonials preview: Review grid with rating summary: filtering bars, chips for verified, photos and skin type, masonry cards and…

Review Grid Summary

Review grid with rating summary: filtering bars, chips for verified, photos and skin type, masonry cards and helpful votes.

Questions about Portrait Quote Carousel

Run npx shadcn@latest add https://freedesignmd.com/r/portrait-quote-carousel.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Testimonial carousel: one large serif guest quote at a time over an arched room photo, with a portrait filmstrip. It is a free, MIT-licensed React testimonials component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. Autoplay pauses on hover, focus, off-screen and with reduced motion.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.