React.js

Screen Recording 2025-09-29 at 2.13.11 AM 2.mov

InfiniteMarquee.tsx

type InfiniteMarqueeProps<T> = {
  items: T[];
  renderItem: (item: T, i: number) => React.ReactNode;
  /** seconds per full loop; higher = slower */
  speedSeconds?: number;
  /** pause the animation when hovering the row */
  pauseOnHover?: boolean;
  /** scroll right-to-left by default; set true to reverse */
  reverse?: boolean;
  /** Tailwind gap class, e.g. "gap-6" (applies inside the UL) */
  gapClass?: string;
  className?: string;
  /** Optional gradient mask on edges */
  fadeEdges?: boolean;
};

export default function InfiniteMarquee<T>({
  items,
  renderItem,
  speedSeconds = 30,
  pauseOnHover = true,
  reverse = false,
  gapClass = "gap-6",
  className = "",
  fadeEdges = true,
}: InfiniteMarqueeProps<T>) {
  const ulRef = React.useRef<HTMLUListElement | null>(null);
  const firstARef = React.useRef<HTMLLIElement | null>(null);
  const firstBRef = React.useRef<HTMLLIElement | null>(null);
  const [dist, setDist] = React.useState(0);
  const [paused, setPaused] = React.useState(false);

  // Measure exact distance from first item of pass A to first item of pass B.
  // This automatically includes all internal gaps + the seam gap, with no rounding drift.
  React.useLayoutEffect(() => {
    const ul = ulRef.current;

    const measure = () => {
      const a = firstARef.current;
      const b = firstBRef.current;
      if (!ul || !a || !b) return;

      // offsetLeft is relative to the nearest offsetParent (the UL here), perfect for our case.
      const dx = b.offsetLeft - a.offsetLeft;
      if (dx > 0) setDist(dx);
    };

    measure();

    // Re-measure on resize / content changes
    const ro = new ResizeObserver(measure);
    if (ul) ro.observe(ul);
    window.addEventListener("load", measure);

    return () => {
      ro.disconnect();
      window.removeEventListener("load", measure);
    };
  }, [items, gapClass]);

  const styleVars: React.CSSProperties = {
    ["--dur" as any]: `${speedSeconds}s`,
    ["--listW" as any]: `${dist}px`,
    animation:
      dist > 0
        ? `${
            reverse ? "marquee-px-reverse" : "marquee-px"
          } var(--dur) linear infinite`
        : undefined,
    animationPlayState: paused ? "paused" : "running",
  };

  return (
    <div
      className={[
        "relative w-full overflow-hidden",
        fadeEdges
          ? "[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]"
          : "",
        className,
      ].join(" ")}
    >
      <ul
        ref={ulRef}
        style={styleVars}
        className={[
          "inline-flex flex-nowrap list-none m-0 p-0 transform-gpu",
          gapClass,
        ].join(" ")}
        onMouseEnter={() => pauseOnHover && setPaused(true)}
        onMouseLeave={() => pauseOnHover && setPaused(false)}
        onTouchStart={() => pauseOnHover && setPaused(true)}
        onTouchEnd={() => pauseOnHover && setPaused(false)}
      >
        {/* Pass A */}
        {items.map((it, i) => (
          <li
            key={`a-${i}`}
            ref={i === 0 ? firstARef : undefined}
            className="shrink-0"
          >
            {renderItem(it, i)}
          </li>
        ))}
        {/* Pass B (duplicate) */}
        {items.map((it, i) => (
          <li
            key={`b-${i}`}
            ref={i === 0 ? firstBRef : undefined}
            className="shrink-0"
            aria-hidden
          >
            {renderItem(it, i)}
          </li>
        ))}
      </ul>

      {/* Respect reduced motion */}
      <div className="sr-only motion-reduce:not-sr-only">
        Animations are reduced per system preferences.
      </div>
    </div>
  );
}

index.css

@layer utilities {
  @keyframes marquee-px {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-1 * var(--listW, 0px))); }
  }
  @keyframes marquee-px-reverse {
    from { transform: translateX(calc(-1 * var(--listW, 0px))); }
    to   { transform: translateX(0); }
  }
}