Octocat

Marquee

A component that displays a continuous stream of content.

Foundationsegg
'use client';

import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';

const MarqueePreview = () => {
  return (
    <Marquee className="w-96 items-center gap-2" direction="left">
      Foundations
      <Egg />
    </Marquee>
  );
};

export default MarqueePreview;

Dependencies

Source Code

'use client';

import {
  Children,
  type ComponentPropsWithoutRef,
  cloneElement,
  type 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/classnames';

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);

  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]);

  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 = Number.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;
    };
  }, [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) =>
          // biome-ignore lint/suspicious/noExplicitAny: expected
          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

Prop Default Type Description
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 boolean Whether the marquee is paused.

Examples

Direction

left
Foundationsegg
up
Foundationsegg
right
Foundationsegg
down
Foundationsegg
'use client';

import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';

const MarqueeDirectionExample = () => {
  return (
    <div className="grid grid-cols-2 gap-8 whitespace-pre">
      {['left', 'up', 'right', 'down'].map((direction) => (
        <div key={direction}>
          <div className="mb-2 font-medium capitalize">{direction}</div>
          <Marquee
            direction={direction as 'left' | 'right' | 'up' | 'down'}
            className="h-[2.5em] w-48 items-center gap-2 rounded border border-border px-2 text-foreground-secondary"
          >
            Foundations
            <Egg />
          </Marquee>
        </div>
      ))}
    </div>
  );
};

export default MarqueeDirectionExample;

Duration Control

1000
Foundationsegg
'use client';

import { useState } from 'react';

import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';

const MarqueeDurationExample = () => {
  const [duration, setDuration] = useState(1000);

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-4">
        <label
          htmlFor="speed"
          className="whitespace-nowrap font-medium text-base"
        >
          Duration <span className="text-foreground-secondary">(ms)</span>
        </label>
        <input
          id="speed"
          type="range"
          min={100}
          max={2000}
          step={100}
          value={duration}
          onChange={(e) => setDuration(Number(e.target.value))}
          className="w-48"
        />
        <span className="w-12 text-foreground-secondary">{duration}</span>
      </div>
      <Marquee
        duration={duration}
        className="h-[2.5em] w-96 items-center gap-2 rounded border border-border px-2 text-foreground-secondary"
      >
        Foundations
        <Egg />
      </Marquee>
    </div>
  );
};

export default MarqueeDurationExample;

Pause on Hover

Hover to pause
Foundationsegg
'use client';

import { useState } from 'react';

import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';

const MarqueePauseHoverExample = () => {
  const [isPaused, setIsPaused] = useState(false);

  return (
    <div className="space-y-4">
      <div className="font-medium text-base">Hover to pause</div>
      <Marquee
        paused={isPaused}
        className="h-[2.5em] w-96 items-center gap-2 rounded border border-border px-2 text-foreground-secondary"
        onMouseEnter={() => setIsPaused(true)}
        onMouseLeave={() => setIsPaused(false)}
      >
        Foundations
        <Egg />
      </Marquee>
    </div>
  );
};

export default MarqueePauseHoverExample;

Dynamic Content

0
'use client';

import { MinusIcon, PlusIcon } from '@phosphor-icons/react';
import { useState } from 'react';

import { Marquee } from '@/components/marquee';
import { Button } from '@/components/button';

const MarqueeDynamicContentExample = () => {
  const [items, setItems] = useState(['0']);

  const addItem = () => {
    setItems((prevItems) => [...prevItems, `${prevItems.length}`]);
  };

  const removeItem = () => {
    if (items.length > 1) {
      setItems(items.slice(0, -1));
    }
  };

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-4">
        <Button onClick={addItem} variant="outline" size="sm">
          <PlusIcon size={16} />
          Add
        </Button>
        <Button onClick={removeItem} variant="outline" size="sm">
          <MinusIcon size={16} />
          Remove
        </Button>
      </div>
      <Marquee className="h-[2.5em] w-96 items-center gap-2 rounded border border-border px-2 text-foreground-secondary">
        {items}
      </Marquee>
    </div>
  );
};

export default MarqueeDynamicContentExample;

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.

Previous

Instance Counter

Next

Sequence