shadimage

Marquee

A scroll-velocity marquee with seamless loop math, eased hover pause, vertical/horizontal direction, ResizeObserver-based copy count, and full prefers-reduced-motion support.

registry/sonaui/marquee/marquee.tsx

"use client";

import {
  useAnimationFrame,
  useMotionValue,
  useReducedMotion,
  useScroll,
  useSpring,
  useVelocity,
} from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/sona-utils";

export interface MarqueeProps {
  /** Content of one marquee segment. Can be a single small element or a long strip. */
  children: React.ReactNode;
  className?: string;
  /** Class for the outer clipping container. */
  containerClassName?: string;
  /**
   * Scroll speed in pixels per second. Higher = faster.
   * @default 80
   */
  speed?: number;
  /**
   * Gap between repeated segments, any CSS length string.
   * @default "4rem"
   */
  gap?: string;
  /**
   * Scroll direction.
   * @default "left"
   */
  direction?: "left" | "right" | "up" | "down";
  /**
   * Multiply speed by scroll velocity (and flip direction on scroll-up).
   * @default false
   */
  scrollVelocity?: boolean;
  /**
   * Max speed multiplier when scrollVelocity is enabled.
   * @default 5
   */
  maxVelocity?: number;
  /**
   * Pause (with easing, not a snap) on hover.
   * @default false
   */
  pauseOnHover?: boolean;
  /**
   * How many segment copies to render.
   * "auto" measures the container and fills 2× it.
   * @default "auto"
   */
  repeat?: number | "auto";
}

