{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "relay-carousel",
  "type": "registry:ui",
  "title": "Relay Carousel",
  "description": "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.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "carousel",
    "testimonials",
    "motion"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/relay-carousel",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/relay-carousel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionStyle,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Every card is placed and sized from one motion value, `place`, through CSS variables\n *   (`--offset`, `--grow`, `--ahead`, `--drift`); the sizes live in CSS (`SIZES`), so the server\n *   renders the strip already laid out.\n * - The leaving quote lifts off first, the strip glides on one spring, and the new quote sets in\n *   line by line only once the strip is within `RELAY.settle` of its slot and nothing is dragging.\n * - A drag lands where the flick was heading and hands its speed to the glide as `velocity`, so a\n *   release never snaps.\n * - The strip is endless: slots are virtualized around the lead, and only the copy nearest the\n *   lead (`canonical`) is announced and focusable; repeats are `aria-hidden` and `inert`.\n * - Photo drift and zoom are CSS-only behind `motion-safe:`, so reduced motion turns them off\n *   without a hydration mismatch; it also jumps the strip and holds autoplay until played.\n */\nconst TIMING = {\n  /** The strip gliding to a new lead: a spring with no bounce, over `duration`. */\n  glide: { type: \"spring\", bounce: 0 },\n  /** How long the strip takes to glide to a new lead, in ms (the `duration` prop). */\n  duration: 750,\n  /** Between the lead's quote lines setting in, in ms (the `stagger` prop). */\n  stagger: 80,\n  /** The strip leaning toward a hovered arrow, and back. */\n  lean: { type: \"spring\", visualDuration: 0.35, bounce: 0 },\n  /** Release speed carried into where a drag lands, in seconds of travel. */\n  flick: 0.2,\n  /** How recently the pointer must have moved for a release to carry its speed, in ms. */\n  fresh: 80,\n  /** Without sideways wheel input before the strip snaps, in ms. */\n  wheelIdle: 140,\n  /** The autoplay ring filling over `autoPlay`, as an easing. */\n  ring: \"linear\",\n  /** The ring fading as autoplay pauses and plays, as a Tailwind class. */\n  ringFade: \"duration-300\",\n  /** A quote line, label or extra lifting off when its card stops leading, as Tailwind classes. */\n  hide: \"duration-200 ease-out\",\n  /** A quote line setting in from the blur once the strip lands, as Tailwind classes. */\n  show: \"group-data-[shown=true]/relay:duration-700 group-data-[shown=true]/relay:ease-[cubic-bezier(0.16,1,0.3,1)]\",\n  /** A resting card's photo brightening on hover, as a Tailwind class. */\n  brighten: \"duration-500\",\n  /** A round button's hover and press, as Tailwind classes. */\n  button: \"duration-200 ease-out\",\n  /** An arrow nudging on its button's hover, as Tailwind classes. */\n  nudge: \"duration-200 ease-out\",\n  /** The counter's digits rolling to the new lead. */\n  count: { type: \"spring\", visualDuration: 0.4, bounce: 0 },\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * RELAY CAROUSEL STORYBOARD\n *\n *  rest     a strip of photo cards that runs off the right edge.\n *           The first card is the lead: wider and taller than\n *           the rest, its photo under a scrim, its quote set\n *           above the name. The others show a name and a role.\n *           Cards that already led fade into the left margin\n *  relay    next/prev, a click on a card, keys or autoplay pass\n *           the lead on. The leaving quote lifts off first, then\n *           the strip glides on one spring: the old lead shrinks\n *           and slides out while the new one grows into its\n *           place. Photos drift inside their cards as they\n *           travel (14px a card) and the lead's eases back from\n *           a 6% zoom. Once the strip has nearly landed, the new\n *           quote sets in line by line from a soft blur\n *  drag     pull the strip with a finger or the mouse, or swipe\n *           sideways on a trackpad. It snaps to the nearest\n *           card, carrying the flick's speed\n *  play     with autoPlay, the ring around the pause button fills\n *           as the timer. It holds while the pointer or keyboard\n *           focus is inside, or the carousel is off screen\n *  loop     the strip never ends. Cards are virtualized and\n *           repeated when there are too few to fill a wide screen\n *  narrow   the lead fills the width with the next card peeking,\n *           and the controls move under the strip\n *  reduced  no glide, blur or autoplay: the lead switches at once\n * ───────────────────────────────────────────────────────── */\n\nconst RELAY = {\n  settle: 0.3, // cards from rest at which the new quote starts to set in\n  drag: 6, // px a press travels before it counts as a drag\n  lean: 0.04, // cards the strip leans toward an arrow under the pointer\n} as const;\n\n// Card sizes live in CSS, so the server renders the strip already laid out. cqi is the carousel's width.\nconst SIZES = [\n  \"[--relay-gap:0.75rem]\",\n  \"[--relay-l:min(calc(100cqi_-_2_*_var(--relay-inset)_-_2.75rem),22rem)]\",\n  \"[--relay-s:calc(var(--relay-l)_*_0.6)]\",\n  \"[--relay-hs:calc(var(--relay-s)_*_1.3)]\",\n  \"[--relay-hl:calc(var(--relay-l)_*_1.12)]\",\n  \"@2xl:[--relay-gap:1rem]\",\n  \"@2xl:[--relay-l:clamp(19rem,40cqi,25rem)]\",\n  \"@2xl:[--relay-s:calc(var(--relay-l)_*_0.62)]\",\n  \"@2xl:[--relay-hs:calc(var(--relay-s)_*_1.25)]\",\n  \"@2xl:[--relay-hl:calc(var(--relay-hs)_*_1.2)]\",\n].join(\" \");\n\nconst INSET =\n  \"[--relay-inset:max(1.25rem,calc((100cqi_-_76rem)_/_2))] @2xl:[--relay-inset:max(2.5rem,calc((100cqi_-_76rem)_/_2))]\";\n\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\n\ntype Metrics = { width: number; inset: number; s: number; l: number; gap: number };\n\ntype Relay = {\n  count: number;\n  lead: number;\n  revealed: number | null;\n  /** ms between quote lines setting in. */\n  stagger: number;\n  /** px a photo drifts inside its card per card travelled. */\n  drift: number;\n  /** Where the strip is, in slots: the glide plus any lean toward a hovered arrow. */\n  place: MotionValue<number>;\n  go: (slot: number) => void;\n  onKeyDown: (event: React.KeyboardEvent) => void;\n};\n\ntype Slot = { slot: number; index: number; canonical: boolean };\n\nconst RelayContext = React.createContext<Relay | null>(null);\nconst SlotContext = React.createContext<Slot>({ slot: 0, index: 0, canonical: true });\n\nfunction useRelay() {\n  const relay = React.useContext(RelayContext);\n  if (!relay) throw new Error(\"<RelayCarouselItem> must be rendered inside <RelayCarousel>.\");\n  return relay;\n}\n\nconst subscribeNothing = () => () => {};\n\n/* ── RelayCarousel ──────────────────────────────────────── */\n\nexport interface RelayCarouselProps extends Omit<React.HTMLAttributes<HTMLElement>, \"onChange\"> {\n  /** Shown top left, in line with the lead card. The controls sit opposite it. */\n  heading?: React.ReactNode;\n  /** The card that leads. Leave unset to let the carousel manage it. */\n  index?: number;\n  /** The card that leads first. */\n  defaultIndex?: number;\n  /** Called with the new lead's index, whatever moved it. */\n  onIndexChange?: (index: number) => void;\n  /** Passes the lead on every this many ms, with a pause button. Off for reduced motion until played. */\n  autoPlay?: number;\n  /** Left edge of the heading and the lead card, as a CSS length. `cqi` is the carousel's width. */\n  inset?: string;\n  /** Milliseconds the strip takes to glide to a new lead. */\n  duration?: number;\n  /** Milliseconds between the lead's quote lines setting in. */\n  stagger?: number;\n  /** How far each photo drifts inside its card per card the strip travels, in px. `0` keeps the photos still. */\n  drift?: number;\n}\n\n/**\n * A looping strip of photo cards. The first card leads, larger, with its quote\n * showing; passing the lead on slides the strip and grows the next card into place.\n */\nexport function RelayCarousel({\n  heading,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  autoPlay,\n  inset,\n  duration = TIMING.duration,\n  stagger = TIMING.stagger,\n  drift = 14,\n  className,\n  children,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: RelayCarouselProps) {\n  const items = React.Children.toArray(children).filter(\n    (child): child is React.ReactElement<RelayCarouselItemProps> => React.isValidElement(child),\n  );\n  const count = items.length;\n  const reduced = useReducedMotion() ?? false;\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n  const id = React.useId();\n  const trackId = `${id}-track`;\n\n  /* The lead is a slot on an endless strip; its item is the slot modulo the count. */\n  const start = count ? mod(index ?? defaultIndex, count) : 0;\n  const [lead, setLead] = React.useState({ slot: start, turn: 0 });\n  const [step, setStep] = React.useState(1);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const active = count ? mod(lead.slot, count) : -1;\n\n  // A controlled index moves the lead the short way round.\n  const [syncedIndex, setSyncedIndex] = React.useState(index);\n  if (index !== syncedIndex) {\n    setSyncedIndex(index);\n    if (index !== undefined && count && mod(index, count) !== active) {\n      let delta = mod(index - active, count);\n      if (delta > count / 2) delta -= count;\n      setLead({ slot: lead.slot + delta, turn: lead.turn + 1 });\n      setStep(Math.sign(delta));\n    }\n  }\n\n  const pos = useMotionValue(start);\n  // Pointing at an arrow leans the strip a little that way, a hint of where it's about to go.\n  const lean = useMotionValue(0);\n  const place = useTransform(() => pos.get() + lean.get());\n  const leanTo = (to: number) => {\n    if (!reduced) animate(lean, to, TIMING.lean);\n  };\n  const glide = React.useRef<AnimationPlaybackControls | null>(null);\n  const release = React.useRef(0);\n  const latest = React.useRef({ lead, count, items, onIndexChange });\n  React.useLayoutEffect(() => {\n    latest.current = { lead, count, items, onIndexChange };\n  });\n\n  const go = React.useCallback((slot: number, source: \"user\" | \"auto\" = \"user\") => {\n    const { lead, count, items, onIndexChange } = latest.current;\n    if (!count) return;\n    const next = { slot, turn: lead.turn + 1 };\n    latest.current.lead = next;\n    setLead(next);\n    if (slot !== lead.slot) setStep(Math.sign(slot - lead.slot));\n    const index = mod(slot, count);\n    if (source === \"user\") {\n      const { name, role, quote } = items[index].props;\n      setAnnouncement(\n        `${index + 1} of ${count}. ${name}${typeof role === \"string\" ? `, ${role}` : \"\"}. “${quote}”`,\n      );\n    }\n    if (slot !== lead.slot) onIndexChange?.(index);\n  }, []);\n\n  // Every new lead (or a snap back after a short drag) glides the strip to it.\n  React.useEffect(() => {\n    if (reduced) {\n      pos.jump(lead.slot);\n      return;\n    }\n    const controls = animate(pos, lead.slot, {\n      ...TIMING.glide,\n      visualDuration: duration / 1000,\n      velocity: release.current,\n    });\n    release.current = 0;\n    glide.current = controls;\n    return () => controls.stop();\n  }, [lead, reduced, pos, duration]);\n\n  /* What the strip shows: the slots around the lead and wherever the strip is passing. */\n  const [span, setSpan] = React.useState<[number, number]>([start, start]);\n  const [revealed, setRevealed] = React.useState<number | null>(start);\n  const [dragging, setDragging] = React.useState(false);\n  useMotionValueEvent(pos, \"change\", (value) => {\n    const low = Math.floor(value);\n    const high = Math.ceil(value);\n    setSpan((prev) => (prev[0] === low && prev[1] === high ? prev : [low, high]));\n    const slot = latest.current.lead.slot;\n    setRevealed(Math.abs(value - slot) < RELAY.settle ? slot : null);\n  });\n\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const probeRef = React.useRef<HTMLDivElement>(null);\n  const [metrics, setMetrics] = React.useState<Metrics | null>(null);\n  React.useLayoutEffect(() => {\n    const track = trackRef.current;\n    const probe = probeRef.current;\n    if (!track || !probe) return;\n    const [inset, s, l, gap] = Array.from(probe.children) as HTMLElement[];\n    const observer = new ResizeObserver(() => {\n      const next = {\n        width: track.clientWidth,\n        inset: inset.offsetWidth,\n        s: s.offsetWidth,\n        l: l.offsetWidth,\n        gap: gap.offsetWidth,\n      };\n      setMetrics((prev) =>\n        prev &&\n        prev.width === next.width &&\n        prev.inset === next.inset &&\n        prev.s === next.s &&\n        prev.l === next.l &&\n        prev.gap === next.gap\n          ? prev\n          : next,\n      );\n    });\n    observer.observe(track);\n    for (const el of [inset, s, l, gap]) observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const pitch = metrics ? metrics.s + metrics.gap : 1;\n  const before = metrics ? Math.ceil(metrics.inset / pitch) + 1 : 2;\n  const after = metrics ? Math.ceil((metrics.width - metrics.inset - metrics.l) / pitch) + 1 : 0;\n  const low = Math.min(span[0], lead.slot) - before;\n  const high = Math.max(Math.max(span[1], lead.slot) + after, low + count - 1);\n  const slots: Slot[] = [];\n  if (count === 1) slots.push({ slot: lead.slot, index: 0, canonical: true });\n  else if (count) {\n    // Each item is announced once: the copy nearest the lead. Repeats are decoration.\n    const nearest = new Map<number, number>();\n    for (let slot = low; slot <= high; slot++) {\n      const i = mod(slot, count);\n      const best = nearest.get(i);\n      if (best === undefined || Math.abs(slot - lead.slot) <= Math.abs(best - lead.slot)) {\n        nearest.set(i, slot);\n      }\n    }\n    for (let slot = low; slot <= high; slot++) {\n      const i = mod(slot, count);\n      slots.push({ slot, index: i, canonical: nearest.get(i) === slot });\n    }\n  }\n\n  /* Autoplay: the ring fills while nothing holds it, then the lead moves on. */\n  const progress = useMotionValue(0);\n  const [paused, setPaused] = React.useState<boolean | null>(null);\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [seen, setSeen] = React.useState(true);\n  const canPlay = !!autoPlay && autoPlay > 0 && count > 1;\n  const playing = canPlay && !(paused ?? (hydrated && reduced));\n  const held = hovered || focused || dragging || !seen;\n\n  React.useLayoutEffect(() => {\n    progress.jump(0);\n  }, [lead.slot, progress]);\n\n  React.useEffect(() => {\n    if (!playing || held) return;\n    const controls = animate(progress, 1, {\n      duration: ((autoPlay ?? 0) / 1000) * (1 - progress.get()),\n      ease: TIMING.ring,\n      onComplete: () => go(latest.current.lead.slot + 1, \"auto\"),\n    });\n    return () => controls.stop();\n  }, [playing, held, lead.slot, autoPlay, progress, go]);\n\n  React.useEffect(() => {\n    const track = trackRef.current;\n    if (!canPlay || !track) return;\n    let inView = true;\n    const update = () => setSeen(inView && document.visibilityState === \"visible\");\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { threshold: 0.25 },\n    );\n    observer.observe(track);\n    document.addEventListener(\"visibilitychange\", update);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", update);\n    };\n  }, [canPlay]);\n\n  /* Dragging: the strip follows the pointer, then lands where the flick was heading. */\n  const pointer = React.useRef({\n    id: -1,\n    x: 0,\n    from: 0,\n    moved: false,\n    swallow: false,\n    samples: [] as { x: number; t: number }[],\n  });\n\n  const unit = () => {\n    const m = metrics;\n    return m ? (m.s + m.l) / 2 + m.gap : 300;\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    const p = pointer.current;\n    p.swallow = false;\n    if (count < 2 || event.button !== 0) return;\n    if ((event.target as HTMLElement).closest(\"a, input, textarea, select, [data-relay-extra]\")) return;\n    p.id = event.pointerId;\n    p.x = event.clientX;\n    p.from = pos.get();\n    p.moved = false;\n    p.samples = [{ x: event.clientX, t: event.timeStamp }];\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const p = pointer.current;\n    if (event.pointerId !== p.id) return;\n    const dx = event.clientX - p.x;\n    if (!p.moved) {\n      if (Math.abs(dx) < RELAY.drag) return;\n      p.moved = true;\n      glide.current?.stop();\n      p.from = pos.get() + dx / unit();\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setDragging(true);\n    }\n    pos.set(p.from - dx / unit());\n    p.samples.push({ x: event.clientX, t: event.timeStamp });\n    if (p.samples.length > 6) p.samples.shift();\n  };\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    const p = pointer.current;\n    if (event.pointerId !== p.id) return;\n    p.id = -1;\n    if (!p.moved) return;\n    p.swallow = true;\n    setDragging(false);\n    const first = p.samples[0];\n    const last = p.samples[p.samples.length - 1];\n    const elapsed = last.t - first.t;\n    const speed = elapsed > 0 && event.timeStamp - last.t < TIMING.fresh ? (last.x - first.x) / elapsed : 0; // px/ms\n    const cards = -(speed * 1000) / unit(); // cards/s, positive toward the next card\n    const reach = Math.max(1, Math.ceil((metrics?.width ?? 1000) / pitch) - 1);\n    const landing = Math.round(pos.get() + cards * TIMING.flick);\n    const slot = Math.max(Math.round(p.from) - reach, Math.min(Math.round(p.from) + reach, landing));\n    release.current = reduced ? 0 : cards;\n    go(slot);\n  };\n\n  /* Sideways trackpad swipes scrub the strip and snap once they stop. */\n  React.useEffect(() => {\n    const track = trackRef.current;\n    if (!track || count < 2) return;\n    let timer = 0;\n    const onWheel = (event: WheelEvent) => {\n      if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;\n      event.preventDefault();\n      glide.current?.stop();\n      setDragging(true);\n      const m = metrics;\n      const size = m ? (m.s + m.l) / 2 + m.gap : 300;\n      pos.set(pos.get() + event.deltaX / size);\n      window.clearTimeout(timer);\n      timer = window.setTimeout(() => {\n        setDragging(false);\n        go(Math.round(pos.get()));\n      }, TIMING.wheelIdle);\n    };\n    track.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      track.removeEventListener(\"wheel\", onWheel);\n      window.clearTimeout(timer);\n    };\n  }, [count, metrics, pos, go]);\n\n  /* Keys: ←/→ pass the lead, Home/End go the short way to the first and last card. Focus follows the lead. */\n  const keyboard = React.useRef(false);\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    const slot = latest.current.lead.slot;\n    const toIndex = (i: number) => {\n      let delta = mod(i - slot, count);\n      if (delta > count / 2) delta -= count;\n      return slot + delta;\n    };\n    let to: number | null = null;\n    if (event.key === \"ArrowRight\") to = slot + 1;\n    else if (event.key === \"ArrowLeft\") to = slot - 1;\n    else if (event.key === \"Home\") to = toIndex(0);\n    else if (event.key === \"End\") to = toIndex(count - 1);\n    if (to === null) return;\n    event.preventDefault();\n    keyboard.current = true;\n    go(to);\n  };\n  React.useEffect(() => {\n    if (!keyboard.current) return;\n    keyboard.current = false;\n    trackRef.current?.querySelector<HTMLElement>(`[data-relay-slot=\"${lead.slot}\"]`)?.focus();\n  }, [lead]);\n\n  return (\n    <RelayContext.Provider\n      value={{ count, lead: lead.slot, revealed: dragging ? null : revealed, stagger, drift, place, go, onKeyDown }}\n    >\n      <section\n        {...props}\n        aria-roledescription=\"carousel\"\n        className={cn(\"@container relative w-full\", className)}\n        onPointerEnter={(event) => {\n          if (event.pointerType === \"mouse\") setHovered(true);\n          onPointerEnter?.(event);\n        }}\n        onPointerLeave={(event) => {\n          setHovered(false);\n          onPointerLeave?.(event);\n        }}\n        onFocus={(event) => {\n          if (event.target.matches(\":focus-visible\")) setFocused(true);\n          onFocus?.(event);\n        }}\n        onBlur={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);\n          onBlur?.(event);\n        }}\n      >\n        <div\n          className={cn(\"grid grid-cols-[minmax(0,1fr)_auto]\", inset === undefined && INSET, SIZES)}\n          style={inset === undefined ? undefined : ({ \"--relay-inset\": inset } as React.CSSProperties)}\n        >\n          {heading != null && (\n            <div className=\"col-start-1 row-start-1 min-w-0 self-end pr-4 pl-[var(--relay-inset)] @2xl:pr-10\">\n              {heading}\n            </div>\n          )}\n\n          <div\n            className={cn(\n              \"col-span-2 row-start-3 mt-5 flex items-center gap-2 px-[var(--relay-inset)]\",\n              \"@2xl:col-span-1 @2xl:col-start-2 @2xl:row-start-1 @2xl:mt-0 @2xl:self-end @2xl:pl-0\",\n              count < 2 && \"hidden\",\n            )}\n          >\n            <Counter active={active} count={count} step={step} reduced={reduced} />\n            <RoundButton\n              label=\"Previous\"\n              controls={trackId}\n              onClick={() => go(lead.slot - 1)}\n              onHover={(on) => leanTo(on ? -RELAY.lean : 0)}\n            >\n              <Arrow direction={-1} />\n            </RoundButton>\n            <RoundButton\n              label=\"Next\"\n              controls={trackId}\n              onClick={() => go(lead.slot + 1)}\n              onHover={(on) => leanTo(on ? RELAY.lean : 0)}\n            >\n              <Arrow direction={1} />\n            </RoundButton>\n            {canPlay && (\n              <RoundButton\n                label={playing ? \"Pause\" : \"Play\"}\n                controls={trackId}\n                onClick={() => setPaused(playing)}\n              >\n                <svg aria-hidden viewBox=\"0 0 40 40\" className=\"pointer-events-none absolute -inset-px -rotate-90\">\n                  <motion.circle\n                    cx=\"20\"\n                    cy=\"20\"\n                    r=\"19.25\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"1.5\"\n                    style={{ pathLength: progress, opacity: playing ? 1 : 0 }}\n                    className={`transition-opacity ${TIMING.ringFade}`}\n                  />\n                </svg>\n                {playing ? <PauseIcon /> : <PlayIcon />}\n              </RoundButton>\n            )}\n          </div>\n\n          <div\n            ref={trackRef}\n            id={trackId}\n            className={cn(\n              \"relative col-span-2 row-start-2 -my-2 h-[calc(var(--relay-hl)_+_1rem)] touch-pan-y overflow-hidden select-none\",\n              // Cards that already led fade into the margin. Phones have no room for one, so it's hidden there.\n              \"[mask-image:linear-gradient(to_right,transparent_calc(var(--relay-inset)_-_var(--relay-gap)),#000_calc(var(--relay-inset)_-_0.25rem))]\",\n              \"@2xl:[mask-image:linear-gradient(to_right,transparent,#000_calc(var(--relay-inset)_-_0.5rem))]\",\n              heading != null ? \"mt-6 @2xl:mt-10\" : \"@2xl:mt-6\",\n              count > 1 && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n            )}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={endDrag}\n            onPointerCancel={endDrag}\n            onClickCapture={(event) => {\n              if (!pointer.current.swallow) return;\n              pointer.current.swallow = false;\n              event.preventDefault();\n              event.stopPropagation();\n            }}\n            onDragStart={(event) => event.preventDefault()}\n          >\n            <div ref={probeRef} aria-hidden className=\"invisible absolute h-0\">\n              <div className=\"w-[var(--relay-inset)]\" />\n              <div className=\"w-[var(--relay-s)]\" />\n              <div className=\"w-[var(--relay-l)]\" />\n              <div className=\"w-[var(--relay-gap)]\" />\n            </div>\n            {slots.map((slot) => (\n              <SlotContext.Provider key={slot.slot} value={slot}>\n                {items[slot.index]}\n              </SlotContext.Provider>\n            ))}\n          </div>\n        </div>\n        <p aria-live=\"polite\" aria-atomic className=\"sr-only\">\n          {announcement}\n        </p>\n      </section>\n    </RelayContext.Provider>\n  );\n}\n\n/* ── RelayCarouselItem ──────────────────────────────────── */\n\nexport interface RelayCarouselItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"role\"> {\n  /** Who said it. Shown on every card. */\n  name: string;\n  /** Their role and company, under the name. */\n  role?: React.ReactNode;\n  /** What they said, without quotation marks. Shown while the card leads. */\n  quote: string;\n  /** Photo URL. Cut to the lead card's size, so a resting card shows its top middle. */\n  image: string;\n  /** Alt text for the photo. */\n  alt?: string;\n  /** Small text in the lead card's top corner, like a place or a product. */\n  label?: React.ReactNode;\n  /** Classes for the quote, like a serif face. */\n  quoteClassName?: string;\n  /** Classes for the photo. The card is a `group/relay` with `data-state=\"lead\"` or `\"rest\"`. */\n  imageClassName?: string;\n  /** Pinned to the lead card's bottom right corner, like a link to the full story. */\n  children?: React.ReactNode;\n}\n\n/** One card. Render inside `RelayCarousel`. */\nexport function RelayCarouselItem({\n  name,\n  role,\n  quote,\n  image,\n  alt = \"\",\n  label,\n  quoteClassName,\n  imageClassName,\n  className,\n  children,\n  ...props\n}: RelayCarouselItemProps) {\n  const { count, lead, revealed, stagger, drift: driftPx, place, go, onKeyDown } = useRelay();\n  const { slot, index, canonical } = React.useContext(SlotContext);\n  const isLead = slot === lead;\n  const shown = isLead && revealed === slot;\n\n  // Where this card sits, in cards from the lead slot, and how much of the lead it is.\n  const offset = useTransform(place, (value) => slot - value);\n  const grow = useTransform(offset, (d) => Math.max(0, 1 - Math.abs(d)));\n  const ahead = useTransform(offset, (d) => Math.min(1, Math.max(0, d)));\n  const drift = useTransform(offset, (d) => Math.max(-2, Math.min(2, d)));\n\n  // Quote lines set in one after another, so each word learns which line it landed on.\n  const quoteRef = React.useRef<HTMLParagraphElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const paragraph = quoteRef.current;\n    const card = cardRef.current;\n    if (!paragraph || !card) return;\n    const assign = () => {\n      let line = -1;\n      let top = -Infinity;\n      for (const word of Array.from(paragraph.children) as HTMLElement[]) {\n        if (word.offsetTop > top + 2) {\n          line += 1;\n          top = word.offsetTop;\n        }\n        word.style.setProperty(\"--line\", String(line));\n      }\n      card.style.setProperty(\"--lines\", String(line + 1));\n    };\n    assign();\n    const observer = new ResizeObserver(assign);\n    observer.observe(paragraph);\n    document.fonts?.ready.then(assign);\n    return () => observer.disconnect();\n  }, [quote]);\n\n  const words = quote.trim().split(/\\s+/);\n  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`;\n\n  return (\n    <motion.div\n      ref={cardRef}\n      data-relay-slot={slot}\n      data-state={isLead ? \"lead\" : \"rest\"}\n      data-shown={shown}\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={`${index + 1} of ${count}: ${name}`}\n      aria-hidden={canonical ? undefined : true}\n      inert={!canonical}\n      tabIndex={isLead && canonical ? 0 : -1}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"group/relay absolute top-2 left-0 outline-none\",\n        \"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))]\",\n        \"[transform:translateX(calc(var(--relay-inset)_+_var(--offset)_*_(var(--relay-s)_+_var(--relay-gap))_+_var(--ahead)_*_(var(--relay-l)_-_var(--relay-s))))]\",\n      )}\n      style={\n        {\n          \"--offset\": offset,\n          \"--grow\": grow,\n          \"--ahead\": ahead,\n          \"--drift\": drift,\n          \"--relay-drift\": `${driftPx}px`,\n        } as MotionStyle\n      }\n    >\n      <div\n        {...props}\n        className={cn(\n          \"relative isolate size-full overflow-hidden rounded-2xl bg-zinc-800 text-white\",\n          \"outline-current group-focus-visible/relay:outline-2 group-focus-visible/relay:outline-offset-2\",\n          className,\n        )}\n      >\n        {/* eslint-disable-next-line @next/next/no-img-element -- a remote photo in an installable component */}\n        <img\n          src={image}\n          alt={alt}\n          draggable={false}\n          decoding=\"async\"\n          className={cn(\n            \"pointer-events-none absolute top-0 left-1/2 h-[var(--relay-hl)] w-[var(--relay-l)] max-w-none object-cover\",\n            // Drift and zoom are CSS-only, so reduced motion turns them off without a hydration mismatch.\n            \"[--zoom:0] [--drift-px:0px] motion-safe:[--zoom:0.06] motion-safe:[--drift-px:var(--relay-drift)]\",\n            \"[transform:translateX(calc(-50%_-_var(--drift)_*_var(--drift-px)))_scale(calc(1_+_var(--zoom)_*_(1_-_var(--grow))))] [transform-origin:50%_30%]\",\n            `transition-[filter] ${TIMING.brighten} group-data-[state=rest]/relay:brightness-[0.88] group-data-[state=rest]/relay:group-hover/relay:brightness-100`,\n            imageClassName,\n          )}\n        />\n        {/* A light foot for the caption on every card, and a deeper scrim the lead's quote sits on. */}\n        <div\n          aria-hidden\n          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%)]\"\n        />\n        <div\n          aria-hidden\n          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)]\"\n        />\n\n        {/* A hairline inner edge keeps a dark card's corners crisp on a dark page. */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 z-20 rounded-[inherit] ring-1 ring-white/10 ring-inset\"\n        />\n\n        {!isLead && (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label={`Show ${name}'s quote`}\n            onClick={() => go(slot)}\n            className=\"absolute inset-0 z-10 cursor-pointer\"\n          />\n        )}\n\n        {label != null && (\n          <div\n            className={cn(\n              \"pointer-events-none absolute top-0 left-0 z-20 p-5 text-xs font-medium tracking-wide text-white/80 @2xl:p-6\",\n              reveal,\n            )}\n          >\n            {label}\n          </div>\n        )}\n\n        <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))]\">\n          <div className=\"relative\">\n            <blockquote\n              aria-hidden={isLead ? undefined : true}\n              className={cn(\n                \"absolute bottom-full left-0 mb-4 w-[calc(var(--relay-l)_-_2.5rem)] @2xl:mb-5 @2xl:w-[calc(var(--relay-l)_-_3rem)]\",\n                \"text-[clamp(1rem,calc(var(--relay-l)_*_0.05),1.3125rem)] leading-[1.4] font-medium tracking-[-0.01em] text-pretty\",\n                quoteClassName,\n              )}\n            >\n              <p ref={quoteRef}>\n                {words.map((word, i) => (\n                  <React.Fragment key={i}>\n                    <span\n                      className={cn(\"relative inline-block\", reveal)}\n                      style={{ transitionDelay: shown ? `calc(var(--line, 0) * ${stagger}ms)` : undefined }}\n                    >\n                      {i === 0 && <span className=\"absolute right-full\">“</span>}\n                      {word}\n                      {i === words.length - 1 && \"”\"}\n                    </span>\n                    {i < words.length - 1 && \" \"}\n                  </React.Fragment>\n                ))}\n              </p>\n            </blockquote>\n            <div className=\"max-w-full pr-12\">\n              <div className=\"truncate text-sm leading-5 font-medium\">{name}</div>\n              {role != null && <div className=\"truncate text-xs leading-4 text-white/70\">{role}</div>}\n            </div>\n          </div>\n        </div>\n\n        {children != null && (\n          <div\n            data-relay-extra\n            inert={!isLead}\n            className={cn(\n              \"absolute right-0 bottom-0 z-30 p-3.5 @2xl:p-5\",\n              reveal,\n              \"group-data-[state=rest]/relay:pointer-events-none\",\n            )}\n            style={{ transitionDelay: shown ? `calc(var(--lines, 1) * ${stagger}ms)` : undefined }}\n          >\n            {children}\n          </div>\n        )}\n      </div>\n    </motion.div>\n  );\n}\n\n/* ── Controls ───────────────────────────────────────────── */\n\nfunction RoundButton({\n  label,\n  controls,\n  onClick,\n  onHover,\n  children,\n}: {\n  label: string;\n  controls: string;\n  onClick: () => void;\n  onHover?: (on: boolean) => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-controls={controls}\n      onClick={onClick}\n      onPointerEnter={(event) => event.pointerType === \"mouse\" && onHover?.(true)}\n      onPointerLeave={() => onHover?.(false)}\n      className={cn(\n        // Drawn in the text color around it, so the controls suit any background.\n        \"group/button relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full border border-current/15 @2xl:size-10\",\n        `transition-[background-color,border-color,scale] ${TIMING.button} hover:border-current/40 hover:bg-current/[0.07] active:scale-[0.94]`,\n        \"outline-current focus-visible:outline-2 focus-visible:outline-offset-2\",\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction Arrow({ direction }: { direction: 1 | -1 }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={cn(\n        `size-4 transition-transform ${TIMING.nudge}`,\n        direction > 0 ? \"group-hover/button:translate-x-0.5\" : \"-scale-x-100 group-hover/button:-translate-x-0.5\",\n      )}\n    >\n      <path d=\"M3 8h10M9 4l4 4-4 4\" />\n    </svg>\n  );\n}\n\nfunction PauseIcon() {\n  return (\n    <svg aria-hidden viewBox=\"0 0 16 16\" fill=\"currentColor\" className=\"size-4\">\n      <rect x=\"4\" y=\"3\" width=\"2.5\" height=\"10\" rx=\"0.75\" />\n      <rect x=\"9.5\" y=\"3\" width=\"2.5\" height=\"10\" rx=\"0.75\" />\n    </svg>\n  );\n}\n\nfunction PlayIcon() {\n  return (\n    <svg aria-hidden viewBox=\"0 0 16 16\" fill=\"currentColor\" className=\"size-4 translate-x-px\">\n      <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\" />\n    </svg>\n  );\n}\n\nfunction Counter({\n  active,\n  count,\n  step,\n  reduced,\n}: {\n  active: number;\n  count: number;\n  step: number;\n  reduced: boolean;\n}) {\n  const pad = (n: number) => String(n).padStart(String(count).length < 2 ? 2 : String(count).length, \"0\");\n  return (\n    <div\n      aria-hidden\n      className=\"mr-auto flex items-center text-sm leading-5 tabular-nums @2xl:mr-3\"\n    >\n      <span className=\"relative inline-flex h-5 overflow-hidden\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={step}>\n          <motion.span\n            key={active}\n            custom={step}\n            variants={{\n              enter: (dir: number) => ({ y: reduced ? 0 : `${dir * 100}%`, opacity: 0 }),\n              center: { y: 0, opacity: 1 },\n              exit: (dir: number) => ({ y: reduced ? 0 : `${dir * -100}%`, opacity: 0 }),\n            }}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={TIMING.count}\n            className=\"block\"\n          >\n            {pad(active + 1)}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <span className=\"px-1.5 opacity-30\">/</span>\n      <span className=\"opacity-50\">{pad(count)}</span>\n    </div>\n  );\n}\n"
    }
  ]
}