# Testimonial Reel Focus

> 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.

A shadcn/ui block from the [@tween-ui](https://tween-ui.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tween-ui/testimonial-reel-focus).

- Page, with a live preview: https://shadcn.exchange/r/tween-ui/testimonial-reel-focus
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tween-ui/testimonial-reel-focus-light-d1005c75f451a4e3.png
- Registry item: https://tween-ui.vercel.app/r/testimonial-reel-focus.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://tween-ui.vercel.app/r/testimonial-reel-focus.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@gsap/react`, `gsap`

## Notes from the registry

Docs, props and examples: https://tween-ui.vercel.app/block/testimonial-reel-focus
GSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up.
The 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.

## Files

### `components/tweenui/testimonial-reel-focus.tsx`

```tsx
'use client';

import {
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type CSSProperties,
} from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { cn } from '@/lib/utils';

gsap.registerPlugin(useGSAP, ScrollTrigger);

const prefersReducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** Pulling focus is a pointer affordance — skip it on touch. */
const canHover = () =>
  typeof window !== 'undefined' &&
  window.matchMedia('(hover: hover) and (pointer: fine)').matches &&
  !prefersReducedMotion();

/**
 * The element `position: sticky` will actually stick to — the nearest scrollable
 * ancestor, or the window. ScrollTrigger has to watch the same one, or a reel dropped
 * into a scrolling panel (a docs preview, a drawer, a modal) would pin against a
 * scrollbar nobody is moving. `hidden` is left out on purpose: it captures sticky but
 * can never scroll, so a reel under one is better off on the window.
 */
const scrollportOf = (node: HTMLElement) => {
  let el = node.parentElement;
  while (el && el !== document.body && el !== document.documentElement) {
    const { overflowY } = getComputedStyle(el);
    if (overflowY === 'auto' || overflowY === 'scroll') return el;
    el = el.parentElement;
  }
  return undefined;
};

/** Scrubber bars: 2px wide on a 3px gap, lifting from 8px at rest to 18px under the head. */
const TICK_WIDTH = 2;
const TICK_GAP = 3;
const TICK_MIN_HEIGHT = 8;
const TICK_MAX_HEIGHT = 18;
const TICK_MIN_ALPHA = 0.18;
/** Bars lit either side of the head. */
const TICK_RADIUS = 3;

/** How far below its resting line a card waits while it is still off to the right. */
const WAVE_RISE = 56;
/** Share of a card's width it spends rising, once its leading edge has crossed in. */
const WAVE_SPAN = 0.9;
/** How faint a card is before it has entered. */
const WAVE_MIN_ALPHA = 0.2;

/** Scroller speed, in px per second, turned into tilt per card away from centre. */
const TILT_PER_VELOCITY = 0.012;
/** Cap, so a flung scroll bows the row rather than folding it. */
const TILT_MAX = 26;
/** Milliseconds of stillness before the row falls flat again. */
const TILT_SETTLE = 140;

const DIM_OPACITY = 0.38;
const DIM_BLUR = 6;
const DIM_SCALE = 0.985;

const clamp = (n: number, min: number, max: number) => (n < min ? min : n > max ? max : n);

const pad2 = (n: number) => String(n).padStart(2, '0');

/** Smoothstep, so a card eases out of the wave instead of arriving on a straight line. */
const smooth = (t: number) => t * t * (3 - 2 * t);

/** Bars that fit a strip `width` wide. The last one needs no gap after it. */
const fitTicks = (width: number) =>
  width > 0 ? Math.max(0, Math.floor((width + TICK_GAP) / (TICK_WIDTH + TICK_GAP))) : 0;

/**
 * The index both the counter and the tick cursor sit on. Anchored at the ends rather
 * than tracking whatever is under a focus line, so the run starts on the first card and
 * finishes on the last — and the number can never disagree with the lit bar.
 */
const nearestIndex = (progress: number, count: number) =>
  count > 0 ? clamp(Math.round(clamp(progress, 0, 1) * (count - 1)), 0, count - 1) : 0;

/** Initials for the monogram when a voice brings no `initials` of its own. */
const initialsOf = (name: string) =>
  name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((word) => word[0]?.toUpperCase() ?? '')
    .join('') || '·';

/** Hues held in a teal-to-indigo band, so eight avatars read as a set and not a rainbow. */
const hueOf = (index: number) => 168 + ((index * 29) % 78);

/** Film grain over the monogram gradient, so the chip reads as printed. */
const GRAIN =
  "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\")";

export interface TestimonialReelItem {
  /** Who is speaking. */
  name: string;
  /** Their role, shown under the name. */
  role: string;
  /** Monogram for the avatar. Derived from `name` when omitted. */
  initials?: string;
  /** Degree on the colour wheel the monogram gradient is built from. */
  hue?: number;
  /** The quote itself, plain text — the curly quotes are added for you. */
  quote: string;
  /** Pill on the card's footer, e.g. the kind of work. */
  tag: string;
  /** Outcome beside the pill, marked with an accent dot. */
  metric: string;
}

export interface TestimonialReelFocusProps extends ComponentPropsWithoutRef<'section'> {
  /** The voices, in the order they ride the reel. */
  items?: TestimonialReelItem[];
  /** Note at the end of the scrubber row. */
  hint?: string;
}

const DEFAULT_HINT = 'Hover to focus';

const DEFAULT_ITEMS: TestimonialReelItem[] = [
  {
    name: 'Maya Rahman',
    role: 'CEO, Northwind',
    quote:
      'They did not just redesign our brand — they gave the whole company a spine. Our sales calls got shorter.',
    tag: 'Brand identity',
    metric: 'Sales cycle −30%',
  },
  {
    name: 'Daniel Osei',
    role: 'VP Engineering, Halo Labs',
    quote:
      'The design system paid for itself in a quarter. Engineers stopped asking what things should look like and started shipping.',
    tag: 'Design system',
    metric: 'Ships 2× faster',
  },
  {
    name: 'Priya Venkataraman',
    role: 'CMO, Kindred',
    quote:
      'Three directions, all of them right. I have never had a harder time choosing, or an easier time defending it to the board.',
    tag: 'Campaign',
    metric: '+41% sign-ups',
  },
  {
    name: 'Tomás Reyes',
    role: 'Founder, Lumen Health',
    quote:
      'They made a 2am telehealth flow feel like a conversation, not a form. Completion has not stopped climbing since launch.',
    tag: 'Product · App',
    metric: '+27% completion',
  },
  {
    name: 'Hana Kobayashi',
    role: 'Head of Brand, Atlas & Co.',
    quote:
      'A small senior team that actually listens. Every review ended with fewer open questions than it started with.',
    tag: 'Brand identity',
    metric: 'One round of edits',
  },
  {
    name: 'Marcus Hale',
    role: 'COO, Verge',
    quote:
      'The strategy work changed how our leadership talks about the company. The identity simply made it visible.',
    tag: 'Strategy',
    metric: 'Repositioned in 8 weeks',
  },
  {
    name: 'Elena Petrova',
    role: 'Design Director, Monolith',
    quote:
      'Rare to find a studio that cares about the system as much as the hero shot. We still build on their foundations.',
    tag: 'Design system',
    metric: '3 years on one base',
  },
  {
    name: 'Kenji Watanabe',
    role: 'CTO, Parallel',
    quote:
      'Clear process, honest timelines, and work we were proud to put in front of investors. We would hire them again tomorrow.',
    tag: 'Web · Motion',
    metric: 'Raised on the first deck',
  },
];

export default function TestimonialReelFocus({
  items = DEFAULT_ITEMS,
  hint = DEFAULT_HINT,
  className,
  // The reel is all there is, so the section carries its own name rather than
  // borrowing one from a heading. Pass your own to say what these voices are about.
  'aria-label': ariaLabel = 'Testimonials',
  ...props
}: TestimonialReelFocusProps) {
  const rootRef = useRef<HTMLElement>(null);
  const pinRef = useRef<HTMLDivElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const viewportRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLUListElement>(null);
  const stripRef = useRef<HTMLDivElement>(null);
  const counterRef = useRef<HTMLSpanElement>(null);
  /** The <li>: carries the wave, and the geometry the wave is measured from. */
  const cardRefs = useRef<(HTMLLIElement | null)[]>([]);
  /** The face inside it: carries hover focus, so the two never write the same property. */
  const shellRefs = useRef<(HTMLDivElement | null)[]>([]);

  /** Bars are rendered by React but painted by hand — 60 state updates a second is not a plan. */
  const [tickCount, setTickCount] = useState(0);
  /** The reel only earns a pinned runway when the row is wider than the stage. */
  const [pinned, setPinned] = useState(false);
  const headRef = useRef(-1);
  const drawRef = useRef<() => void>(() => {});

  const total = items.length;

  useGSAP(
    () => {
      const pin = pinRef.current;
      const viewport = viewportRef.current;
      const track = trackRef.current;
      const strip = stripRef.current;
      if (!pin || !viewport || !track || total === 0) return;

      const reduce = prefersReducedMotion();
      const scrollport = scrollportOf(pin);
      const cards = cardRefs.current.filter(Boolean) as HTMLLIElement[];
      const setX = gsap.quickSetter(track, 'x', 'px');
      const setY = cards.map((card) => gsap.quickSetter(card, 'y', 'px'));
      const setAlpha = cards.map((card) => gsap.quickSetter(card, 'opacity'));

      let rail: ScrollTrigger | null = null;
      let run = 0;
      let shown = -1;
      /** Each card's resting left edge in the stage, before the row is moved. */
      let offsets: number[] = [];
      let cardWidth = 0;
      /** How far the row has been carried left, and how hard it is leaning on it. */
      let shift = 0;
      const flex = { tilt: 0 };
      const middle = (cards.length - 1) / 2;
      let settle: ReturnType<typeof setTimeout> | null = null;

      // How far the row has to travel: its full width less the window it shows through.
      // The runway height is built from this, so it is the one number worth getting right.
      const measure = () => {
        run = Math.max(0, track.scrollWidth - viewport.clientWidth);
        offsets = cards.map((card) => card.offsetLeft - viewport.offsetLeft);
        cardWidth = cards[0]?.offsetWidth ?? 0;
        pin.style.setProperty('--reel-run', `${run}px`);
        // The stage fills its scrollport, whatever that turns out to be — one viewport
        // on a page, the panel's own height when the reel is embedded in one.
        pin.style.setProperty(
          '--reel-view',
          `${scrollport ? scrollport.clientHeight : window.innerHeight}px`
        );
        setPinned(!reduce && run > 0);
      };

      /**
       * The wave, which is two things at once.
       *
       * A card waits a little below its line while it is still off to the right and
       * rises as its leading edge crosses in, so every card arrives rather than only
       * the two that happened to be on stage when the section came into view.
       *
       * On top of that the row *flexes while it travels*: cards ahead of centre swing
       * low and the ones behind ride high, by an amount set by how fast the reel is
       * moving. That is what makes eight cards read as one strip of film being drawn
       * past, and it is why the row is flat again the moment you stop scrolling.
       */
      const paintCards = () => {
        if (reduce || cardWidth === 0) return;
        const edge = viewport.clientWidth;
        const span = cardWidth * WAVE_SPAN;
        for (let i = 0; i < cards.length; i++) {
          const entered = smooth(clamp((edge - (offsets[i] + shift)) / span, 0, 1));
          setY[i]((1 - entered) * WAVE_RISE + (i - middle) * flex.tilt);
          setAlpha[i](WAVE_MIN_ALPHA + (1 - WAVE_MIN_ALPHA) * entered);
        }
      };

      /**
       * Lean the row by however fast the scroller is going, then let it fall flat a
       * beat after the scrolling stops. The lean tracks the scroll directly — smoothing
       * it would put the row behind the cards it belongs to — so only the release is a
       * tween, and it is the same tween whichever direction you came to rest from.
       */
      const lean = (velocity: number) => {
        gsap.killTweensOf(flex);
        flex.tilt = clamp(velocity * TILT_PER_VELOCITY, -TILT_MAX, TILT_MAX);
        paintCards();
        if (settle) clearTimeout(settle);
        settle = setTimeout(() => {
          gsap.to(flex, {
            tilt: 0,
            duration: 0.55,
            ease: 'power2.out',
            onUpdate: paintCards,
          });
        }, TILT_SETTLE);
      };

      const paintMeter = (progress: number) => {
        const index = nearestIndex(progress, total);
        if (index !== shown) {
          shown = index;
          if (counterRef.current) counterRef.current.textContent = pad2(index + 1);
        }
        if (!strip) return;

        strip.setAttribute('aria-valuenow', String(Math.round(clamp(progress, 0, 1) * 100)));
        strip.setAttribute('aria-valuetext', `${index + 1} of ${total}`);

        const bars = Array.from(strip.children) as HTMLElement[];
        const head = nearestIndex(progress, bars.length);
        if (head === headRef.current) return;
        headRef.current = head;

        // A triangular falloff that peaks on the head and tapers to the resting bar.
        bars.forEach((bar, i) => {
          const distance = Math.abs(i - head);
          if (distance > TICK_RADIUS) {
            delete bar.dataset.on;
            bar.style.height = '';
            bar.style.opacity = '';
            return;
          }
          const falloff = 1 - distance / TICK_RADIUS;
          bar.dataset.on = '';
          bar.style.height = `${TICK_MIN_HEIGHT + (TICK_MAX_HEIGHT - TICK_MIN_HEIGHT) * falloff}px`;
          bar.style.opacity = String(TICK_MIN_ALPHA + (1 - TICK_MIN_ALPHA) * falloff);
        });
      };

      // One place that decides where everything sits, so load, resize and refresh agree.
      // A refresh only fires onUpdate when progress has moved, and React rebuilds the bars
      // on resize — either way the reel has to be told to paint itself again.
      const draw = () => {
        const progress = run > 0 && rail ? rail.progress : 0;
        shift = run > 0 ? -run * progress : 0;
        setX(shift);
        paintCards();
        paintMeter(progress);
      };
      drawRef.current = draw;

      const nativeProgress = () => {
        const max = viewport.scrollWidth - viewport.clientWidth;
        return max > 0 ? clamp(viewport.scrollLeft / max, 0, 1) : 0;
      };

      const fit = () => setTickCount(fitTicks(strip?.clientWidth ?? 0));

      measure();
      fit();

      if (reduce) {
        // Motion turned down: the row stays a native horizontal scroller and the meter
        // reads that scroller. Nothing is pinned, nothing waves and nothing is tweened,
        // but the section still counts itself off properly.
        paintMeter(nativeProgress());
        viewport.addEventListener('scroll', () => paintMeter(nativeProgress()), { passive: true });
      } else {
        rail = ScrollTrigger.create({
          trigger: pin,
          scroller: scrollport,
          start: 'top top',
          // Re-read on every refresh: a resize changes how far the row must travel.
          end: () => `+=${run}`,
          invalidateOnRefresh: true,
          onRefreshInit: measure,
          onUpdate: (self) => {
            if (run <= 0) return;
            shift = -run * self.progress;
            setX(shift);
            paintMeter(self.progress);
            lean(self.getVelocity());
          },
        });
        draw();
      }

      // Width is the only thing that changes the reel's maths — the runway growing as
      // --reel-run is set must not send us round again.
      let seenStage = viewport.clientWidth;
      let seenStrip = strip?.clientWidth ?? 0;
      let timer: ReturnType<typeof setTimeout> | null = null;
      const observer = new ResizeObserver(() => {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          const stripWidth = strip?.clientWidth ?? 0;
          if (viewport.clientWidth === seenStage && stripWidth === seenStrip) return;
          seenStage = viewport.clientWidth;
          seenStrip = stripWidth;
          measure();
          fit();
          if (reduce) return paintMeter(nativeProgress());
          ScrollTrigger.refresh();
          draw();
        }, 150);
      });
      observer.observe(stageRef.current ?? pin);

      return () => {
        if (timer) clearTimeout(timer);
        if (settle) clearTimeout(settle);
        gsap.killTweensOf(flex);
        observer.disconnect();
      };
    },
    { scope: rootRef, dependencies: [items] }
  );

  // React owns how many bars exist, so repaint whenever it has rebuilt them.
  useEffect(() => {
    headRef.current = -1;
    drawRef.current();
  }, [tickCount]);

  /** Pull one card forward by pushing the rest back. */
  const focus = (index: number | null) => {
    if (!canHover()) return;
    shellRefs.current.forEach((shell, i) => {
      if (!shell) return;
      const dim = index !== null && i !== index;
      gsap.to(shell, {
        opacity: dim ? DIM_OPACITY : 1,
        filter: dim ? `blur(${DIM_BLUR}px)` : 'blur(0px)',
        scale: dim ? DIM_SCALE : 1,
        duration: 0.45,
        ease: 'power2.out',
        overwrite: 'auto',
      });
    });
  };

  return (
    <section
      ref={rootRef}
      data-testimonial-reel-focus
      aria-label={ariaLabel}
      className={cn(
        // clip, not hidden: `hidden` would make this a scroll container and the stage
        // inside would stick to it, which is to say never stick at all.
        'relative w-full overflow-clip bg-white text-[#12161F]',
        'dark:bg-[#0d1117] dark:text-white',
        '[--reel-accent:#045f64] dark:[--reel-accent:#c6f56f]',
        className
      )}
      {...props}
    >
      {/* The runway: one scrollport of stage plus however far the row has to travel. */}
      <div
        ref={pinRef}
        data-reel-pin
        className={cn(
          '[--reel-card-h:400px] [--reel-card-w:min(78vw,420px)] [--reel-gap:12px]',
          'md:[--reel-card-h:min(460px,66svh)] md:[--reel-card-w:420px] md:[--reel-gap:24px]',
          '[--reel-view:100svh]',
          pinned && 'h-[calc(var(--reel-view)+var(--reel-run,0px))]'
        )}
      >
        <div
          ref={stageRef}
          className={cn(
            // cqw in here is the stage's own width, which is what --reel-edge measures
            // against — vw would be off by the scrollbar.
            '@container flex flex-col justify-center gap-8',
            '[--reel-edge:max(1.25rem,calc((100cqw-1290px)/2))]',
            pinned && 'sticky top-0 h-(--reel-view)'
          )}
        >
          <div
            ref={viewportRef}
            data-reel-viewport
            className={cn(
              // Before the reel is measured — and whenever motion is turned down — the
              // same row is a plain horizontal scroller, so every card stays reachable.
              '[scrollbar-width:none] overscroll-x-contain [&::-webkit-scrollbar]:hidden',
              pinned
                ? // Clip sideways but leave the vertical alone, so a card riding the
                  // wave is not sheared off at the stage edge.
                  'overflow-x-clip overflow-y-visible'
                : 'snap-x snap-proximity overflow-x-auto'
            )}
          >
            <ul
              ref={trackRef}
              data-reel-track
              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"
            >
              {items.map((item, index) => {
                const hue = item.hue ?? hueOf(index);
                return (
                  <li
                    key={`${item.name}-${index}`}
                    ref={(node) => {
                      cardRefs.current[index] = node;
                    }}
                    data-reel-card
                    className="w-(--reel-card-w) flex-none snap-center will-change-transform motion-reduce:transform-none motion-reduce:opacity-100"
                    onPointerEnter={() => focus(index)}
                    onPointerLeave={() => focus(null)}
                  >
                    {/* A plain hairline that warms to the accent under the pointer.
                        Deliberately not a masked gradient ring: the `mask` shorthand
                        resets `mask-composite`, whichever order the two are authored
                        in, so the ring un-punches and floods the card with accent. */}
                    <div
                      ref={(node) => {
                        shellRefs.current[index] = node;
                      }}
                      data-reel-shell
                      className={cn(
                        'relative h-(--reel-card-h) w-full overflow-hidden rounded-xl border will-change-transform',
                        '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)]',
                        'dark:border-white/10 dark:bg-[#161b22] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06)]',
                        'transition-colors duration-500 ease-out hover:border-[#045f64]/45 dark:hover:border-[#c6f56f]/40',
                        'motion-reduce:transform-none motion-reduce:opacity-100 motion-reduce:blur-none motion-reduce:transition-none'
                      )}
                    >
                      <article className="relative flex h-full flex-col p-6 md:p-8">
                        <div className="flex items-center gap-4">
                          <span
                            aria-hidden="true"
                            style={{ '--reel-hue': hue } as CSSProperties}
                            className={cn(
                              'relative grid size-10 flex-none place-items-center overflow-hidden rounded-full md:size-12',
                              'text-xs font-semibold tracking-[0.06em] text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.25)] md:text-[13px]',
                              'shadow-[0_0_0_1px_rgba(18,22,31,0.12)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.14)]',
                              'bg-[linear-gradient(150deg,oklch(0.6_0.09_var(--reel-hue))_0%,oklch(0.36_0.07_calc(var(--reel-hue)+40))_100%)]'
                            )}
                          >
                            {item.initials ?? initialsOf(item.name)}
                            <span
                              className="absolute inset-0 opacity-[0.18] mix-blend-overlay"
                              style={{ backgroundImage: GRAIN, backgroundSize: '50px 50px' }}
                            />
                          </span>
                          <span className="grid min-w-0 flex-1 gap-0.5">
                            <span className="text-[15px] leading-tight font-medium tracking-tight">
                              {item.name}
                            </span>
                            <span className="text-[13px] leading-tight text-[#18181b]/50 dark:text-white/50">
                              {item.role}
                            </span>
                          </span>
                          <span className="flex-none text-xs whitespace-nowrap text-(--reel-accent) tabular-nums">
                            {pad2(index + 1)}/{pad2(total)}
                          </span>
                        </div>

                        {/* The quote sits on the floor of every card, so the reel reads
                            as one baseline rather than eight ragged ones. */}
                        <div className="mt-auto grid gap-4 md:gap-5">
                          <svg
                            viewBox="0 0 24 19"
                            fill="none"
                            aria-hidden="true"
                            className="h-auto w-6 text-(--reel-accent)"
                          >
                            <path
                              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"
                              fill="currentColor"
                            />
                            <path
                              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"
                              fill="currentColor"
                            />
                          </svg>
                          <blockquote className="text-[17px] leading-relaxed tracking-tight text-pretty">
                            {`“${item.quote}”`}
                          </blockquote>
                        </div>

                        <div className="mt-6 flex items-center justify-between gap-4 border-t border-[#12161F]/10 pt-4 dark:border-white/10">
                          <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">
                            {item.tag}
                          </span>
                          <span className="inline-flex items-center gap-2 text-xs tracking-[0.04em] whitespace-nowrap uppercase">
                            <span
                              aria-hidden="true"
                              className="size-1.5 flex-none rounded-full bg-(--reel-accent)"
                            />
                            {item.metric}
                          </span>
                        </div>
                      </article>
                    </div>
                  </li>
                );
              })}
            </ul>
          </div>

          <div className="flex items-center gap-4 px-(--reel-edge) md:gap-6">
            <p className="text-xs whitespace-nowrap tabular-nums">
              <span ref={counterRef}>{pad2(1)}</span>
              <span className="text-[#18181b]/45 dark:text-white/45"> / {pad2(total)}</span>
            </p>
            <div
              ref={stripRef}
              role="progressbar"
              aria-label="Testimonials progress"
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={0}
              className="flex h-[18px] min-w-0 flex-1 items-end gap-[3px] overflow-hidden"
            >
              {/* Bars are painted by hand from the scroll position; React only sizes the strip. */}
              {Array.from({ length: tickCount }, (_, i) => (
                <span
                  key={i}
                  aria-hidden="true"
                  className={cn(
                    'h-2 w-0.5 flex-none bg-[#12161F]/10 transition-[height,opacity] duration-300 ease-out dark:bg-white/12',
                    'data-[on]:bg-[linear-gradient(180deg,#12161F_0%,var(--reel-accent)_100%)]',
                    'dark:data-[on]:bg-[linear-gradient(180deg,#ffffff_0%,var(--reel-accent)_100%)]',
                    'motion-reduce:transition-none'
                  )}
                />
              ))}
            </div>
            <p className="text-xs tracking-[0.04em] whitespace-nowrap text-[#18181b]/45 uppercase dark:text-white/45">
              Scroll<span className="hidden sm:inline"> · {hint}</span>
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}
```
