# Relay Carousel: Testimonial Carousel for React

> A looping strip of photo cards where the first card leads: bigger, with its quote showing. Passing the lead on glides the strip, shrinks the old lead out of the way and grows the next card into its place, then sets the new quote in line by line.

Testimonial Carousel for React: the lead card grows and shows its quote. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/relay-carousel
- Tags: Carousel, Testimonials, Motion
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Glide](https://www.glideapps.com/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/relay-carousel.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/relay-carousel
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/relay-carousel.json
```

## Usage

The main demo shows typical usage.

### Customer Stories

Workshop owners on the invoicing app they run their shop on. It plays by itself and holds while you point at it; pull the strip, use the arrows or click a card to pass the lead.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/relay-carousel-demo
```

```tsx title="components/relay-carousel-demo.tsx"
import { RelayCarousel, RelayCarouselItem } from "@/components/ui/relay-carousel";

const makers = [
  {
    name: "Joaquim Carvalho",
    role: "Ceramicist, Barro Studio",
    label: "Ceramics · Porto",
    quote:
      "I used to spend Sunday nights matching invoices to kiln loads. Now an order comes in and the glaze stock counts itself down.",
    photo: "1753164725926-dc4de5911e61",
    alt: "A bearded potter in a clay-streaked apron raising a tall vase on the wheel",
  },
  {
    name: "Tomasz Wiśniewski",
    role: "Owner, Kruszon Bakery",
    label: "Bakery · Kraków",
    quote:
      "Our cafés reorder from a link I sent them once. I know what to bake tomorrow before the ovens have cooled down.",
    photo: "1772758632504-361aa9d14a19",
    alt: "A baker sliding flatbreads into a brick oven, rounds of dough waiting in front",
  },
  {
    name: "Gareth Lloyd",
    role: "Woodcarver, Grain & Gauge",
    label: "Woodwork · Leeds",
    quote:
      "Every commission keeps its timber, hours and deposit on one page. Quoting a carved panel takes ten minutes, not an evening.",
    photo: "1783669081274-3db07e6ec196",
    alt: "A woodcarver chiseling a relief panel at a cluttered bench under a desk lamp",
  },
  {
    name: "Kenji Arai",
    role: "Roaster, Hiraku Coffee",
    label: "Coffee · Osaka",
    quote:
      "Green beans, roast days and wholesale orders finally talk to each other. I buy sacks when the numbers say so, not when I panic.",
    photo: "1607681034512-1c9c5fbda608",
    alt: "A roaster in ear defenders checking freshly roasted beans under a magnifier lamp",
  },
  {
    name: "Lena Hoffmann",
    role: "Florist, Stiel",
    label: "Flowers · Leipzig",
    quote:
      "Wedding season used to live on sticky notes. This year every bouquet had its stems, its order and its invoice before the bride did.",
    photo: "1531058240690-006c446962d8",
    alt: "A florist at the counter of her flower shop, seen through the plants",
  },
  {
    name: "Idris Hale",
    role: "Leatherworker, Hale & Hide",
    label: "Leather · Bristol",
    quote:
      "Custom orders, hides and the balance owed sit on the same screen. I've stopped losing deposits in the gap between them.",
    photo: "1534126511673-b6899657816a",
    alt: "A craftsman stitching a leather strap on an industrial sewing machine",
  },
  {
    name: "Rafael Moreno",
    role: "Mechanic, Cadena Cycles",
    label: "Repairs · Valencia",
    quote:
      "Customers get a message when their bike is ready and pay from the link. My bench stays clear, and so does my inbox.",
    photo: "1675798227643-da319f8ee8f7",
    alt: "A mechanic's hands tightening the crank on a black bicycle frame",
  },
  {
    name: "Ewan MacLeod",
    role: "Printer, Low Tide Press",
    label: "Screen print · Edinburgh",
    quote:
      "A poster run has twelve proofs and three deposits. Field & Fold keeps all of it straight, so I can keep my hands inky.",
    photo: "1456456496250-d5e7c0a9b44d",
    alt: "A printmaker pulling ink across a silkscreen by the window",
  },
];

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=880&h=840&q=80`;

export function RelayCarouselDemo() {
  return (
    <div className="flex min-h-[var(--demo-height,auto)] w-full flex-col justify-center overflow-hidden rounded-2xl border border-zinc-200 bg-white py-14 text-zinc-950 sm:py-20 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <RelayCarousel
        aria-labelledby="relay-makers-heading"
        autoPlay={7000}
        heading={
          <h2
            id="relay-makers-heading"
            className="text-[2rem] leading-[1.02] font-semibold tracking-[-0.035em] text-balance @2xl:text-5xl"
          >
            Small shops,
            <br />
            <span className="text-zinc-400 dark:text-zinc-500">steady books.</span>
          </h2>
        }
      >
        {makers.map((maker) => (
          <RelayCarouselItem
            key={maker.name}
            name={maker.name}
            role={maker.role}
            label={maker.label}
            quote={maker.quote}
            image={photo(maker.photo)}
            alt={maker.alt}
          />
        ))}
      </RelayCarousel>
    </div>
  );
}
```

## Examples

Finished sections built with Relay Carousel. Each one installs whole, with the component included.

### Careers

A hiring page's team voices on a dark section. Once a quote is in, the lead card offers a link to that team's open roles through `children`. The controls take the section's white text, with no extra setup.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/relay-carousel-careers-demo
```

```tsx title="components/relay-carousel-careers-demo.tsx"
import { RelayCarousel, RelayCarouselItem } from "@/components/ui/relay-carousel";

const team = [
  {
    name: "Sofía Herrera",
    role: "Product Designer, 3 years",
    team: "Design",
    city: "Lisbon",
    quote:
      "I joined for the craft and stayed for the critiques. Nobody here is precious about their pixels, and the work is better for it.",
    photo: "1521999693742-4717d76f97cc",
    alt: "A designer sketching at her desk under a lamp late at night",
  },
  {
    name: "Daniel Mensah",
    role: "Staff Engineer, 5 years",
    team: "Platform",
    city: "Accra",
    quote:
      "We ship small and often, and on-call is a rota, not a hero story. A Friday deploy here is just another deploy.",
    photo: "1719400471588-575b23e27bd7",
    alt: "An engineer working late in a dark room lit by screens of code",
  },
  {
    name: "Hana Kobayashi",
    role: "Support Lead, 2 years",
    team: "Support",
    city: "Remote",
    quote:
      "Support isn't a queue to clear. When three customers trip on the same thing, I open the issue and watch it get fixed that week.",
    photo: "1588873281272-14886ba1f737",
    alt: "A support lead gesturing to a customer on a laptop video call",
  },
  {
    name: "Marcus Feld",
    role: "Data Scientist, 4 years",
    team: "Data",
    city: "Berlin",
    quote:
      "Questions arrive with context and the time to answer them properly. I spend my weeks on analysis, not on dashboards nobody opens.",
    photo: "1758876022473-d3d8513f15d3",
    alt: "Two colleagues talking in front of a wall of sticky notes",
  },
  {
    name: "Aisha Rahman",
    role: "Engineering Manager, 6 years",
    team: "Mobile",
    city: "London",
    quote:
      "Focus weeks sounded like a perk when I joined. They turned out to be how we plan, and my team has never shipped calmer.",
    photo: "1768471125958-78556538fadc",
    alt: "An engineering manager working quietly in a studio full of plants",
  },
  {
    name: "Tomás Ibarra",
    role: "Brand Designer, 1 year",
    team: "Brand",
    city: "Mexico City",
    quote:
      "In my first month I redrew the icon set and nobody asked me to wait my turn. The bar is high, and so is the trust.",
    photo: "1761122827167-159d1d272313",
    alt: "A designer in a cap sketching icons on a tablet at his desk",
  },
];

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=880&h=840&q=80`;

export function RelayCarouselCareersDemo() {
  return (
    <div className="flex min-h-[var(--demo-height,auto)] w-full flex-col justify-center overflow-hidden rounded-2xl bg-zinc-950 py-14 text-white sm:py-20">
      <RelayCarousel
        aria-labelledby="relay-careers-heading"
        heading={
          <h2
            id="relay-careers-heading"
            className="text-[2rem] leading-[1.02] font-semibold tracking-[-0.035em] text-balance @2xl:text-5xl"
          >
            Ask the people{" "}
            <br />
            <span className="text-zinc-500">who build Okto.</span>
          </h2>
        }
      >
        {team.map((person) => (
          <RelayCarouselItem
            key={person.name}
            name={person.name}
            role={person.role}
            label={`${person.team} · ${person.city}`}
            quote={person.quote}
            image={photo(person.photo)}
            alt={person.alt}
          >
            <a
              href="#open-roles"
              aria-label={`Open roles in ${person.team}`}
              className="group/link grid size-9 place-items-center rounded-full bg-white text-zinc-950 transition-transform duration-200 ease-out outline-white hover:scale-105 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95"
            >
              <svg
                aria-hidden
                viewBox="0 0 16 16"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.6"
                strokeLinecap="round"
                strokeLinejoin="round"
                className="size-4 transition-transform duration-200 ease-out group-hover/link:translate-x-px group-hover/link:-translate-y-px"
              >
                <path d="M5 11 11 5M6 5h5v5" />
              </svg>
            </a>
          </RelayCarouselItem>
        ))}
      </RelayCarousel>
    </div>
  );
}
```

### Conference Speakers

A speaker lineup with each talk's line in a serif through `quoteClassName`. The schedule under the heading follows the lead through `index` and `onIndexChange`, and picking a time moves the strip the short way round.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/relay-carousel-speakers-demo
```

```tsx title="components/relay-carousel-speakers-demo.tsx"
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { RelayCarousel, RelayCarouselItem } from "@/components/ui/relay-carousel";

const talks = [
  {
    name: "Ana Duarte",
    role: "Head of Design, Tinta",
    day: "Thu",
    time: "10:00",
    title: "Designing for the second visit",
    quote:
      "The first visit is a demo. The second one is the product, and that's where most of us stop designing.",
    photo: "1679579664878-2c025e3f87b0",
    alt: "A speaker with a headset microphone gesturing on a pink-lit stage",
  },
  {
    name: "Kwame Asante",
    role: "CTO, Okto",
    day: "Thu",
    time: "11:30",
    title: "Boring infrastructure, on purpose",
    quote:
      "Every clever system I've built has paged me at three in the morning. The boring ones keep running while I sleep.",
    photo: "1731395157985-76af28b68afa",
    alt: "A speaker in a varsity jacket talking into a handheld microphone",
  },
  {
    name: "Mei Lin",
    role: "Research Lead, Northwind",
    day: "Thu",
    time: "14:00",
    title: "What people do when nobody's watching",
    quote:
      "Interviews tell you what people remember. Session notes tell you what they did, and the two rarely agree.",
    photo: "1772419216340-fd8abb4f55de",
    alt: "A speaker with a handheld microphone at a lectern against a dark backdrop",
  },
  {
    name: "Jonas Berg",
    role: "Founder, Field & Fold",
    day: "Thu",
    time: "16:30",
    title: "Pricing a tool for small shops",
    quote:
      "Small shops don't want a cheaper plan. They want a price they can explain to their accountant in one sentence.",
    photo: "1575707751065-42256084fbb7",
    alt: "A speaker in a navy blazer mid-sentence against warm event lights",
  },
  {
    name: "Oliver Grant",
    role: "Principal Engineer, Northwind",
    day: "Fri",
    time: "09:30",
    title: "Accessibility is a performance problem",
    quote:
      "A page that waits on twelve scripts before it answers a screen reader isn't slow for some people. It's broken for them.",
    photo: "1762158007969-eb58e74ee3d3",
    alt: "A speaker in glasses with his arms open wide at a podium",
  },
  {
    name: "Ravi Menon",
    role: "Motion Designer, Tinta",
    day: "Fri",
    time: "11:00",
    title: "Motion with a job to do",
    quote:
      "Animation earns its place when it explains where something went. The rest is a loading spinner in disguise.",
    photo: "1769755409608-e61191208f91",
    alt: "A speaker in a black blazer holding a microphone on a dark stage",
  },
];

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=880&h=840&q=80`;

export function RelayCarouselSpeakersDemo() {
  const [index, setIndex] = React.useState(0);
  const talk = talks[index];

  return (
    <div className="flex min-h-[var(--demo-height,auto)] w-full flex-col justify-center overflow-hidden rounded-2xl border border-stone-200 bg-stone-100 py-14 text-stone-950 sm:py-20 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-50">
      <RelayCarousel
        aria-labelledby="relay-speakers-heading"
        index={index}
        onIndexChange={setIndex}
        heading={
          <div>
            <h2
              id="relay-speakers-heading"
              className="text-[2rem] leading-[1.02] font-semibold tracking-[-0.035em] text-balance @2xl:text-5xl"
            >
              On stage at{" "}
              <br />
              <span className="text-stone-400 dark:text-stone-500">Northwind Live.</span>
            </h2>
            <p className="mt-5 max-w-sm text-sm leading-6 text-stone-600 dark:text-stone-400">
              <span className="font-medium text-stone-950 tabular-nums dark:text-stone-50">
                {talk.day} {talk.time}
              </span>{" "}
              · {talk.title}
            </p>
            <ol aria-label="Schedule" className="mt-4 flex flex-wrap items-center gap-1.5">
              {talks.map((item, i) => (
                <li key={item.name} className="flex items-center gap-1.5">
                  {item.day !== talks[i - 1]?.day && (
                    <span
                      aria-hidden
                      className={cn(
                        "pr-1 text-xs font-medium text-stone-500 dark:text-stone-400",
                        i > 0 && "pl-2.5",
                      )}
                    >
                      {item.day}
                    </span>
                  )}
                  <button
                    type="button"
                    aria-current={i === index ? "true" : undefined}
                    aria-label={`${item.day} ${item.time}, ${item.name}`}
                    onClick={() => setIndex(i)}
                    className={cn(
                      "h-8 cursor-pointer rounded-full border px-3 text-xs font-medium tabular-nums transition-colors duration-200",
                      "outline-stone-950 focus-visible:outline-2 focus-visible:outline-offset-2 dark:outline-stone-50",
                      i === index
                        ? "border-stone-950 bg-stone-950 text-stone-50 dark:border-stone-50 dark:bg-stone-50 dark:text-stone-950"
                        : "border-stone-300 text-stone-600 hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-400 dark:hover:border-stone-500 dark:hover:text-stone-50",
                    )}
                  >
                    {item.time}
                  </button>
                </li>
              ))}
            </ol>
          </div>
        }
      >
        {talks.map((item) => (
          <RelayCarouselItem
            key={item.name}
            name={item.name}
            role={item.role}
            label={`${item.day} ${item.time} · Main stage`}
            quote={item.quote}
            quoteClassName="font-serif font-normal tracking-normal text-[clamp(1.0625rem,calc(var(--relay-l)_*_0.056),1.4375rem)] leading-[1.32]"
            image={photo(item.photo)}
            alt={item.alt}
          />
        ))}
      </RelayCarousel>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `React.ReactNode` |  | The cards, as `RelayCarouselItem`s. The strip loops, and repeats them when there are too few to fill a wide screen. |
| `heading` | `React.ReactNode` |  | Shown top left, lined up with the lead card. The controls sit opposite it, and move under the strip on narrow screens. |
| `autoPlay` | `number` |  | Passes the lead on every this many ms. Adds a pause button whose ring fills as the timer. Holds while the pointer or keyboard focus is inside or the carousel is off screen. Off for reduced motion until the visitor presses play. |
| `defaultIndex` | `number` | `0` | The card that leads first. |
| `index` | `number` |  | The card that leads. Set it with `onIndexChange` to control the carousel from outside; it moves the short way round. |
| `onIndexChange` | `(index: number) => void` |  | Called with the new lead's index after a button, a click on a card, a drag, a swipe, a key or autoplay moves it. |
| `inset` | `string` | `"max(2.5rem, calc((100cqi - 76rem) / 2))"` | Left edge of the heading and the lead card, as a CSS length (1.25rem minimum on narrow screens). `cqi` is the carousel's width, so the default lines up with a 76rem page column. Cards that already led fade out in this margin. |
| `duration` | `number` | `750` | Milliseconds the strip takes to glide to a new lead, on a spring with no bounce. A flick's speed carries into it. |
| `stagger` | `number` | `80` | Milliseconds between the lead's quote lines setting in. Content in `children` follows the last line. |
| `drift` | `number` | `14` | How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. Off for reduced motion. |
| `className` | `string` |  | Classes for the section. The controls are drawn in its text color. |
| `item.name` (required) | `string` |  | `RelayCarouselItem`: who said it. Shown on every card. |
| `item.role` | `React.ReactNode` |  | `RelayCarouselItem`: their role and company, under the name. |
| `item.quote` (required) | `string` |  | `RelayCarouselItem`: what they said, without quotation marks. Shown while the card leads; around 130 characters fits a phone. |
| `item.image` (required) | `string` |  | `RelayCarouselItem`: photo URL. It's cut to the lead card's size and anchored at the top, so a resting card shows its top middle. |
| `item.alt` | `string` | `""` | `RelayCarouselItem`: alt text for the photo. |
| `item.label` | `React.ReactNode` |  | `RelayCarouselItem`: small text in the lead card's top corner, like a place or a product. |
| `item.children` | `React.ReactNode` |  | `RelayCarouselItem`: pinned to the lead card's bottom right corner once its quote is in, like a link to the full story. |
| `item.quoteClassName` | `string` |  | `RelayCarouselItem`: classes for the quote, like a serif face or a size. |
| `item.imageClassName` | `string` |  | `RelayCarouselItem`: classes for the photo. The card is a `group/relay` with `data-state="lead"` or `"rest"`. |
| `item.className` | `string` |  | `RelayCarouselItem`: classes for the card, like its corner radius or background. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/relay-carousel.tsx`, keep these, which make it feel and behave right:

- Every card is placed and sized from one motion value, `place`, through CSS variables (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server renders the strip already laid out.
- The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging.
- A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a release never snaps.
- The strip is endless: slots are virtualized around the lead, and only the copy nearest the lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`.
- Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off without a hydration mismatch; it also jumps the strip and holds autoplay until played.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/relay-carousel.tsx"
"use client";

import * as React from "react";
import {
  animate,
  AnimatePresence,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
  type AnimationPlaybackControls,
  type MotionStyle,
  type MotionValue,
} from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - Every card is placed and sized from one motion value, `place`, through CSS variables
 *   (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server
 *   renders the strip already laid out.
 * - The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in
 *   line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging.
 * - A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a
 *   release never snaps.
 * - The strip is endless: slots are virtualized around the lead, and only the copy nearest the
 *   lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`.
 * - Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off
 *   without a hydration mismatch; it also jumps the strip and holds autoplay until played.
 */
const TIMING = {
  /** The strip gliding to a new lead: a spring with no bounce, over `duration`. */
  glide: { type: "spring", bounce: 0 },
  /** How long the strip takes to glide to a new lead, in ms (the `duration` prop). */
  duration: 750,
  /** Between the lead's quote lines setting in, in ms (the `stagger` prop). */
  stagger: 80,
  /** The strip leaning toward a hovered arrow, and back. */
  lean: { type: "spring", visualDuration: 0.35, bounce: 0 },
  /** Release speed carried into where a drag lands, in seconds of travel. */
  flick: 0.2,
  /** How recently the pointer must have moved for a release to carry its speed, in ms. */
  fresh: 80,
  /** Without sideways wheel input before the strip snaps, in ms. */
  wheelIdle: 140,
  /** The autoplay ring filling over `autoPlay`, as an easing. */
  ring: "linear",
  /** The ring fading as autoplay pauses and plays, as a Tailwind class. */
  ringFade: "duration-300",
  /** A quote line, label or extra lifting off when its card stops leading, as Tailwind classes. */
  hide: "duration-200 ease-out",
  /** A quote line setting in from the blur once the strip lands, as Tailwind classes. */
  show: "group-data-[shown=true]/relay:duration-700 group-data-[shown=true]/relay:ease-[cubic-bezier(0.16,1,0.3,1)]",
  /** A resting card's photo brightening on hover, as a Tailwind class. */
  brighten: "duration-500",
  /** A round button's hover and press, as Tailwind classes. */
  button: "duration-200 ease-out",
  /** An arrow nudging on its button's hover, as Tailwind classes. */
  nudge: "duration-200 ease-out",
  /** The counter's digits rolling to the new lead. */
  count: { type: "spring", visualDuration: 0.4, bounce: 0 },
} as const;

/* ─────────────────────────────────────────────────────────
 * RELAY CAROUSEL STORYBOARD
 *
 *  rest     a strip of photo cards that runs off the right edge.
 *           The first card is the lead: wider and taller than
 *           the rest, its photo under a scrim, its quote set
 *           above the name. The others show a name and a role.
 *           Cards that already led fade into the left margin
 *  relay    next/prev, a click on a card, keys or autoplay pass
 *           the lead on. The leaving quote lifts off first, then
 *           the strip glides on one spring: the old lead shrinks
 *           and slides out while the new one grows into its
 *           place. Photos drift inside their cards as they
 *           travel (14px a card) and the lead's eases back from
 *           a 6% zoom. Once the strip has nearly landed, the new
 *           quote sets in line by line from a soft blur
 *  drag     pull the strip with a finger or the mouse, or swipe
 *           sideways on a trackpad. It snaps to the nearest
 *           card, carrying the flick's speed
 *  play     with autoPlay, the ring around the pause button fills
 *           as the timer. It holds while the pointer or keyboard
 *           focus is inside, or the carousel is off screen
 *  loop     the strip never ends. Cards are virtualized and
 *           repeated when there are too few to fill a wide screen
 *  narrow   the lead fills the width with the next card peeking,
 *           and the controls move under the strip
 *  reduced  no glide, blur or autoplay: the lead switches at once
 * ───────────────────────────────────────────────────────── */

const RELAY = {
  settle: 0.3, // cards from rest at which the new quote starts to set in
  drag: 6, // px a press travels before it counts as a drag
  lean: 0.04, // cards the strip leans toward an arrow under the pointer
} as const;

// Card sizes live in CSS, so the server renders the strip already laid out. cqi is the carousel's width.
const SIZES = [
  "[--relay-gap:0.75rem]",
  "[--relay-l:min(calc(100cqi_-_2_*_var(--relay-inset)_-_2.75rem),22rem)]",
  "[--relay-s:calc(var(--relay-l)_*_0.6)]",
  "[--relay-hs:calc(var(--relay-s)_*_1.3)]",
  "[--relay-hl:calc(var(--relay-l)_*_1.12)]",
  "@2xl:[--relay-gap:1rem]",
  "@2xl:[--relay-l:clamp(19rem,40cqi,25rem)]",
  "@2xl:[--relay-s:calc(var(--relay-l)_*_0.62)]",
  "@2xl:[--relay-hs:calc(var(--relay-s)_*_1.25)]",
  "@2xl:[--relay-hl:calc(var(--relay-hs)_*_1.2)]",
].join(" ");

const INSET =
  "[--relay-inset:max(1.25rem,calc((100cqi_-_76rem)_/_2))] @2xl:[--relay-inset:max(2.5rem,calc((100cqi_-_76rem)_/_2))]";

const mod = (n: number, m: number) => ((n % m) + m) % m;

type Metrics = { width: number; inset: number; s: number; l: number; gap: number };

type Relay = {
  count: number;
  lead: number;
  revealed: number | null;
  /** ms between quote lines setting in. */
  stagger: number;
  /** px a photo drifts inside its card per card travelled. */
  drift: number;
  /** Where the strip is, in slots: the glide plus any lean toward a hovered arrow. */
  place: MotionValue<number>;
  go: (slot: number) => void;
  onKeyDown: (event: React.KeyboardEvent) => void;
};

type Slot = { slot: number; index: number; canonical: boolean };

const RelayContext = React.createContext<Relay | null>(null);
const SlotContext = React.createContext<Slot>({ slot: 0, index: 0, canonical: true });

function useRelay() {
  const relay = React.useContext(RelayContext);
  if (!relay) throw new Error("<RelayCarouselItem> must be rendered inside <RelayCarousel>.");
  return relay;
}

const subscribeNothing = () => () => {};

/* ── RelayCarousel ──────────────────────────────────────── */

export interface RelayCarouselProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange"> {
  /** Shown top left, in line with the lead card. The controls sit opposite it. */
  heading?: React.ReactNode;
  /** The card that leads. Leave unset to let the carousel manage it. */
  index?: number;
  /** The card that leads first. */
  defaultIndex?: number;
  /** Called with the new lead's index, whatever moved it. */
  onIndexChange?: (index: number) => void;
  /** Passes the lead on every this many ms, with a pause button. Off for reduced motion until played. */
  autoPlay?: number;
  /** Left edge of the heading and the lead card, as a CSS length. `cqi` is the carousel's width. */
  inset?: string;
  /** Milliseconds the strip takes to glide to a new lead. */
  duration?: number;
  /** Milliseconds between the lead's quote lines setting in. */
  stagger?: number;
  /** How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. */
  drift?: number;
}

/**
 * A looping strip of photo cards. The first card leads, larger, with its quote
 * showing; passing the lead on slides the strip and grows the next card into place.
 */
export function RelayCarousel({
  heading,
  index,
  defaultIndex = 0,
  onIndexChange,
  autoPlay,
  inset,
  duration = TIMING.duration,
  stagger = TIMING.stagger,
  drift = 14,
  className,
  children,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: RelayCarouselProps) {
  const items = React.Children.toArray(children).filter(
    (child): child is React.ReactElement<RelayCarouselItemProps> => React.isValidElement(child),
  );
  const count = items.length;
  const reduced = useReducedMotion() ?? false;
  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false);
  const id = React.useId();
  const trackId = `${id}-track`;

  /* The lead is a slot on an endless strip; its item is the slot modulo the count. */
  const start = count ? mod(index ?? defaultIndex, count) : 0;
  const [lead, setLead] = React.useState({ slot: start, turn: 0 });
  const [step, setStep] = React.useState(1);
  const [announcement, setAnnouncement] = React.useState("");
  const active = count ? mod(lead.slot, count) : -1;

  // A controlled index moves the lead the short way round.
  const [syncedIndex, setSyncedIndex] = React.useState(index);
  if (index !== syncedIndex) {
    setSyncedIndex(index);
    if (index !== undefined && count && mod(index, count) !== active) {
      let delta = mod(index - active, count);
      if (delta > count / 2) delta -= count;
      setLead({ slot: lead.slot + delta, turn: lead.turn + 1 });
      setStep(Math.sign(delta));
    }
  }

  const pos = useMotionValue(start);
  // Pointing at an arrow leans the strip a little that way, a hint of where it's about to go.
  const lean = useMotionValue(0);
  const place = useTransform(() => pos.get() + lean.get());
  const leanTo = (to: number) => {
    if (!reduced) animate(lean, to, TIMING.lean);
  };
  const glide = React.useRef<AnimationPlaybackControls | null>(null);
  const release = React.useRef(0);
  const latest = React.useRef({ lead, count, items, onIndexChange });
  React.useLayoutEffect(() => {
    latest.current = { lead, count, items, onIndexChange };
  });

  const go = React.useCallback((slot: number, source: "user" | "auto" = "user") => {
    const { lead, count, items, onIndexChange } = latest.current;
    if (!count) return;
    const next = { slot, turn: lead.turn + 1 };
    latest.current.lead = next;
    setLead(next);
    if (slot !== lead.slot) setStep(Math.sign(slot - lead.slot));
    const index = mod(slot, count);
    if (source === "user") {
      const { name, role, quote } = items[index].props;
      setAnnouncement(
        `${index + 1} of ${count}. ${name}${typeof role === "string" ? `, ${role}` : ""}. “${quote}”`,
      );
    }
    if (slot !== lead.slot) onIndexChange?.(index);
  }, []);

  // Every new lead (or a snap back after a short drag) glides the strip to it.
  React.useEffect(() => {
    if (reduced) {
      pos.jump(lead.slot);
      return;
    }
    const controls = animate(pos, lead.slot, {
      ...TIMING.glide,
      visualDuration: duration / 1000,
      velocity: release.current,
    });
    release.current = 0;
    glide.current = controls;
    return () => controls.stop();
  }, [lead, reduced, pos, duration]);

  /* What the strip shows: the slots around the lead and wherever the strip is passing. */
  const [span, setSpan] = React.useState<[number, number]>([start, start]);
  const [revealed, setRevealed] = React.useState<number | null>(start);
  const [dragging, setDragging] = React.useState(false);
  useMotionValueEvent(pos, "change", (value) => {
    const low = Math.floor(value);
    const high = Math.ceil(value);
    setSpan((prev) => (prev[0] === low && prev[1] === high ? prev : [low, high]));
    const slot = latest.current.lead.slot;
    setRevealed(Math.abs(value - slot) < RELAY.settle ? slot : null);
  });

  const trackRef = React.useRef<HTMLDivElement>(null);
  const probeRef = React.useRef<HTMLDivElement>(null);
  const [metrics, setMetrics] = React.useState<Metrics | null>(null);
  React.useLayoutEffect(() => {
    const track = trackRef.current;
    const probe = probeRef.current;
    if (!track || !probe) return;
    const [inset, s, l, gap] = Array.from(probe.children) as HTMLElement[];
    const observer = new ResizeObserver(() => {
      const next = {
        width: track.clientWidth,
        inset: inset.offsetWidth,
        s: s.offsetWidth,
        l: l.offsetWidth,
        gap: gap.offsetWidth,
      };
      setMetrics((prev) =>
        prev &&
        prev.width === next.width &&
        prev.inset === next.inset &&
        prev.s === next.s &&
        prev.l === next.l &&
        prev.gap === next.gap
          ? prev
          : next,
      );
    });
    observer.observe(track);
    for (const el of [inset, s, l, gap]) observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const pitch = metrics ? metrics.s + metrics.gap : 1;
  const before = metrics ? Math.ceil(metrics.inset / pitch) + 1 : 2;
  const after = metrics ? Math.ceil((metrics.width - metrics.inset - metrics.l) / pitch) + 1 : 0;
  const low = Math.min(span[0], lead.slot) - before;
  const high = Math.max(Math.max(span[1], lead.slot) + after, low + count - 1);
  const slots: Slot[] = [];
  if (count === 1) slots.push({ slot: lead.slot, index: 0, canonical: true });
  else if (count) {
    // Each item is announced once: the copy nearest the lead. Repeats are decoration.
    const nearest = new Map<number, number>();
    for (let slot = low; slot <= high; slot++) {
      const i = mod(slot, count);
      const best = nearest.get(i);
      if (best === undefined || Math.abs(slot - lead.slot) <= Math.abs(best - lead.slot)) {
        nearest.set(i, slot);
      }
    }
    for (let slot = low; slot <= high; slot++) {
      const i = mod(slot, count);
      slots.push({ slot, index: i, canonical: nearest.get(i) === slot });
    }
  }

  /* Autoplay: the ring fills while nothing holds it, then the lead moves on. */
  const progress = useMotionValue(0);
  const [paused, setPaused] = React.useState<boolean | null>(null);
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [seen, setSeen] = React.useState(true);
  const canPlay = !!autoPlay && autoPlay > 0 && count > 1;
  const playing = canPlay && !(paused ?? (hydrated && reduced));
  const held = hovered || focused || dragging || !seen;

  React.useLayoutEffect(() => {
    progress.jump(0);
  }, [lead.slot, progress]);

  React.useEffect(() => {
    if (!playing || held) return;
    const controls = animate(progress, 1, {
      duration: ((autoPlay ?? 0) / 1000) * (1 - progress.get()),
      ease: TIMING.ring,
      onComplete: () => go(latest.current.lead.slot + 1, "auto"),
    });
    return () => controls.stop();
  }, [playing, held, lead.slot, autoPlay, progress, go]);

  React.useEffect(() => {
    const track = trackRef.current;
    if (!canPlay || !track) return;
    let inView = true;
    const update = () => setSeen(inView && document.visibilityState === "visible");
    const observer = new IntersectionObserver(
      ([entry]) => {
        inView = entry.isIntersecting;
        update();
      },
      { threshold: 0.25 },
    );
    observer.observe(track);
    document.addEventListener("visibilitychange", update);
    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", update);
    };
  }, [canPlay]);

  /* Dragging: the strip follows the pointer, then lands where the flick was heading. */
  const pointer = React.useRef({
    id: -1,
    x: 0,
    from: 0,
    moved: false,
    swallow: false,
    samples: [] as { x: number; t: number }[],
  });

  const unit = () => {
    const m = metrics;
    return m ? (m.s + m.l) / 2 + m.gap : 300;
  };

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    const p = pointer.current;
    p.swallow = false;
    if (count < 2 || event.button !== 0) return;
    if ((event.target as HTMLElement).closest("a, input, textarea, select, [data-relay-extra]")) return;
    p.id = event.pointerId;
    p.x = event.clientX;
    p.from = pos.get();
    p.moved = false;
    p.samples = [{ x: event.clientX, t: event.timeStamp }];
  };

  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const p = pointer.current;
    if (event.pointerId !== p.id) return;
    const dx = event.clientX - p.x;
    if (!p.moved) {
      if (Math.abs(dx) < RELAY.drag) return;
      p.moved = true;
      glide.current?.stop();
      p.from = pos.get() + dx / unit();
      event.currentTarget.setPointerCapture(event.pointerId);
      setDragging(true);
    }
    pos.set(p.from - dx / unit());
    p.samples.push({ x: event.clientX, t: event.timeStamp });
    if (p.samples.length > 6) p.samples.shift();
  };

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    const p = pointer.current;
    if (event.pointerId !== p.id) return;
    p.id = -1;
    if (!p.moved) return;
    p.swallow = true;
    setDragging(false);
    const first = p.samples[0];
    const last = p.samples[p.samples.length - 1];
    const elapsed = last.t - first.t;
    const speed = elapsed > 0 && event.timeStamp - last.t < TIMING.fresh ? (last.x - first.x) / elapsed : 0; // px/ms
    const cards = -(speed * 1000) / unit(); // cards/s, positive toward the next card
    const reach = Math.max(1, Math.ceil((metrics?.width ?? 1000) / pitch) - 1);
    const landing = Math.round(pos.get() + cards * TIMING.flick);
    const slot = Math.max(Math.round(p.from) - reach, Math.min(Math.round(p.from) + reach, landing));
    release.current = reduced ? 0 : cards;
    go(slot);
  };

  /* Sideways trackpad swipes scrub the strip and snap once they stop. */
  React.useEffect(() => {
    const track = trackRef.current;
    if (!track || count < 2) return;
    let timer = 0;
    const onWheel = (event: WheelEvent) => {
      if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
      event.preventDefault();
      glide.current?.stop();
      setDragging(true);
      const m = metrics;
      const size = m ? (m.s + m.l) / 2 + m.gap : 300;
      pos.set(pos.get() + event.deltaX / size);
      window.clearTimeout(timer);
      timer = window.setTimeout(() => {
        setDragging(false);
        go(Math.round(pos.get()));
      }, TIMING.wheelIdle);
    };
    track.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      track.removeEventListener("wheel", onWheel);
      window.clearTimeout(timer);
    };
  }, [count, metrics, pos, go]);

  /* Keys: ←/→ pass the lead, Home/End go the short way to the first and last card. Focus follows the lead. */
  const keyboard = React.useRef(false);
  const onKeyDown = (event: React.KeyboardEvent) => {
    const slot = latest.current.lead.slot;
    const toIndex = (i: number) => {
      let delta = mod(i - slot, count);
      if (delta > count / 2) delta -= count;
      return slot + delta;
    };
    let to: number | null = null;
    if (event.key === "ArrowRight") to = slot + 1;
    else if (event.key === "ArrowLeft") to = slot - 1;
    else if (event.key === "Home") to = toIndex(0);
    else if (event.key === "End") to = toIndex(count - 1);
    if (to === null) return;
    event.preventDefault();
    keyboard.current = true;
    go(to);
  };
  React.useEffect(() => {
    if (!keyboard.current) return;
    keyboard.current = false;
    trackRef.current?.querySelector<HTMLElement>(`[data-relay-slot="${lead.slot}"]`)?.focus();
  }, [lead]);

  return (
    <RelayContext.Provider
      value={{ count, lead: lead.slot, revealed: dragging ? null : revealed, stagger, drift, place, go, onKeyDown }}
    >
      <section
        {...props}
        aria-roledescription="carousel"
        className={cn("@container relative w-full", className)}
        onPointerEnter={(event) => {
          if (event.pointerType === "mouse") setHovered(true);
          onPointerEnter?.(event);
        }}
        onPointerLeave={(event) => {
          setHovered(false);
          onPointerLeave?.(event);
        }}
        onFocus={(event) => {
          if (event.target.matches(":focus-visible")) setFocused(true);
          onFocus?.(event);
        }}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
          onBlur?.(event);
        }}
      >
        <div
          className={cn("grid grid-cols-[minmax(0,1fr)_auto]", inset === undefined && INSET, SIZES)}
          style={inset === undefined ? undefined : ({ "--relay-inset": inset } as React.CSSProperties)}
        >
          {heading != null && (
            <div className="col-start-1 row-start-1 min-w-0 self-end pr-4 pl-[var(--relay-inset)] @2xl:pr-10">
              {heading}
            </div>
          )}

          <div
            className={cn(
              "col-span-2 row-start-3 mt-5 flex items-center gap-2 px-[var(--relay-inset)]",
              "@2xl:col-span-1 @2xl:col-start-2 @2xl:row-start-1 @2xl:mt-0 @2xl:self-end @2xl:pl-0",
              count < 2 && "hidden",
            )}
          >
            <Counter active={active} count={count} step={step} reduced={reduced} />
            <RoundButton
              label="Previous"
              controls={trackId}
              onClick={() => go(lead.slot - 1)}
              onHover={(on) => leanTo(on ? -RELAY.lean : 0)}
            >
              <Arrow direction={-1} />
            </RoundButton>
            <RoundButton
              label="Next"
              controls={trackId}
              onClick={() => go(lead.slot + 1)}
              onHover={(on) => leanTo(on ? RELAY.lean : 0)}
            >
              <Arrow direction={1} />
            </RoundButton>
            {canPlay && (
              <RoundButton
                label={playing ? "Pause" : "Play"}
                controls={trackId}
                onClick={() => setPaused(playing)}
              >
                <svg aria-hidden viewBox="0 0 40 40" className="pointer-events-none absolute -inset-px -rotate-90">
                  <motion.circle
                    cx="20"
                    cy="20"
                    r="19.25"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    style={{ pathLength: progress, opacity: playing ? 1 : 0 }}
                    className={`transition-opacity ${TIMING.ringFade}`}
                  />
                </svg>
                {playing ? <PauseIcon /> : <PlayIcon />}
              </RoundButton>
            )}
          </div>

          <div
            ref={trackRef}
            id={trackId}
            className={cn(
              "relative col-span-2 row-start-2 -my-2 h-[calc(var(--relay-hl)_+_1rem)] touch-pan-y overflow-hidden select-none",
              // Cards that already led fade into the margin. Phones have no room for one, so it's hidden there.
              "[mask-image:linear-gradient(to_right,transparent_calc(var(--relay-inset)_-_var(--relay-gap)),#000_calc(var(--relay-inset)_-_0.25rem))]",
              "@2xl:[mask-image:linear-gradient(to_right,transparent,#000_calc(var(--relay-inset)_-_0.5rem))]",
              heading != null ? "mt-6 @2xl:mt-10" : "@2xl:mt-6",
              count > 1 && (dragging ? "cursor-grabbing" : "cursor-grab"),
            )}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={endDrag}
            onPointerCancel={endDrag}
            onClickCapture={(event) => {
              if (!pointer.current.swallow) return;
              pointer.current.swallow = false;
              event.preventDefault();
              event.stopPropagation();
            }}
            onDragStart={(event) => event.preventDefault()}
          >
            <div ref={probeRef} aria-hidden className="invisible absolute h-0">
              <div className="w-[var(--relay-inset)]" />
              <div className="w-[var(--relay-s)]" />
              <div className="w-[var(--relay-l)]" />
              <div className="w-[var(--relay-gap)]" />
            </div>
            {slots.map((slot) => (
              <SlotContext.Provider key={slot.slot} value={slot}>
                {items[slot.index]}
              </SlotContext.Provider>
            ))}
          </div>
        </div>
        <p aria-live="polite" aria-atomic className="sr-only">
          {announcement}
        </p>
      </section>
    </RelayContext.Provider>
  );
}

/* ── RelayCarouselItem ──────────────────────────────────── */

export interface RelayCarouselItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
  /** Who said it. Shown on every card. */
  name: string;
  /** Their role and company, under the name. */
  role?: React.ReactNode;
  /** What they said, without quotation marks. Shown while the card leads. */
  quote: string;
  /** Photo URL. Cut to the lead card's size, so a resting card shows its top middle. */
  image: string;
  /** Alt text for the photo. */
  alt?: string;
  /** Small text in the lead card's top corner, like a place or a product. */
  label?: React.ReactNode;
  /** Classes for the quote, like a serif face. */
  quoteClassName?: string;
  /** Classes for the photo. The card is a `group/relay` with `data-state="lead"` or `"rest"`. */
  imageClassName?: string;
  /** Pinned to the lead card's bottom right corner, like a link to the full story. */
  children?: React.ReactNode;
}

/** One card. Render inside `RelayCarousel`. */
export function RelayCarouselItem({
  name,
  role,
  quote,
  image,
  alt = "",
  label,
  quoteClassName,
  imageClassName,
  className,
  children,
  ...props
}: RelayCarouselItemProps) {
  const { count, lead, revealed, stagger, drift: driftPx, place, go, onKeyDown } = useRelay();
  const { slot, index, canonical } = React.useContext(SlotContext);
  const isLead = slot === lead;
  const shown = isLead && revealed === slot;

  // Where this card sits, in cards from the lead slot, and how much of the lead it is.
  const offset = useTransform(place, (value) => slot - value);
  const grow = useTransform(offset, (d) => Math.max(0, 1 - Math.abs(d)));
  const ahead = useTransform(offset, (d) => Math.min(1, Math.max(0, d)));
  const drift = useTransform(offset, (d) => Math.max(-2, Math.min(2, d)));

  // Quote lines set in one after another, so each word learns which line it landed on.
  const quoteRef = React.useRef<HTMLParagraphElement>(null);
  const cardRef = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    const paragraph = quoteRef.current;
    const card = cardRef.current;
    if (!paragraph || !card) return;
    const assign = () => {
      let line = -1;
      let top = -Infinity;
      for (const word of Array.from(paragraph.children) as HTMLElement[]) {
        if (word.offsetTop > top + 2) {
          line += 1;
          top = word.offsetTop;
        }
        word.style.setProperty("--line", String(line));
      }
      card.style.setProperty("--lines", String(line + 1));
    };
    assign();
    const observer = new ResizeObserver(assign);
    observer.observe(paragraph);
    document.fonts?.ready.then(assign);
    return () => observer.disconnect();
  }, [quote]);

  const words = quote.trim().split(/\s+/);
  const reveal = `opacity-0 transition-[opacity,translate,filter] ${TIMING.hide} motion-safe:translate-y-[0.5em] motion-safe:blur-[6px] group-data-[shown=true]/relay:opacity-100 ${TIMING.show} motion-safe:group-data-[shown=true]/relay:translate-y-0 motion-safe:group-data-[shown=true]/relay:blur-none`;

  return (
    <motion.div
      ref={cardRef}
      data-relay-slot={slot}
      data-state={isLead ? "lead" : "rest"}
      data-shown={shown}
      role="group"
      aria-roledescription="slide"
      aria-label={`${index + 1} of ${count}: ${name}`}
      aria-hidden={canonical ? undefined : true}
      inert={!canonical}
      tabIndex={isLead && canonical ? 0 : -1}
      onKeyDown={onKeyDown}
      className={cn(
        "group/relay absolute top-2 left-0 outline-none",
        "h-[calc(var(--relay-hs)_+_(var(--relay-hl)_-_var(--relay-hs))_*_var(--grow))] w-[calc(var(--relay-s)_+_(var(--relay-l)_-_var(--relay-s))_*_var(--grow))]",
        "[transform:translateX(calc(var(--relay-inset)_+_var(--offset)_*_(var(--relay-s)_+_var(--relay-gap))_+_var(--ahead)_*_(var(--relay-l)_-_var(--relay-s))))]",
      )}
      style={
        {
          "--offset": offset,
          "--grow": grow,
          "--ahead": ahead,
          "--drift": drift,
          "--relay-drift": `${driftPx}px`,
        } as MotionStyle
      }
    >
      <div
        {...props}
        className={cn(
          "relative isolate size-full overflow-hidden rounded-2xl bg-zinc-800 text-white",
          "outline-current group-focus-visible/relay:outline-2 group-focus-visible/relay:outline-offset-2",
          className,
        )}
      >
        {/* eslint-disable-next-line @next/next/no-img-element -- a remote photo in an installable component */}
        <img
          src={image}
          alt={alt}
          draggable={false}
          decoding="async"
          className={cn(
            "pointer-events-none absolute top-0 left-1/2 h-[var(--relay-hl)] w-[var(--relay-l)] max-w-none object-cover",
            // Drift and zoom are CSS-only, so reduced motion turns them off without a hydration mismatch.
            "[--zoom:0] [--drift-px:0px] motion-safe:[--zoom:0.06] motion-safe:[--drift-px:var(--relay-drift)]",
            "[transform:translateX(calc(-50%_-_var(--drift)_*_var(--drift-px)))_scale(calc(1_+_var(--zoom)_*_(1_-_var(--grow))))] [transform-origin:50%_30%]",
            `transition-[filter] ${TIMING.brighten} group-data-[state=rest]/relay:brightness-[0.88] group-data-[state=rest]/relay:group-hover/relay:brightness-100`,
            imageClassName,
          )}
        />
        {/* A light foot for the caption on every card, and a deeper scrim the lead's quote sits on. */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.62),rgb(0_0_0/0.18)_30%,transparent_52%)]"
        />
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.82),rgb(0_0_0/0.62)_38%,rgb(0_0_0/0.2)_72%,rgb(0_0_0/0.28))] opacity-[var(--grow)]"
        />

        {/* A hairline inner edge keeps a dark card's corners crisp on a dark page. */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 z-20 rounded-[inherit] ring-1 ring-white/10 ring-inset"
        />

        {!isLead && (
          <button
            type="button"
            tabIndex={-1}
            aria-label={`Show ${name}'s quote`}
            onClick={() => go(slot)}
            className="absolute inset-0 z-10 cursor-pointer"
          />
        )}

        {label != null && (
          <div
            className={cn(
              "pointer-events-none absolute top-0 left-0 z-20 p-5 text-xs font-medium tracking-wide text-white/80 @2xl:p-6",
              reveal,
            )}
          >
            {label}
          </div>
        )}

        <div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-[calc(0.875rem_+_0.375rem_*_var(--grow))] @2xl:p-[calc(0.875rem_+_0.625rem_*_var(--grow))]">
          <div className="relative">
            <blockquote
              aria-hidden={isLead ? undefined : true}
              className={cn(
                "absolute bottom-full left-0 mb-4 w-[calc(var(--relay-l)_-_2.5rem)] @2xl:mb-5 @2xl:w-[calc(var(--relay-l)_-_3rem)]",
                "text-[clamp(1rem,calc(var(--relay-l)_*_0.05),1.3125rem)] leading-[1.4] font-medium tracking-[-0.01em] text-pretty",
                quoteClassName,
              )}
            >
              <p ref={quoteRef}>
                {words.map((word, i) => (
                  <React.Fragment key={i}>
                    <span
                      className={cn("relative inline-block", reveal)}
                      style={{ transitionDelay: shown ? `calc(var(--line, 0) * ${stagger}ms)` : undefined }}
                    >
                      {i === 0 && <span className="absolute right-full">“</span>}
                      {word}
                      {i === words.length - 1 && "”"}
                    </span>
                    {i < words.length - 1 && " "}
                  </React.Fragment>
                ))}
              </p>
            </blockquote>
            <div className="max-w-full pr-12">
              <div className="truncate text-sm leading-5 font-medium">{name}</div>
              {role != null && <div className="truncate text-xs leading-4 text-white/70">{role}</div>}
            </div>
          </div>
        </div>

        {children != null && (
          <div
            data-relay-extra
            inert={!isLead}
            className={cn(
              "absolute right-0 bottom-0 z-30 p-3.5 @2xl:p-5",
              reveal,
              "group-data-[state=rest]/relay:pointer-events-none",
            )}
            style={{ transitionDelay: shown ? `calc(var(--lines, 1) * ${stagger}ms)` : undefined }}
          >
            {children}
          </div>
        )}
      </div>
    </motion.div>
  );
}

