260 lines
8.8 KiB
TypeScript
260 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* TESTIMONIALS — an interactive, auto-advancing carousel that feels substantial.
|
|
* - 6 sample testimonials, one large featured quote at a time, with a
|
|
* monogram avatar, name, role, company and a result chip.
|
|
* - Auto-advances every ~6s; pauses on hover/focus-within and when offscreen
|
|
* or the tab is hidden. Manual prev/next buttons + clickable dots.
|
|
* - A thumbnail rail of all clients (monogram avatars) doubles as navigation;
|
|
* the active one is highlighted.
|
|
* - Slides crossfade/slide with Motion (AnimatePresence) using the page's
|
|
* EASE curves; direction-aware.
|
|
* - Fully keyboard accessible: real <button>s, arrow-key support, aria-live
|
|
* announces the active slide, aria-roledescription="carousel".
|
|
* - prefers-reduced-motion: no auto-advance, instant slide swaps.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import Reveal from "./Reveal";
|
|
import { testimonials } from "../content";
|
|
import { EASE_OUT } from "./motion";
|
|
|
|
const AUTOPLAY_MS = 6000;
|
|
|
|
export default function Testimonials() {
|
|
const reduce = useReducedMotion();
|
|
const [[index, dir], setState] = useState<[number, number]>([0, 0]);
|
|
const [paused, setPaused] = useState(false);
|
|
const rootRef = useRef<HTMLElement>(null);
|
|
const inView = useRef(true);
|
|
|
|
const count = testimonials.length;
|
|
const go = useCallback(
|
|
(next: number, direction: number) => {
|
|
setState([(next + count) % count, direction]);
|
|
},
|
|
[count]
|
|
);
|
|
const next = useCallback(() => go(index + 1, 1), [go, index]);
|
|
const prev = useCallback(() => go(index - 1, -1), [go, index]);
|
|
|
|
// autoplay — paused on hover/focus, offscreen, hidden tab, reduced-motion
|
|
useEffect(() => {
|
|
if (reduce) return;
|
|
const el = rootRef.current;
|
|
if (!el) return;
|
|
|
|
const io = new IntersectionObserver(
|
|
([e]) => {
|
|
inView.current = e.isIntersecting;
|
|
},
|
|
{ threshold: 0.25 }
|
|
);
|
|
io.observe(el);
|
|
|
|
const id = window.setInterval(() => {
|
|
if (paused || !inView.current || document.hidden) return;
|
|
setState(([i]) => [(i + 1) % count, 1]);
|
|
}, AUTOPLAY_MS);
|
|
|
|
return () => {
|
|
io.disconnect();
|
|
window.clearInterval(id);
|
|
};
|
|
}, [reduce, paused, count]);
|
|
|
|
// keyboard arrows when the carousel has focus
|
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
next();
|
|
} else if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
prev();
|
|
}
|
|
};
|
|
|
|
const t = testimonials[index];
|
|
|
|
const variants = {
|
|
enter: (d: number) =>
|
|
reduce
|
|
? { opacity: 0 }
|
|
: { opacity: 0, x: d > 0 ? 60 : -60, filter: "blur(6px)" },
|
|
center: { opacity: 1, x: 0, filter: "blur(0px)" },
|
|
exit: (d: number) =>
|
|
reduce
|
|
? { opacity: 0 }
|
|
: { opacity: 0, x: d > 0 ? -60 : 60, filter: "blur(6px)" },
|
|
};
|
|
|
|
return (
|
|
<section
|
|
ref={rootRef}
|
|
className="quotes frame"
|
|
aria-labelledby="quotes-h"
|
|
onMouseEnter={() => setPaused(true)}
|
|
onMouseLeave={() => setPaused(false)}
|
|
onFocusCapture={() => setPaused(true)}
|
|
onBlurCapture={() => setPaused(false)}
|
|
>
|
|
<div className="wrap">
|
|
<header className="sec-head">
|
|
<p className="kicker">
|
|
<span className="kicker__dot" />
|
|
In their words — sample
|
|
</p>
|
|
<Reveal as="h2" variant="clip">
|
|
<span id="quotes-h" className="display sec-head__title">
|
|
The number is the point
|
|
</span>
|
|
</Reveal>
|
|
</header>
|
|
|
|
<div
|
|
className="carousel"
|
|
role="group"
|
|
aria-roledescription="carousel"
|
|
aria-label="Client testimonials"
|
|
tabIndex={0}
|
|
onKeyDown={onKeyDown}
|
|
>
|
|
<div className="carousel__stage">
|
|
{/* decorative oversized quote mark */}
|
|
<span className="carousel__mark display" aria-hidden="true">
|
|
“
|
|
</span>
|
|
|
|
<AnimatePresence mode="wait" custom={dir} initial={false}>
|
|
<motion.figure
|
|
key={index}
|
|
className="carousel__slide"
|
|
custom={dir}
|
|
variants={variants}
|
|
initial="enter"
|
|
animate="center"
|
|
exit="exit"
|
|
transition={{ duration: reduce ? 0.2 : 0.55, ease: EASE_OUT }}
|
|
aria-roledescription="slide"
|
|
aria-label={`${index + 1} of ${count}`}
|
|
>
|
|
<blockquote className="carousel__quote display">
|
|
{t.quote}
|
|
</blockquote>
|
|
<figcaption className="carousel__by">
|
|
<span className="carousel__avatar">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={t.avatar}
|
|
alt=""
|
|
width={64}
|
|
height={64}
|
|
loading="lazy"
|
|
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
|
|
/>
|
|
</span>
|
|
<span className="carousel__id">
|
|
<span className="carousel__name">{t.name}</span>
|
|
<span className="carousel__role">
|
|
{t.role}, {t.company}
|
|
</span>
|
|
</span>
|
|
<span className="carousel__chip">{t.metric}</span>
|
|
</figcaption>
|
|
</motion.figure>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* controls */}
|
|
<div className="carousel__controls">
|
|
<button
|
|
type="button"
|
|
className="carousel__arrow"
|
|
onClick={prev}
|
|
aria-label="Previous testimonial"
|
|
data-cursor="Prev"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
|
<path
|
|
d="M15 5l-7 7 7 7"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<ul className="carousel__dots" role="tablist" aria-label="Choose testimonial">
|
|
{testimonials.map((tt, i) => (
|
|
<li key={tt.name} role="presentation">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={i === index}
|
|
aria-label={`${tt.name}, ${tt.company}`}
|
|
className={`carousel__dot ${i === index ? "is-active" : ""}`}
|
|
onClick={() => go(i, i > index ? 1 : -1)}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<button
|
|
type="button"
|
|
className="carousel__arrow"
|
|
onClick={next}
|
|
aria-label="Next testimonial"
|
|
data-cursor="Next"
|
|
>
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
|
<path
|
|
d="M9 5l7 7-7 7"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* avatar rail — all clients; doubles as quick nav */}
|
|
<ul className="carousel__rail" aria-hidden="true">
|
|
{testimonials.map((tt, i) => (
|
|
<li key={tt.name}>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
className={`carousel__railitem ${i === index ? "is-active" : ""}`}
|
|
onClick={() => go(i, i > index ? 1 : -1)}
|
|
>
|
|
<span className="carousel__railmono">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={tt.avatar}
|
|
alt=""
|
|
width={44}
|
|
height={44}
|
|
loading="lazy"
|
|
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
|
|
/>
|
|
</span>
|
|
<span className="carousel__railname">{tt.company}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{/* live region for screen readers */}
|
|
<p className="sr-only" aria-live="polite">
|
|
Testimonial {index + 1} of {count}: {t.name}, {t.role} at {t.company}.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|