{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-reel-focus",
  "type": "registry:block",
  "title": "Testimonial Reel Focus",
  "description": "A reel of testimonial cards wider than the window, run sideways by vertical page scroll from inside a sticky stage. Each card rises as it crosses in, so scrolling sends a swell along the row; a tick strip reads the run back like a scrubber, and hovering a card pulls it into focus while the rest of the row dims and blurs behind it.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/testimonial-reel-focus.tsx",
      "type": "registry:component",
      "target": "components/tweenui/testimonial-reel-focus.tsx",
      "content": "'use client';\n\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type CSSProperties,\n} from 'react';\nimport { useGSAP } from '@gsap/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\n/** Pulling focus is a pointer affordance — skip it on touch. */\nconst canHover = () =>\n  typeof window !== 'undefined' &&\n  window.matchMedia('(hover: hover) and (pointer: fine)').matches &&\n  !prefersReducedMotion();\n\n/**\n * The element `position: sticky` will actually stick to — the nearest scrollable\n * ancestor, or the window. ScrollTrigger has to watch the same one, or a reel dropped\n * into a scrolling panel (a docs preview, a drawer, a modal) would pin against a\n * scrollbar nobody is moving. `hidden` is left out on purpose: it captures sticky but\n * can never scroll, so a reel under one is better off on the window.\n */\nconst scrollportOf = (node: HTMLElement) => {\n  let el = node.parentElement;\n  while (el && el !== document.body && el !== document.documentElement) {\n    const { overflowY } = getComputedStyle(el);\n    if (overflowY === 'auto' || overflowY === 'scroll') return el;\n    el = el.parentElement;\n  }\n  return undefined;\n};\n\n/** Scrubber bars: 2px wide on a 3px gap, lifting from 8px at rest to 18px under the head. */\nconst TICK_WIDTH = 2;\nconst TICK_GAP = 3;\nconst TICK_MIN_HEIGHT = 8;\nconst TICK_MAX_HEIGHT = 18;\nconst TICK_MIN_ALPHA = 0.18;\n/** Bars lit either side of the head. */\nconst TICK_RADIUS = 3;\n\n/** How far below its resting line a card waits while it is still off to the right. */\nconst WAVE_RISE = 56;\n/** Share of a card's width it spends rising, once its leading edge has crossed in. */\nconst WAVE_SPAN = 0.9;\n/** How faint a card is before it has entered. */\nconst WAVE_MIN_ALPHA = 0.2;\n\n/** Scroller speed, in px per second, turned into tilt per card away from centre. */\nconst TILT_PER_VELOCITY = 0.012;\n/** Cap, so a flung scroll bows the row rather than folding it. */\nconst TILT_MAX = 26;\n/** Milliseconds of stillness before the row falls flat again. */\nconst TILT_SETTLE = 140;\n\nconst DIM_OPACITY = 0.38;\nconst DIM_BLUR = 6;\nconst DIM_SCALE = 0.985;\n\nconst clamp = (n: number, min: number, max: number) => (n < min ? min : n > max ? max : n);\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\n/** Smoothstep, so a card eases out of the wave instead of arriving on a straight line. */\nconst smooth = (t: number) => t * t * (3 - 2 * t);\n\n/** Bars that fit a strip `width` wide. The last one needs no gap after it. */\nconst fitTicks = (width: number) =>\n  width > 0 ? Math.max(0, Math.floor((width + TICK_GAP) / (TICK_WIDTH + TICK_GAP))) : 0;\n\n/**\n * The index both the counter and the tick cursor sit on. Anchored at the ends rather\n * than tracking whatever is under a focus line, so the run starts on the first card and\n * finishes on the last — and the number can never disagree with the lit bar.\n */\nconst nearestIndex = (progress: number, count: number) =>\n  count > 0 ? clamp(Math.round(clamp(progress, 0, 1) * (count - 1)), 0, count - 1) : 0;\n\n/** Initials for the monogram when a voice brings no `initials` of its own. */\nconst initialsOf = (name: string) =>\n  name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((word) => word[0]?.toUpperCase() ?? '')\n    .join('') || '·';\n\n/** Hues held in a teal-to-indigo band, so eight avatars read as a set and not a rainbow. */\nconst hueOf = (index: number) => 168 + ((index * 29) % 78);\n\n/** Film grain over the monogram gradient, so the chip reads as printed. */\nconst GRAIN =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='80' height='80' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\n\nexport interface TestimonialReelItem {\n  /** Who is speaking. */\n  name: string;\n  /** Their role, shown under the name. */\n  role: string;\n  /** Monogram for the avatar. Derived from `name` when omitted. */\n  initials?: string;\n  /** Degree on the colour wheel the monogram gradient is built from. */\n  hue?: number;\n  /** The quote itself, plain text — the curly quotes are added for you. */\n  quote: string;\n  /** Pill on the card's footer, e.g. the kind of work. */\n  tag: string;\n  /** Outcome beside the pill, marked with an accent dot. */\n  metric: string;\n}\n\nexport interface TestimonialReelFocusProps extends ComponentPropsWithoutRef<'section'> {\n  /** The voices, in the order they ride the reel. */\n  items?: TestimonialReelItem[];\n  /** Note at the end of the scrubber row. */\n  hint?: string;\n}\n\nconst DEFAULT_HINT = 'Hover to focus';\n\nconst DEFAULT_ITEMS: TestimonialReelItem[] = [\n  {\n    name: 'Maya Rahman',\n    role: 'CEO, Northwind',\n    quote:\n      'They did not just redesign our brand — they gave the whole company a spine. Our sales calls got shorter.',\n    tag: 'Brand identity',\n    metric: 'Sales cycle −30%',\n  },\n  {\n    name: 'Daniel Osei',\n    role: 'VP Engineering, Halo Labs',\n    quote:\n      'The design system paid for itself in a quarter. Engineers stopped asking what things should look like and started shipping.',\n    tag: 'Design system',\n    metric: 'Ships 2× faster',\n  },\n  {\n    name: 'Priya Venkataraman',\n    role: 'CMO, Kindred',\n    quote:\n      'Three directions, all of them right. I have never had a harder time choosing, or an easier time defending it to the board.',\n    tag: 'Campaign',\n    metric: '+41% sign-ups',\n  },\n  {\n    name: 'Tomás Reyes',\n    role: 'Founder, Lumen Health',\n    quote:\n      'They made a 2am telehealth flow feel like a conversation, not a form. Completion has not stopped climbing since launch.',\n    tag: 'Product · App',\n    metric: '+27% completion',\n  },\n  {\n    name: 'Hana Kobayashi',\n    role: 'Head of Brand, Atlas & Co.',\n    quote:\n      'A small senior team that actually listens. Every review ended with fewer open questions than it started with.',\n    tag: 'Brand identity',\n    metric: 'One round of edits',\n  },\n  {\n    name: 'Marcus Hale',\n    role: 'COO, Verge',\n    quote:\n      'The strategy work changed how our leadership talks about the company. The identity simply made it visible.',\n    tag: 'Strategy',\n    metric: 'Repositioned in 8 weeks',\n  },\n  {\n    name: 'Elena Petrova',\n    role: 'Design Director, Monolith',\n    quote:\n      'Rare to find a studio that cares about the system as much as the hero shot. We still build on their foundations.',\n    tag: 'Design system',\n    metric: '3 years on one base',\n  },\n  {\n    name: 'Kenji Watanabe',\n    role: 'CTO, Parallel',\n    quote:\n      'Clear process, honest timelines, and work we were proud to put in front of investors. We would hire them again tomorrow.',\n    tag: 'Web · Motion',\n    metric: 'Raised on the first deck',\n  },\n];\n\nconst TestimonialReelFocus = ({\n  items = DEFAULT_ITEMS,\n  hint = DEFAULT_HINT,\n  className,\n  // The reel is all there is, so the section carries its own name rather than\n  // borrowing one from a heading. Pass your own to say what these voices are about.\n  'aria-label': ariaLabel = 'Testimonials',\n  ...props\n}: TestimonialReelFocusProps) => {\n  const rootRef = useRef<HTMLElement>(null);\n  const pinRef = useRef<HTMLDivElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLUListElement>(null);\n  const stripRef = useRef<HTMLDivElement>(null);\n  const counterRef = useRef<HTMLSpanElement>(null);\n  /** The <li>: carries the wave, and the geometry the wave is measured from. */\n  const cardRefs = useRef<(HTMLLIElement | null)[]>([]);\n  /** The face inside it: carries hover focus, so the two never write the same property. */\n  const shellRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n  /** Bars are rendered by React but painted by hand — 60 state updates a second is not a plan. */\n  const [tickCount, setTickCount] = useState(0);\n  /** The reel only earns a pinned runway when the row is wider than the stage. */\n  const [pinned, setPinned] = useState(false);\n  const headRef = useRef(-1);\n  const drawRef = useRef<() => void>(() => {});\n\n  const total = items.length;\n\n  useGSAP(\n    () => {\n      const pin = pinRef.current;\n      const viewport = viewportRef.current;\n      const track = trackRef.current;\n      const strip = stripRef.current;\n      if (!pin || !viewport || !track || total === 0) return;\n\n      const reduce = prefersReducedMotion();\n      const scrollport = scrollportOf(pin);\n      const cards = cardRefs.current.filter(Boolean) as HTMLLIElement[];\n      const setX = gsap.quickSetter(track, 'x', 'px');\n      const setY = cards.map((card) => gsap.quickSetter(card, 'y', 'px'));\n      const setAlpha = cards.map((card) => gsap.quickSetter(card, 'opacity'));\n\n      let rail: ScrollTrigger | null = null;\n      let run = 0;\n      let shown = -1;\n      /** Each card's resting left edge in the stage, before the row is moved. */\n      let offsets: number[] = [];\n      let cardWidth = 0;\n      /** How far the row has been carried left, and how hard it is leaning on it. */\n      let shift = 0;\n      const flex = { tilt: 0 };\n      const middle = (cards.length - 1) / 2;\n      let settle: ReturnType<typeof setTimeout> | null = null;\n\n      // How far the row has to travel: its full width less the window it shows through.\n      // The runway height is built from this, so it is the one number worth getting right.\n      const measure = () => {\n        run = Math.max(0, track.scrollWidth - viewport.clientWidth);\n        offsets = cards.map((card) => card.offsetLeft - viewport.offsetLeft);\n        cardWidth = cards[0]?.offsetWidth ?? 0;\n        pin.style.setProperty('--reel-run', `${run}px`);\n        // The stage fills its scrollport, whatever that turns out to be — one viewport\n        // on a page, the panel's own height when the reel is embedded in one.\n        pin.style.setProperty(\n          '--reel-view',\n          `${scrollport ? scrollport.clientHeight : window.innerHeight}px`\n        );\n        setPinned(!reduce && run > 0);\n      };\n\n      /**\n       * The wave, which is two things at once.\n       *\n       * A card waits a little below its line while it is still off to the right and\n       * rises as its leading edge crosses in, so every card arrives rather than only\n       * the two that happened to be on stage when the section came into view.\n       *\n       * On top of that the row *flexes while it travels*: cards ahead of centre swing\n       * low and the ones behind ride high, by an amount set by how fast the reel is\n       * moving. That is what makes eight cards read as one strip of film being drawn\n       * past, and it is why the row is flat again the moment you stop scrolling.\n       */\n      const paintCards = () => {\n        if (reduce || cardWidth === 0) return;\n        const edge = viewport.clientWidth;\n        const span = cardWidth * WAVE_SPAN;\n        for (let i = 0; i < cards.length; i++) {\n          const entered = smooth(clamp((edge - (offsets[i] + shift)) / span, 0, 1));\n          setY[i]((1 - entered) * WAVE_RISE + (i - middle) * flex.tilt);\n          setAlpha[i](WAVE_MIN_ALPHA + (1 - WAVE_MIN_ALPHA) * entered);\n        }\n      };\n\n      /**\n       * Lean the row by however fast the scroller is going, then let it fall flat a\n       * beat after the scrolling stops. The lean tracks the scroll directly — smoothing\n       * it would put the row behind the cards it belongs to — so only the release is a\n       * tween, and it is the same tween whichever direction you came to rest from.\n       */\n      const lean = (velocity: number) => {\n        gsap.killTweensOf(flex);\n        flex.tilt = clamp(velocity * TILT_PER_VELOCITY, -TILT_MAX, TILT_MAX);\n        paintCards();\n        if (settle) clearTimeout(settle);\n        settle = setTimeout(() => {\n          gsap.to(flex, {\n            tilt: 0,\n            duration: 0.55,\n            ease: 'power2.out',\n            onUpdate: paintCards,\n          });\n        }, TILT_SETTLE);\n      };\n\n      const paintMeter = (progress: number) => {\n        const index = nearestIndex(progress, total);\n        if (index !== shown) {\n          shown = index;\n          if (counterRef.current) counterRef.current.textContent = pad2(index + 1);\n        }\n        if (!strip) return;\n\n        strip.setAttribute('aria-valuenow', String(Math.round(clamp(progress, 0, 1) * 100)));\n        strip.setAttribute('aria-valuetext', `${index + 1} of ${total}`);\n\n        const bars = Array.from(strip.children) as HTMLElement[];\n        const head = nearestIndex(progress, bars.length);\n        if (head === headRef.current) return;\n        headRef.current = head;\n\n        // A triangular falloff that peaks on the head and tapers to the resting bar.\n        bars.forEach((bar, i) => {\n          const distance = Math.abs(i - head);\n          if (distance > TICK_RADIUS) {\n            delete bar.dataset.on;\n            bar.style.height = '';\n            bar.style.opacity = '';\n            return;\n          }\n          const falloff = 1 - distance / TICK_RADIUS;\n          bar.dataset.on = '';\n          bar.style.height = `${TICK_MIN_HEIGHT + (TICK_MAX_HEIGHT - TICK_MIN_HEIGHT) * falloff}px`;\n          bar.style.opacity = String(TICK_MIN_ALPHA + (1 - TICK_MIN_ALPHA) * falloff);\n        });\n      };\n\n      // One place that decides where everything sits, so load, resize and refresh agree.\n      // A refresh only fires onUpdate when progress has moved, and React rebuilds the bars\n      // on resize — either way the reel has to be told to paint itself again.\n      const draw = () => {\n        const progress = run > 0 && rail ? rail.progress : 0;\n        shift = run > 0 ? -run * progress : 0;\n        setX(shift);\n        paintCards();\n        paintMeter(progress);\n      };\n      drawRef.current = draw;\n\n      const nativeProgress = () => {\n        const max = viewport.scrollWidth - viewport.clientWidth;\n        return max > 0 ? clamp(viewport.scrollLeft / max, 0, 1) : 0;\n      };\n\n      const fit = () => setTickCount(fitTicks(strip?.clientWidth ?? 0));\n\n      measure();\n      fit();\n\n      if (reduce) {\n        // Motion turned down: the row stays a native horizontal scroller and the meter\n        // reads that scroller. Nothing is pinned, nothing waves and nothing is tweened,\n        // but the section still counts itself off properly.\n        paintMeter(nativeProgress());\n        viewport.addEventListener('scroll', () => paintMeter(nativeProgress()), { passive: true });\n      } else {\n        rail = ScrollTrigger.create({\n          trigger: pin,\n          scroller: scrollport,\n          start: 'top top',\n          // Re-read on every refresh: a resize changes how far the row must travel.\n          end: () => `+=${run}`,\n          invalidateOnRefresh: true,\n          onRefreshInit: measure,\n          onUpdate: (self) => {\n            if (run <= 0) return;\n            shift = -run * self.progress;\n            setX(shift);\n            paintMeter(self.progress);\n            lean(self.getVelocity());\n          },\n        });\n        draw();\n      }\n\n      // Width is the only thing that changes the reel's maths — the runway growing as\n      // --reel-run is set must not send us round again.\n      let seenStage = viewport.clientWidth;\n      let seenStrip = strip?.clientWidth ?? 0;\n      let timer: ReturnType<typeof setTimeout> | null = null;\n      const observer = new ResizeObserver(() => {\n        if (timer) clearTimeout(timer);\n        timer = setTimeout(() => {\n          const stripWidth = strip?.clientWidth ?? 0;\n          if (viewport.clientWidth === seenStage && stripWidth === seenStrip) return;\n          seenStage = viewport.clientWidth;\n          seenStrip = stripWidth;\n          measure();\n          fit();\n          if (reduce) return paintMeter(nativeProgress());\n          ScrollTrigger.refresh();\n          draw();\n        }, 150);\n      });\n      observer.observe(stageRef.current ?? pin);\n\n      return () => {\n        if (timer) clearTimeout(timer);\n        if (settle) clearTimeout(settle);\n        gsap.killTweensOf(flex);\n        observer.disconnect();\n      };\n    },\n    { scope: rootRef, dependencies: [items] }\n  );\n\n  // React owns how many bars exist, so repaint whenever it has rebuilt them.\n  useEffect(() => {\n    headRef.current = -1;\n    drawRef.current();\n  }, [tickCount]);\n\n  /** Pull one card forward by pushing the rest back. */\n  const focus = (index: number | null) => {\n    if (!canHover()) return;\n    shellRefs.current.forEach((shell, i) => {\n      if (!shell) return;\n      const dim = index !== null && i !== index;\n      gsap.to(shell, {\n        opacity: dim ? DIM_OPACITY : 1,\n        filter: dim ? `blur(${DIM_BLUR}px)` : 'blur(0px)',\n        scale: dim ? DIM_SCALE : 1,\n        duration: 0.45,\n        ease: 'power2.out',\n        overwrite: 'auto',\n      });\n    });\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      data-testimonial-reel-focus\n      aria-label={ariaLabel}\n      className={cn(\n        // clip, not hidden: `hidden` would make this a scroll container and the stage\n        // inside would stick to it, which is to say never stick at all.\n        'relative w-full overflow-clip bg-white text-[#12161F]',\n        'dark:bg-[#0d1117] dark:text-white',\n        '[--reel-accent:#045f64] dark:[--reel-accent:#c6f56f]',\n        className\n      )}\n      {...props}\n    >\n      {/* The runway: one scrollport of stage plus however far the row has to travel. */}\n      <div\n        ref={pinRef}\n        data-reel-pin\n        className={cn(\n          '[--reel-card-h:400px] [--reel-card-w:min(78vw,420px)] [--reel-gap:12px]',\n          'md:[--reel-card-h:min(460px,66svh)] md:[--reel-card-w:420px] md:[--reel-gap:24px]',\n          '[--reel-view:100svh]',\n          pinned && 'h-[calc(var(--reel-view)+var(--reel-run,0px))]'\n        )}\n      >\n        <div\n          ref={stageRef}\n          className={cn(\n            // cqw in here is the stage's own width, which is what --reel-edge measures\n            // against — vw would be off by the scrollbar.\n            '@container flex flex-col justify-center gap-8',\n            '[--reel-edge:max(1.25rem,calc((100cqw-1290px)/2))]',\n            pinned && 'sticky top-0 h-(--reel-view)'\n          )}\n        >\n          <div\n            ref={viewportRef}\n            data-reel-viewport\n            className={cn(\n              // Before the reel is measured — and whenever motion is turned down — the\n              // same row is a plain horizontal scroller, so every card stays reachable.\n              '[scrollbar-width:none] overscroll-x-contain [&::-webkit-scrollbar]:hidden',\n              pinned\n                ? // Clip sideways but leave the vertical alone, so a card riding the\n                  // wave is not sheared off at the stage edge.\n                  'overflow-x-clip overflow-y-visible'\n                : 'snap-x snap-proximity overflow-x-auto'\n            )}\n          >\n            <ul\n              ref={trackRef}\n              data-reel-track\n              className=\"flex w-max items-start gap-(--reel-gap) px-(--reel-edge) py-5 will-change-transform motion-reduce:transform-none motion-reduce:will-change-auto\"\n            >\n              {items.map((item, index) => {\n                const hue = item.hue ?? hueOf(index);\n                return (\n                  <li\n                    key={`${item.name}-${index}`}\n                    ref={(node) => {\n                      cardRefs.current[index] = node;\n                    }}\n                    data-reel-card\n                    className=\"w-(--reel-card-w) flex-none snap-center will-change-transform motion-reduce:transform-none motion-reduce:opacity-100\"\n                    onPointerEnter={() => focus(index)}\n                    onPointerLeave={() => focus(null)}\n                  >\n                    {/* A plain hairline that warms to the accent under the pointer.\n                        Deliberately not a masked gradient ring: the `mask` shorthand\n                        resets `mask-composite`, whichever order the two are authored\n                        in, so the ring un-punches and floods the card with accent. */}\n                    <div\n                      ref={(node) => {\n                        shellRefs.current[index] = node;\n                      }}\n                      data-reel-shell\n                      className={cn(\n                        'relative h-(--reel-card-h) w-full overflow-hidden rounded-xl border will-change-transform',\n                        'border-[#12161F]/10 bg-white shadow-[0_1px_2px_rgba(18,22,31,0.05),0_18px_40px_-28px_rgba(18,22,31,0.35)]',\n                        'dark:border-white/10 dark:bg-[#161b22] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06)]',\n                        'transition-colors duration-500 ease-out hover:border-[#045f64]/45 dark:hover:border-[#c6f56f]/40',\n                        'motion-reduce:transform-none motion-reduce:opacity-100 motion-reduce:blur-none motion-reduce:transition-none'\n                      )}\n                    >\n                      <article className=\"relative flex h-full flex-col p-6 md:p-8\">\n                        <div className=\"flex items-center gap-4\">\n                          <span\n                            aria-hidden=\"true\"\n                            style={{ '--reel-hue': hue } as CSSProperties}\n                            className={cn(\n                              'relative grid size-10 flex-none place-items-center overflow-hidden rounded-full md:size-12',\n                              'text-xs font-semibold tracking-[0.06em] text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.25)] md:text-[13px]',\n                              'shadow-[0_0_0_1px_rgba(18,22,31,0.12)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.14)]',\n                              'bg-[linear-gradient(150deg,oklch(0.6_0.09_var(--reel-hue))_0%,oklch(0.36_0.07_calc(var(--reel-hue)+40))_100%)]'\n                            )}\n                          >\n                            {item.initials ?? initialsOf(item.name)}\n                            <span\n                              className=\"absolute inset-0 opacity-[0.18] mix-blend-overlay\"\n                              style={{ backgroundImage: GRAIN, backgroundSize: '50px 50px' }}\n                            />\n                          </span>\n                          <span className=\"grid min-w-0 flex-1 gap-0.5\">\n                            <span className=\"text-[15px] leading-tight font-medium tracking-tight\">\n                              {item.name}\n                            </span>\n                            <span className=\"text-[13px] leading-tight text-[#18181b]/50 dark:text-white/50\">\n                              {item.role}\n                            </span>\n                          </span>\n                          <span className=\"flex-none text-xs whitespace-nowrap text-(--reel-accent) tabular-nums\">\n                            {pad2(index + 1)}/{pad2(total)}\n                          </span>\n                        </div>\n\n                        {/* The quote sits on the floor of every card, so the reel reads\n                            as one baseline rather than eight ragged ones. */}\n                        <div className=\"mt-auto grid gap-4 md:gap-5\">\n                          <svg\n                            viewBox=\"0 0 24 19\"\n                            fill=\"none\"\n                            aria-hidden=\"true\"\n                            className=\"h-auto w-6 text-(--reel-accent)\"\n                          >\n                            <path\n                              d=\"M3 0h5a3 3 0 0 1 3 3v4c0 6.2-3.1 10.4-8.4 12l-.7-2.4c3.1-1.2 4.9-2.9 5.5-5.6H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z\"\n                              fill=\"currentColor\"\n                            />\n                            <path\n                              d=\"M16 0h5a3 3 0 0 1 3 3v4c0 6.2-3.1 10.4-8.4 12l-.7-2.4c3.1-1.2 4.9-2.9 5.5-5.6h-4.4a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3z\"\n                              fill=\"currentColor\"\n                            />\n                          </svg>\n                          <blockquote className=\"text-[17px] leading-relaxed tracking-tight text-pretty\">\n                            {`“${item.quote}”`}\n                          </blockquote>\n                        </div>\n\n                        <div className=\"mt-6 flex items-center justify-between gap-4 border-t border-[#12161F]/10 pt-4 dark:border-white/10\">\n                          <span className=\"inline-flex items-center rounded-full border border-[#12161F]/12 px-3 py-1.5 text-xs tracking-[0.04em] whitespace-nowrap text-[#18181b]/55 uppercase dark:border-white/15 dark:text-white/55\">\n                            {item.tag}\n                          </span>\n                          <span className=\"inline-flex items-center gap-2 text-xs tracking-[0.04em] whitespace-nowrap uppercase\">\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"size-1.5 flex-none rounded-full bg-(--reel-accent)\"\n                            />\n                            {item.metric}\n                          </span>\n                        </div>\n                      </article>\n                    </div>\n                  </li>\n                );\n              })}\n            </ul>\n          </div>\n\n          <div className=\"flex items-center gap-4 px-(--reel-edge) md:gap-6\">\n            <p className=\"text-xs whitespace-nowrap tabular-nums\">\n              <span ref={counterRef}>{pad2(1)}</span>\n              <span className=\"text-[#18181b]/45 dark:text-white/45\"> / {pad2(total)}</span>\n            </p>\n            <div\n              ref={stripRef}\n              role=\"progressbar\"\n              aria-label=\"Testimonials progress\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={0}\n              className=\"flex h-[18px] min-w-0 flex-1 items-end gap-[3px] overflow-hidden\"\n            >\n              {/* Bars are painted by hand from the scroll position; React only sizes the strip. */}\n              {Array.from({ length: tickCount }, (_, i) => (\n                <span\n                  key={i}\n                  aria-hidden=\"true\"\n                  className={cn(\n                    'h-2 w-0.5 flex-none bg-[#12161F]/10 transition-[height,opacity] duration-300 ease-out dark:bg-white/12',\n                    'data-[on]:bg-[linear-gradient(180deg,#12161F_0%,var(--reel-accent)_100%)]',\n                    'dark:data-[on]:bg-[linear-gradient(180deg,#ffffff_0%,var(--reel-accent)_100%)]',\n                    'motion-reduce:transition-none'\n                  )}\n                />\n              ))}\n            </div>\n            <p className=\"text-xs tracking-[0.04em] whitespace-nowrap text-[#18181b]/45 uppercase dark:text-white/45\">\n              Scroll<span className=\"hidden sm:inline\"> · {hint}</span>\n            </p>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default TestimonialReelFocus;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/testimonial-reel-focus\nGSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up.\nThe reel is driven by vertical scroll, so place it on a page that scrolls. It pins against the window, or against the nearest scrolling ancestor when it sits inside a panel or drawer. With reduced motion on it stays a plain horizontal strip."
}