/* ── Controls ───────────────────────────────────────────── */

function RoundButton({
  label,
  controls,
  onClick,
  onHover,
  children,
}: {
  label: string;
  controls: string;
  onClick: () => void;
  onHover?: (on: boolean) => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      aria-controls={controls}
      onClick={onClick}
      onPointerEnter={(event) => event.pointerType === "mouse" && onHover?.(true)}
      onPointerLeave={() => onHover?.(false)}
      className={cn(
        // Drawn in the text color around it, so the controls suit any background.
        "group/button relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full border border-current/15 @2xl:size-10",
        `transition-[background-color,border-color,scale] ${TIMING.button} hover:border-current/40 hover:bg-current/[0.07] active:scale-[0.94]`,
        "outline-current focus-visible:outline-2 focus-visible:outline-offset-2",
      )}
    >
      {children}
    </button>
  );
}

function Arrow({ direction }: { direction: 1 | -1 }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={cn(
        `size-4 transition-transform ${TIMING.nudge}`,
        direction > 0 ? "group-hover/button:translate-x-0.5" : "-scale-x-100 group-hover/button:-translate-x-0.5",
      )}
    >
      <path d="M3 8h10M9 4l4 4-4 4" />
    </svg>
  );
}

function PauseIcon() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-4">
      <rect x="4" y="3" width="2.5" height="10" rx="0.75" />
      <rect x="9.5" y="3" width="2.5" height="10" rx="0.75" />
    </svg>
  );
}

