Marquee

A component that displays a continuous stream of content.

Dependencies

Source Code

"use client";
 
import {
  Children,
  cloneElement,
  ComponentPropsWithoutRef,
  ReactElement,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
 
import { useIntersectionObserver } from "@/foundations/hooks/use-intersection-observer";
import { useTicker } from "@/foundations/hooks/use-ticker";
import { cn } from "@/lib/utils";
 
type DurationProp = number | ((contentLength: number) => number);
 
interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
  direction?: "left" | "right" | "up" | "down";
  paused?: boolean;
  duration?: DurationProp;
}
 
export const Marquee = ({
  direction: propDirection = "left",
  paused = false,
  children,
  duration,
  style,
  className,
  ...props
}: MarqueeProps) => {
  const [numClones, setNumClones] = useState<number>(1);
  const [rootRef, { isIntersecting }] =
    useIntersectionObserver<HTMLDivElement>();
 
  const progress = useRef(0);
  const contentLength = useRef(0);
  const deferredResizeHandler = useRef<() => void | null>(null);
 
  const getDuration = useMemo(() => {
    if (typeof duration === "number") return () => duration;
    if (typeof duration === "function")
      return () => duration(contentLength.current);
 
    // default duration to 50ms per pixel
    return () => contentLength.current * 50;
  }, [duration, contentLength]);
 
  const [axis, direction] = useMemo(() => {
    return [
      propDirection === "up" || propDirection === "down" ? "y" : "x",
      propDirection === "up" || propDirection === "left" ? "normal" : "reverse",
    ];
  }, [propDirection]);
 
  const ticker = useTicker((timestamp, delta) => {
    if (deferredResizeHandler.current) {
      deferredResizeHandler.current();
      deferredResizeHandler.current = null;
    }
 
    const root = rootRef.current;
    if (!root) return;
 
    progress.current = (progress.current + delta / getDuration()) % 1 || 0;
 
    root.style.setProperty("--progress", progress.current.toString());
  });
 
  useEffect(() => {
    if (paused || !isIntersecting) {
      ticker.stop();
    } else if (isIntersecting) {
      ticker.start();
    }
  }, [ticker, paused, isIntersecting]);
 
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
 
    const content = [...root.children].filter(
      (child) => !child.hasAttribute("data-clone")
    );
 
    const getLength = (element: HTMLElement) => {
      return element.getBoundingClientRect()[axis === "x" ? "width" : "height"];
    };
 
    const onResize = () => {
      const rootLength = getLength(root);
      const gap = Number(getComputedStyle(root).gap.replace("px", ""));
      const gapLength = isNaN(gap) ? 0 : gap;
 
      contentLength.current = content.reduce(
        (acc, item) => acc + getLength(item as HTMLElement),
        0
      );
 
      const numClones = Math.ceil(rootLength / contentLength.current);
      setNumClones(numClones);
 
      root.style.setProperty(
        "--content-length",
        `${contentLength.current + gapLength * content.length}px`
      );
    };
 
    const resizeObserver = new ResizeObserver(() => {
      // if ticker is running, defer the resize handler to the next tick
      // otherwise, call the handler immediately
      if (ticker.paused) {
        onResize();
      } else {
        deferredResizeHandler.current = () => onResize();
      }
    });
 
    onResize();
    content.forEach((item) => resizeObserver.observe(item));
 
    return () => {
      resizeObserver.disconnect();
      deferredResizeHandler.current = null;
    };
  }, [children, axis, rootRef, ticker]);
 
  const transformedChildren = useMemo(() => {
    return Children.map(children, (child) => {
      if (typeof child === "string" || typeof child === "number") {
        return <span className="inline-block">{child}</span>;
      }
 
      return child;
    });
  }, [children]);
 
  return (
    <div
      ref={rootRef}
      role="marquee"
      aria-live="off"
      aria-atomic="false"
      {...props}
      className={cn(
        "box-content flex w-max overflow-hidden will-change-transform",
        "[&>*]:shrink-0 [&>*]:will-change-transform",
        axis === "x" && "flex-row [&>*]:translate-x-(--translate)",
        axis === "y" && "flex-col [&>*]:translate-y-(--translate)",
        className
      )}
      style={{
        ...style,
        "--translate": `calc((${direction === "normal" ? "-1 * " : "-1 + "}var(--progress,0)) * var(--content-length,0px))`,
      }}
    >
      {transformedChildren}
      {Array.from({ length: numClones }).map((_, index) =>
        Children.map(transformedChildren, (child) =>
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          cloneElement(child as ReactElement<any>, {
            key: index,
            "aria-hidden": "true",
            "data-clone": "",
          })
        )
      )}
    </div>
  );
};

Features

  • Native-Like Implementation: Closely mirrors the behavior of the native (now deprecated) marquee element, including maintaining a single-element structure
  • Dynamic Content Cloning: Automatically creates the optimal number of content clones based on element size and content length
  • Automatic Text Wrapping: Text content is automatically wrapped in <span> elements to ensure proper rendering with CSS transforms
  • Customizable Gap: Supports flexible spacing between items using CSS gap property, allowing for consistent and adjustable spacing between content elements
  • Intersection Observer: Pauses animation when not in viewport
  • Flexible Duration Control: Duration can be specified as:
    • A fixed number
    • A function that receives content length for fine-grained and responsive speed control

Anatomy

<Marquee>{/* Content */}</Marquee>

API Reference

PropDefaultTypeDescription

direction

"left"

"left" | "right" | "up" | "down"

The direction of the marquee.

duration

(contentLength) => contentLength * 50

number | ((contentLength: number) => number)

The duration of the marquee in milliseconds.

paused

false

boolean

Whether the marquee is paused.

Examples

Direction

Duration Control

Pause on Hover

Dynamic Content

Best Practices

Avoid Media Without Dimensions: when using images or videos in a marquee, always specify explicit width and height attributes. Without proper dimensions, the content may cause layout shifts as it loads, disrupting the smooth scrolling behavior. This is especially important since the marquee needs to calculate the total content length to determine scrolling speed and clone count.