export default function Marquee({
  children,
  className,
  containerClassName,
  speed = 80,
  gap = "4rem",
  direction = "left",
  scrollVelocity = false,
  maxVelocity = 5,
  pauseOnHover = false,
  repeat = "auto",
}: MarqueeProps) {
  const shouldReduceMotion = useReducedMotion();

  const containerRef = useRef<HTMLDivElement>(null);
  const segmentRef = useRef<HTMLDivElement>(null);

  // How many copies are rendered (updated by ResizeObserver)
  const countRef = useRef<number>(typeof repeat === "number" ? repeat : 4);

  // Motion value for translation — never stored in React state
  const baseX = useMotionValue(0);
  const isHovered = useRef(false);
  // 0 = fully paused, 1 = full speed — lerped in rAF
  const speedMultiplier = useRef(1);

  // Scroll velocity pipeline (motion values only — no setState)
  const { scrollY } = useScroll();
  const scrollVelocityMV = useVelocity(scrollY);
  const smoothVelocity = useSpring(scrollVelocityMV, {
    damping: 50,
    stiffness: 400,
  });

  const isVertical = direction === "up" || direction === "down";
  const directionSign = direction === "left" || direction === "up" ? 1 : -1;

  // ResizeObserver — recompute copy count when container or segment resize
  useEffect(() => {
    if (repeat !== "auto" || shouldReduceMotion) return;

    const container = containerRef.current;
    const segment = segmentRef.current;
    if (!container || !segment) return;

    function recompute() {
      if (!container || !segment) return;
      const containerSize = isVertical
        ? container.offsetHeight
        : container.offsetWidth;
      const segmentSize = isVertical
        ? segment.offsetHeight
        : segment.offsetWidth;
      if (segmentSize > 0) {
        countRef.current = Math.max(
          2,
          Math.ceil((containerSize * 2) / segmentSize) + 1,
        );
      }
    }

    recompute();
    const ro = new ResizeObserver(recompute);
    ro.observe(container);
    ro.observe(segment);
    return () => ro.disconnect();
  }, [repeat, isVertical, shouldReduceMotion]);

  // Hover handlers
  const onMouseEnter = () => {
    if (pauseOnHover) isHovered.current = true;
  };
  const onMouseLeave = () => {
    if (pauseOnHover) isHovered.current = false;
  };

  // Main animation loop — all in motion values, zero React state
  useAnimationFrame((_, delta) => {
    if (shouldReduceMotion) return;

    const segment = segmentRef.current;
    if (!segment) return;

    const segmentSize = isVertical ? segment.offsetHeight : segment.offsetWidth;
    if (segmentSize === 0) return;

    // Lerp speedMultiplier toward target (eased pause on hover)
    const targetMultiplier = isHovered.current ? 0 : 1;
    speedMultiplier.current +=
      (targetMultiplier - speedMultiplier.current) * 0.1;

    // Velocity boost from scroll
    let velocityBoost = 1;
    let velocityFlip = 1;
    if (scrollVelocity) {
      const v = smoothVelocity.get();
      velocityBoost = Math.min(Math.abs(v) / 200, maxVelocity);
      velocityBoost = Math.max(velocityBoost, 1);
      if (v < -50) velocityFlip = -1;
    }

    const pxPerMs = speed / 1000;
    const delta_px =
      pxPerMs *
      delta *
      directionSign *
      velocityFlip *
      speedMultiplier.current *
      velocityBoost;

    let next = baseX.get() - delta_px;

    // Wrap: keep translation within [-segmentSize, 0)
    if (directionSign > 0) {
      // moving left/up — translate goes negative (scroll-up flip can drive positive)
      if (next >= 0) next -= segmentSize;
      if (next <= -segmentSize) next += segmentSize;
    } else {
      // moving right/down — translate goes positive
      if (next >= 0) next -= segmentSize;
      if (next < -segmentSize) next += segmentSize;
    }

    baseX.set(next);
  });

  const copies = repeat !== "auto" ? repeat : countRef.current;
  const items = Array.from({ length: copies });

  if (shouldReduceMotion) {
    // Static strip — no animation
    return (
      <section
        ref={containerRef}
        aria-label="Scrolling content"
        className={cn("overflow-hidden", containerClassName)}
      >
        <div
          className={cn(
            isVertical ? "flex flex-col" : "flex flex-row",
            "w-max",
            className,
          )}
          style={{ gap }}
        >
          <div ref={segmentRef}>{children}</div>
          {items.map((_, i) => (
            // biome-ignore lint/suspicious/noArrayIndexKey: static decorative copies
            <div key={i} aria-hidden="true">
              {children}
            </div>
          ))}
        </div>
      </section>
    );
  }

  return (
    <section
      ref={containerRef}
      aria-label="Scrolling content"
      className={cn("overflow-hidden", containerClassName)}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      {/* The track: a flex row/col of copies, translated as a unit */}
      <MotionTrack
        baseX={baseX}
        isVertical={isVertical}
        gap={gap}
        className={className}
        segmentRef={segmentRef}
        items={items}
      >
        {children}
      </MotionTrack>
    </section>
  );
}

// Split into a separate component to isolate motion subscription
import { type MotionValue, motion } from "motion/react";

function MotionTrack({
  baseX,
  isVertical,
  gap,
  className,
  segmentRef,
  items,
  children,
}: {
  baseX: MotionValue<number>;
  isVertical: boolean;
  gap: string;
  className?: string;
  segmentRef: React.RefObject<HTMLDivElement | null>;
  items: unknown[];
  children: React.ReactNode;
}) {
  return (
    <motion.div
      style={isVertical ? { y: baseX } : { x: baseX }}
      className={cn(
        isVertical ? "flex flex-col" : "flex flex-row",
        "w-max will-change-transform",
        className,
      )}
    >
      {/* First segment — measured for loop math */}
      <div
        ref={segmentRef}
        style={{
          paddingRight: isVertical ? 0 : gap,
          paddingBottom: isVertical ? gap : 0,
        }}
      >
        {children}
      </div>
      {/* Copies — decorative, aria-hidden */}
      {items.map((_, i) => (
        <div
          key={String(i)}
          aria-hidden="true"
          style={{
            paddingRight: isVertical ? 0 : gap,
            paddingBottom: isVertical ? gap : 0,
          }}
        >
          {children}
        </div>
      ))}
    </motion.div>
  );
}
Interaction
marquee

DependenciesBundle weight · medium

animation
motion