function PlayIcon() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-4 translate-x-px">
      <path d="M5 3.7v8.6a.7.7 0 0 0 1.06.6l6.9-4.3a.7.7 0 0 0 0-1.2l-6.9-4.3A.7.7 0 0 0 5 3.7Z" />
    </svg>
  );
}

function Counter({
  active,
  count,
  step,
  reduced,
}: {
  active: number;
  count: number;
  step: number;
  reduced: boolean;
}) {
  const pad = (n: number) => String(n).padStart(String(count).length < 2 ? 2 : String(count).length, "0");
  return (
    <div
      aria-hidden
      className="mr-auto flex items-center text-sm leading-5 tabular-nums @2xl:mr-3"
    >
      <span className="relative inline-flex h-5 overflow-hidden">
        <AnimatePresence mode="popLayout" initial={false} custom={step}>
          <motion.span
            key={active}
            custom={step}
            variants={{
              enter: (dir: number) => ({ y: reduced ? 0 : `${dir * 100}%`, opacity: 0 }),
              center: { y: 0, opacity: 1 },
              exit: (dir: number) => ({ y: reduced ? 0 : `${dir * -100}%`, opacity: 0 }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={TIMING.count}
            className="block"
          >
            {pad(active + 1)}
          </motion.span>
        </AnimatePresence>
      </span>
      <span className="px-1.5 opacity-30">/</span>
      <span className="opacity-50">{pad(count)}</span>
    </div>
  );
}
```
