{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-ring",
  "type": "registry:block",
  "title": "Testimonial Ring",
  "description": "Testimonial cards stood on a 3D ring over a reflecting grid floor. Drag it and it spins with inertia before snapping to a card; scrolling turns it too. Whichever card comes to the front blurs its quote in and rolls its metric on Number Flow.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@number-flow/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/testimonial-ring.tsx",
      "type": "registry:component",
      "target": "components/tweenui/testimonial-ring.tsx",
      "content": "'use client';\n\nimport { useId, useRef, useState, type ComponentPropsWithoutRef, type KeyboardEvent } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport NumberFlow from '@number-flow/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface RingMetric {\n  value: number;\n  prefix?: string;\n  suffix?: string;\n  /** What the number measures, shown beside it. */\n  label: string;\n}\n\nexport interface RingTestimonial {\n  quote: string;\n  name: string;\n  role: string;\n  company: string;\n  image: string;\n  imageAlt?: string;\n  metric: RingMetric;\n}\n\nexport interface TestimonialRingProps extends ComponentPropsWithoutRef<'section'> {\n  title?: string;\n  testimonials?: RingTestimonial[];\n}\n\n/** The ring repeats the testimonials until it has at least this many cards. */\nconst MIN_CARDS = 10;\n/** Degrees of spin for each pixel dragged. */\nconst DRAG = 0.12;\n\nconst portrait = (id: string) =>\n  `https://images.unsplash.com/${id}?w=700&h=525&fit=crop&crop=faces&auto=format&q=80`;\n\nconst DEFAULT_TESTIMONIALS: RingTestimonial[] = [\n  {\n    quote:\n      'We used to hear about churn in the quarterly review. Now it gets flagged in week two, while the account is still saveable.',\n    name: 'Ines Okafor',\n    role: 'VP Customer Success',\n    company: 'Ledgerly',\n    image: portrait('photo-1494790108377-be9c29b29330'),\n    metric: { value: 12, prefix: '+', suffix: ' pts', label: 'net revenue retention' },\n  },\n  {\n    quote:\n      'It caught a pricing bug on a Sunday night that would have cost us the whole month. Nobody was even looking at that chart.',\n    name: 'Tobi Mensah',\n    role: 'Head of Revenue Operations',\n    company: 'Halden Freight',\n    image: portrait('photo-1500648767791-00dcc994a43e'),\n    metric: { value: 34, suffix: '%', label: 'fewer stockouts' },\n  },\n  {\n    quote:\n      'My Monday starts with one brief instead of nine dashboards. It reads like a sharp analyst wrote it overnight.',\n    name: 'Maren Holt',\n    role: 'Chief Operating Officer',\n    company: 'Mirelab',\n    image: portrait('photo-1438761681033-6461ffad8d80'),\n    metric: { value: 19, suffix: '%', label: 'faster sample turnaround' },\n  },\n  {\n    quote:\n      'Our crews used to drive to sites that were fine. Now every visit starts with a reason and a part number.',\n    name: 'Elif Arslan',\n    role: 'Director of Field Operations',\n    company: 'Solvane Energy',\n    image: portrait('photo-1534528741775-53994a69daeb'),\n    metric: { value: 28, suffix: '%', label: 'fewer wasted truck rolls' },\n  },\n  {\n    quote: 'We see the bad afternoon coming at breakfast now. That is the whole difference.',\n    name: 'Callum Reyes',\n    role: 'Head of Ground Operations',\n    company: 'Aerowin',\n    image: portrait('photo-1507003211169-0a1dd7228f2d'),\n    metric: { value: 3, suffix: ' hrs', label: 'earlier view of staffing gaps' },\n  },\n  {\n    quote: 'Finance and engineering finally look at the same story, written once for both of us.',\n    name: 'Noor Haddad',\n    role: 'VP Finance',\n    company: 'Corvid Cloud',\n    image: portrait('photo-1573496359142-b8d87734a5a2'),\n    metric: { value: 17, suffix: '%', label: 'lower spend per customer' },\n  },\n];\n\nconst metricText = ({ prefix = '', value, suffix = '' }: RingMetric) =>\n  `${prefix}${value}${suffix}`;\n\nconst arrowButton = cn(\n  'grid size-12 place-items-center rounded-full border border-[#9fd4d6]/24 text-[#e8f3f2]',\n  'transition-colors duration-300 hover:border-[#e8f3f2] hover:bg-[#e8f3f2] hover:text-[#03110f]',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f]',\n  'motion-reduce:transition-none'\n);\n\nconst Arrow = ({ flip = false }: { flip?: boolean }) => {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={cn('size-5', flip && 'rotate-180')}\n    >\n      <path d=\"M19 12H5\" />\n      <path d=\"m12 19-7-7 7-7\" />\n    </svg>\n  );\n};\n\nconst TestimonialRing = ({\n  title = 'In their own words.',\n  testimonials = DEFAULT_TESTIMONIALS,\n  className,\n  onKeyDown,\n  ...props\n}: TestimonialRingProps) => {\n  const count = testimonials.length;\n  const repeat = Math.max(2, Math.ceil(MIN_CARDS / Math.max(1, count)));\n  const slots = Array.from({ length: count * repeat }, (_, i) => testimonials[i % count]);\n\n  // `active` is the card in front (the metric rolls to it at once); `shown` is the text on\n  // screen, which only changes while it is faded out.\n  const [active, setActive] = useState(0);\n  const [shown, setShown] = useState(0);\n  const rootRef = useRef<HTMLElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<HTMLDivElement>(null);\n  const floorRef = useRef<HTMLDivElement>(null);\n  const textRef = useRef<HTMLDivElement>(null);\n  const goRef = useRef<(direction: number) => void>(() => {});\n  const chipRef = useRef<HTMLDivElement>(null);\n  const popRef = useRef<HTMLDivElement>(null);\n  const blobRef = useRef<HTMLSpanElement>(null);\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const titleId = useId();\n\n  useGSAP(\n    () => {\n      const section = rootRef.current;\n      const stage = stageRef.current;\n      const ring = ringRef.current;\n      const floor = floorRef.current;\n      if (!section || !stage || !ring || !count) return;\n\n      const cards = Array.from(ring.querySelectorAll<HTMLElement>('[data-ring-card]'));\n      const shades = cards.map((card) => card.querySelector<HTMLElement>('[data-ring-shade]'));\n      const texts = Array.from(\n        textRef.current?.querySelectorAll<HTMLElement>('[data-ring-text]') ?? []\n      );\n      const animate = !prefersReducedMotion();\n      const total = cards.length;\n      const step = 360 / total;\n      const state = { drag: 0, scroll: 0, current: 0 };\n      let radius = 0;\n      let front = 0;\n      let swap: gsap.core.Timeline | null = null;\n\n      const layout = () => {\n        const width = cards[0]?.offsetWidth ?? 0;\n        radius = (width / 2 / Math.tan(((step / 2) * Math.PI) / 180)) * 1.1;\n        cards.forEach((card, i) => {\n          card.style.transform = `translate(-50%, -50%) rotateY(${-i * step}deg) translateZ(${-radius}px)`;\n        });\n      };\n\n      /** Where a slot sits on the ring right now, -180..180 with 0 facing the viewer. */\n      const angleOf = (slot: number) =>\n        ((((-slot * step + state.current) % 360) + 540) % 360) - 180;\n\n      const bringForward = (index: number) => {\n        setActive(index);\n        swap?.kill();\n        if (!animate) {\n          setShown(index);\n          return;\n        }\n        swap = gsap\n          .timeline()\n          .to(texts, {\n            autoAlpha: 0,\n            y: -14,\n            filter: 'blur(6px)',\n            duration: 0.25,\n            ease: 'power2.in',\n            stagger: 0.04,\n          })\n          .call(() => setShown(index))\n          .fromTo(\n            texts,\n            { autoAlpha: 0, y: 18, filter: 'blur(6px)' },\n            {\n              autoAlpha: 1,\n              y: 0,\n              filter: 'blur(0px)',\n              duration: 0.7,\n              ease: 'expo.out',\n              stagger: 0.04,\n              clearProps: 'filter',\n            }\n          );\n      };\n\n      const render = () => {\n        const target = state.drag + state.scroll;\n        state.current += (target - state.current) * (animate ? 0.09 : 1);\n        ring.style.transform = `translateZ(${radius - 180}px) rotateY(${state.current}deg)`;\n        if (floor) floor.style.backgroundPosition = `${state.current * 7}px 0`;\n\n        cards.forEach((card, i) => {\n          const angle = angleOf(i);\n          const shade = shades[i];\n          if (shade) shade.style.opacity = String(Math.min(1, Math.abs(angle) / 80) * 0.7);\n          card.style.visibility = Math.abs(angle) > 100 ? 'hidden' : 'visible';\n        });\n\n        const next = (((Math.round(state.current / step) % total) + total) % total) % count;\n        if (next !== front) {\n          front = next;\n          bringForward(front);\n        }\n      };\n\n      const spinTo = (drag: number, duration = 1) => {\n        gsap.to(state, {\n          drag,\n          duration: animate ? duration : 0,\n          ease: 'expo.out',\n          overwrite: true,\n        });\n        if (!animate) render();\n      };\n\n      const snap = (velocity = 0) => {\n        const projected = state.drag + state.scroll + velocity;\n        spinTo(Math.round(projected / step) * step - state.scroll);\n      };\n\n      goRef.current = (direction) =>\n        spinTo(Math.round((state.drag + direction * step) / step) * step);\n\n      let pointerX = 0;\n      let startDrag = 0;\n      let lastX = 0;\n      let lastT = 0;\n      let velocity = 0;\n      let dragging = false;\n      let moved = false;\n\n      const onDown = (event: PointerEvent) => {\n        dragging = true;\n        moved = false;\n        pointerX = lastX = event.clientX;\n        lastT = performance.now();\n        startDrag = state.drag;\n        velocity = 0;\n        gsap.killTweensOf(state);\n        stage.setPointerCapture?.(event.pointerId);\n      };\n\n      const onMove = (event: PointerEvent) => {\n        if (!dragging) return;\n        const dx = event.clientX - pointerX;\n        if (Math.abs(dx) > 4) moved = true;\n        state.drag = startDrag - dx * DRAG;\n        const now = performance.now();\n        velocity = ((event.clientX - lastX) / Math.max(1, now - lastT)) * 16;\n        lastX = event.clientX;\n        lastT = now;\n        if (!animate) render();\n      };\n\n      const onUp = (event: PointerEvent) => {\n        if (!dragging) return;\n        dragging = false;\n        stage.releasePointerCapture?.(event.pointerId);\n        if (!moved) {\n          // Pointer capture retargets the release to the stage, so find the card under it\n          const hit = document.elementFromPoint?.(event.clientX, event.clientY);\n          const card = hit?.closest<HTMLElement>('[data-ring-card]');\n          if (card) spinTo(state.drag - angleOf(Number(card.dataset.slot)), 1.1);\n          return;\n        }\n        snap(-velocity * DRAG * 8);\n      };\n\n      stage.addEventListener('pointerdown', onDown);\n      stage.addEventListener('pointermove', onMove);\n      stage.addEventListener('pointerup', onUp);\n      stage.addEventListener('pointercancel', onUp);\n\n      const observer = new ResizeObserver(layout);\n      observer.observe(stage);\n      layout();\n      render();\n\n      let ticking = false;\n      let settle = 0;\n      const startTicking = (on: boolean) => {\n        if (on === ticking) return;\n        ticking = on;\n        if (on) gsap.ticker.add(render);\n        else gsap.ticker.remove(render);\n      };\n\n      // Scrolling the section through the viewport turns the ring by three cards. The turn\n      // is counted from where the page sat on load, so the first card always starts in\n      // front, even when the section loads already halfway up the screen.\n      let base = 0;\n      let scrolled = false;\n      const onScroll = () => {\n        scrolled = true;\n      };\n      window.addEventListener('scroll', onScroll, { once: true, passive: true });\n      const trigger = ScrollTrigger.create({\n        trigger: section,\n        start: 'top bottom',\n        end: 'bottom top',\n        onRefresh: (self) => {\n          if (!scrolled) base = self.progress;\n        },\n        onUpdate: (self) => {\n          if (!animate || !scrolled) return;\n          state.scroll = (self.progress - base) * step * 3;\n          window.clearTimeout(settle);\n          settle = window.setTimeout(() => {\n            if (!dragging) snap();\n          }, 260);\n        },\n        onToggle: (self) => startTicking(self.isActive),\n      });\n      base = trigger.progress;\n      if (animate) startTicking(trigger.isActive);\n\n      return () => {\n        window.removeEventListener('scroll', onScroll);\n        window.clearTimeout(settle);\n        startTicking(false);\n        trigger.kill();\n        observer.disconnect();\n        swap?.kill();\n        gsap.killTweensOf(state);\n        stage.removeEventListener('pointerdown', onDown);\n        stage.removeEventListener('pointermove', onMove);\n        stage.removeEventListener('pointerup', onUp);\n        stage.removeEventListener('pointercancel', onUp);\n        goRef.current = () => {};\n      };\n    },\n    { scope: rootRef, dependencies: [testimonials, slots.length] }\n  );\n\n  // The drag chip: a lime plate that trails a mouse over the cards, squeezes when you grab,\n  // and leans like jelly into the way it is moving. Mouse only, never under reduced motion.\n  useGSAP(\n    () => {\n      const section = rootRef.current;\n      const stage = stageRef.current;\n      const chip = chipRef.current;\n      const pop = popRef.current;\n      const blob = blobRef.current;\n      const label = labelRef.current;\n      if (!section || !stage || !chip || !pop || !blob || !label) return;\n      const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;\n      if (!fine || prefersReducedMotion()) return;\n\n      const arrows = Array.from(label.querySelectorAll<SVGElement>('[data-chip-arrow]'));\n      const word = label.querySelector<HTMLElement>('[data-chip-word]');\n      chip.dataset.on = '';\n      stage.dataset.chipOn = '';\n      gsap.set(pop, { xPercent: -50, yPercent: -50, scale: 0, rotate: -12 });\n\n      const xTo = gsap.quickTo(chip, 'x', { duration: 0.45, ease: 'power3.out' });\n      const yTo = gsap.quickTo(chip, 'y', { duration: 0.45, ease: 'power3.out' });\n      const client = { x: 0, y: 0 };\n      const press = { v: 1 };\n      let inside = false;\n      let pressed = false;\n      let breathe: gsap.core.Tween | null = null;\n\n      const local = () => {\n        const box = section.getBoundingClientRect();\n        return [client.x - box.left, client.y - box.top] as const;\n      };\n      const follow = () => {\n        const [x, y] = local();\n        xTo(x);\n        yTo(y);\n      };\n\n      // While hovering, the arrows breathe outward as a hint that the ring can be dragged\n      const setBreathing = (on: boolean) => {\n        breathe?.kill();\n        breathe = on\n          ? gsap.fromTo(\n              arrows,\n              { x: 0 },\n              {\n                x: (i) => (i === 0 ? -4 : 4),\n                duration: 0.7,\n                ease: 'sine.inOut',\n                repeat: -1,\n                yoyo: true,\n                delay: 0.4,\n              }\n            )\n          : null;\n        if (!on && !pressed) gsap.to(arrows, { x: 0, duration: 0.3, overwrite: true });\n      };\n\n      let visible = false;\n      const show = (on: boolean) => {\n        if (on === visible) return;\n        visible = on;\n        if (on) {\n          stage.dataset.chipOver = '';\n          // Appear right at the pointer, rather than flying over from where it last hid\n          const [x, y] = local();\n          gsap.set(chip, { x, y });\n          xTo(x);\n          yTo(y);\n        } else {\n          delete stage.dataset.chipOver;\n        }\n        gsap.to(\n          pop,\n          on\n            ? { scale: 1, rotate: 0, duration: 0.8, ease: 'elastic.out(1, 0.55)', overwrite: true }\n            : { scale: 0, rotate: 12, duration: 0.3, ease: 'power3.in', overwrite: true }\n        );\n        setBreathing(on && !pressed);\n      };\n\n      // Only a card calls the chip up: the floor, the gaps and the reflections keep the\n      // grab cursor. Once you are holding the ring it stays, wherever the pointer drifts.\n      const overCard = () =>\n        !!document.elementFromPoint?.(client.x, client.y)?.closest('[data-ring-card]');\n      const check = () => show(pressed || (inside && overCard()));\n\n      const isMouse = (event: PointerEvent) => event.pointerType === 'mouse';\n\n      const onEnter = (event: PointerEvent) => {\n        if (!isMouse(event)) return;\n        inside = true;\n        client.x = event.clientX;\n        client.y = event.clientY;\n        check();\n      };\n\n      const onLeave = (event: PointerEvent) => {\n        if (!isMouse(event) || pressed) return;\n        inside = false;\n        check();\n      };\n\n      const onMove = (event: PointerEvent) => {\n        if (!isMouse(event)) return;\n        client.x = event.clientX;\n        client.y = event.clientY;\n        follow();\n        check();\n      };\n\n      const onDown = (event: PointerEvent) => {\n        if (!isMouse(event)) return;\n        pressed = true;\n        check();\n        setBreathing(false);\n        gsap.to(press, { v: 0.82, duration: 0.25, ease: 'power3.out', overwrite: true });\n        gsap.to(arrows, {\n          x: (i) => (i === 0 ? -7 : 7),\n          duration: 0.35,\n          ease: 'back.out(3)',\n          overwrite: true,\n        });\n        if (word) word.textContent = 'Spin';\n      };\n\n      const onUp = (event: PointerEvent) => {\n        if (!pressed) return;\n        pressed = false;\n        gsap.to(press, { v: 1, duration: 0.7, ease: 'elastic.out(1, 0.4)', overwrite: true });\n        if (word) word.textContent = 'Drag';\n        // The drag may have ended outside the stage; the capture kept the leave from firing\n        client.x = event.clientX;\n        client.y = event.clientY;\n        const hit = document.elementFromPoint?.(event.clientX, event.clientY);\n        inside = !!hit && stage.contains(hit);\n        check();\n      };\n\n      const onScroll = () => {\n        if (inside) follow();\n      };\n\n      // Jelly: the plate leans into the way it is moving and stretches with its speed,\n      // while the label stays upright on top\n      let lastX = 0;\n      let lastY = 0;\n      let lean = 0;\n      let stretchX = 0;\n      let stretchY = 0;\n      const jelly = () => {\n        // The ring can turn under a still pointer (scroll, inertia), so re-check every frame\n        if (inside && !pressed) check();\n        const x = Number(gsap.getProperty(chip, 'x'));\n        const y = Number(gsap.getProperty(chip, 'y'));\n        const dx = x - lastX;\n        const dy = y - lastY;\n        lastX = x;\n        lastY = y;\n        if (!visible && Math.abs(lean) < 0.05 && stretchX < 0.005 && stretchY < 0.005) return;\n        lean += (gsap.utils.clamp(-14, 14, -dx * 0.6) - lean) * 0.25;\n        stretchX += (Math.min(Math.abs(dx) / 50, 0.3) - stretchX) * 0.25;\n        stretchY += (Math.min(Math.abs(dy) / 60, 0.2) - stretchY) * 0.25;\n        gsap.set(blob, {\n          skewX: lean,\n          scaleX: (1 + stretchX - stretchY * 0.5) * press.v,\n          scaleY: (1 + stretchY - stretchX * 0.4) * press.v,\n        });\n      };\n      gsap.ticker.add(jelly);\n\n      stage.addEventListener('pointerenter', onEnter);\n      stage.addEventListener('pointerleave', onLeave);\n      stage.addEventListener('pointermove', onMove);\n      stage.addEventListener('pointerdown', onDown);\n      stage.addEventListener('pointerup', onUp);\n      stage.addEventListener('pointercancel', onUp);\n      window.addEventListener('scroll', onScroll, { passive: true });\n\n      return () => {\n        gsap.ticker.remove(jelly);\n        breathe?.kill();\n        gsap.killTweensOf([pop, press, ...arrows]);\n        stage.removeEventListener('pointerenter', onEnter);\n        stage.removeEventListener('pointerleave', onLeave);\n        stage.removeEventListener('pointermove', onMove);\n        stage.removeEventListener('pointerdown', onDown);\n        stage.removeEventListener('pointerup', onUp);\n        stage.removeEventListener('pointercancel', onUp);\n        window.removeEventListener('scroll', onScroll);\n        delete chip.dataset.on;\n        delete stage.dataset.chipOn;\n        delete stage.dataset.chipOver;\n      };\n    },\n    { scope: rootRef }\n  );\n\n  const onKeys = (event: KeyboardEvent<HTMLElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {\n      event.preventDefault();\n      goRef.current(event.key === 'ArrowLeft' ? -1 : 1);\n    }\n  };\n\n  const current = testimonials[shown % Math.max(1, count)];\n  const metric = testimonials[active % Math.max(1, count)]?.metric;\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-labelledby={titleId}\n      onKeyDown={onKeys}\n      className={cn(\n        '@container relative isolate w-full overflow-hidden bg-[#03110f] py-16 text-[#e8f3f2] md:py-24',\n        '[--card:clamp(13rem,27cqw,26rem)]',\n        className\n      )}\n      {...props}\n    >\n      <div className=\"mx-auto flex w-full max-w-[1320px] items-end justify-between gap-8 px-5 sm:px-8\">\n        <h2\n          id={titleId}\n          className=\"max-w-[12ch] text-[clamp(2.25rem,5cqw,4rem)] leading-[0.95] font-bold tracking-[-0.04em] text-[#e8f3f2]\"\n        >\n          {title}\n        </h2>\n        <div className=\"flex shrink-0 items-center gap-3\">\n          <button\n            type=\"button\"\n            aria-label=\"Previous testimonial\"\n            className={arrowButton}\n            onClick={() => goRef.current(-1)}\n          >\n            <Arrow />\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Next testimonial\"\n            className={arrowButton}\n            onClick={() => goRef.current(1)}\n          >\n            <Arrow flip />\n          </button>\n        </div>\n      </div>\n\n      <div\n        ref={stageRef}\n        aria-hidden=\"true\"\n        className=\"relative mt-6 h-[clamp(22rem,46cqw,38rem)] cursor-grab touch-pan-y select-none [perspective:900px] active:cursor-grabbing data-chip-over:cursor-none data-chip-over:active:cursor-none @3xl:[perspective:1150px]\"\n      >\n        <div\n          ref={floorRef}\n          className=\"pointer-events-none absolute top-[72%] left-1/2 h-[150%] w-[300%] origin-top [transform:translateX(-50%)_rotateX(78deg)] [background-image:linear-gradient(rgb(159_212_214/0.18)_1px,transparent_1px),linear-gradient(90deg,rgb(159_212_214/0.18)_1px,transparent_1px)] [mask-image:linear-gradient(to_bottom,#000_0%,transparent_55%)] [background-size:96px_96px]\"\n        />\n        <div ref={ringRef} className=\"absolute top-[42%] left-1/2 [transform-style:preserve-3d]\">\n          {slots.map((item, i) => (\n            <figure\n              key={`${item.name}-${i}`}\n              data-ring-card=\"\"\n              data-slot={i}\n              className=\"group absolute top-0 left-0 w-(--card) [backface-visibility:hidden]\"\n            >\n              <div className=\"relative overflow-hidden border border-[#9fd4d6]/12 bg-[#071d1b] [-webkit-box-reflect:below_14px_linear-gradient(transparent_62%,rgb(0_0_0/0.28))]\">\n                <div className=\"relative aspect-[4/3] overflow-hidden\">\n                  <img\n                    src={item.image}\n                    alt={item.imageAlt ?? ''}\n                    draggable={false}\n                    loading=\"lazy\"\n                    className=\"absolute inset-0 size-full object-cover transition-[scale] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-105 motion-reduce:transition-none\"\n                  />\n                </div>\n                <figcaption className=\"flex items-end justify-between gap-4 p-4 @3xl:p-5\">\n                  <div className=\"min-w-0\">\n                    <p className=\"truncate text-lg leading-tight font-bold text-[#e8f3f2]\">\n                      {item.company}\n                    </p>\n                    <p className=\"truncate text-xs text-[#9fd4d6]/70\">{item.name}</p>\n                  </div>\n                  <p className=\"shrink-0 text-2xl font-bold tracking-[-0.03em] text-[#c6f56f]\">\n                    {metricText(item.metric)}\n                  </p>\n                </figcaption>\n                <span\n                  data-ring-shade=\"\"\n                  className=\"pointer-events-none absolute inset-0 bg-[#03110f]\"\n                  style={{ opacity: 0 }}\n                />\n              </div>\n            </figure>\n          ))}\n        </div>\n      </div>\n\n      <div\n        ref={chipRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-0 left-0 z-20 hidden data-on:block motion-reduce:hidden\"\n      >\n        <div ref={popRef} className=\"relative grid h-14 w-28 place-items-center\">\n          <span\n            ref={blobRef}\n            className=\"absolute inset-0 rounded-2xl bg-[#c6f56f] shadow-[0_0_40px_rgb(198_245_111/0.35)]\"\n          />\n          <span\n            ref={labelRef}\n            className=\"relative flex items-center gap-1 text-[13px] font-semibold tracking-tight text-[#03110f]\"\n          >\n            <svg\n              data-chip-arrow=\"\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className=\"size-3.5\"\n            >\n              <path d=\"m15 18-6-6 6-6\" />\n            </svg>\n            <span data-chip-word=\"\">Drag</span>\n            <svg\n              data-chip-arrow=\"\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className=\"size-3.5\"\n            >\n              <path d=\"m9 18 6-6-6-6\" />\n            </svg>\n          </span>\n        </div>\n      </div>\n\n      {current && (\n        <div\n          ref={textRef}\n          className=\"relative mx-auto mt-4 grid w-full max-w-[1320px] grid-cols-1 gap-10 px-5 sm:px-8 @3xl:grid-cols-12 @3xl:items-end\"\n        >\n          <blockquote className=\"@3xl:col-span-8\">\n            <p\n              aria-live=\"polite\"\n              data-ring-text=\"\"\n              className=\"max-w-[30ch] text-[clamp(1.5rem,2.6cqw,2.75rem)] leading-[1.1] font-medium tracking-[-0.025em] text-[#e8f3f2]\"\n            >\n              &ldquo;{current.quote}&rdquo;\n            </p>\n          </blockquote>\n          <div className=\"flex flex-col gap-6 @3xl:col-span-4 @3xl:items-end @3xl:text-right\">\n            <div data-ring-text=\"\">\n              <p className=\"font-medium text-[#e8f3f2]\">{current.name}</p>\n              <p className=\"text-sm text-[#9fd4d6]/70\">\n                {current.role}, {current.company}\n              </p>\n            </div>\n            {metric && (\n              <div className=\"flex items-center gap-3 @3xl:flex-row-reverse\">\n                <NumberFlow\n                  value={metric.value}\n                  prefix={metric.prefix}\n                  suffix={metric.suffix}\n                  className=\"text-[clamp(2.5rem,4.5cqw,4.5rem)] leading-none font-extrabold tracking-[-0.04em] text-[#c6f56f]\"\n                  transformTiming={{ duration: 1200, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }}\n                  spinTiming={{ duration: 1200, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }}\n                  animated={!prefersReducedMotion()}\n                />\n                <span data-ring-text=\"\" className=\"max-w-[16ch] text-sm text-[#9fd4d6]/70\">\n                  {current.metric.label}\n                </span>\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n    </section>\n  );\n};\n\nexport default TestimonialRing;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/testimonial-ring\nGSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up.\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping.\nPass your own `testimonials`; the ring repeats them until it has at least ten cards, so three or thirty both close the circle."
}