Marquee

Continuously scrolling content lane for badges, logos, and status chips.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/marquee.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import * as React from "react";

import { cn } from "../../lib/utils";

export type MarqueeSpeed = "fast" | "normal" | "slow";

export type MarqueeProps = React.ComponentPropsWithoutRef<"div"> & {
  duration?: number;
  fade?: boolean;
  gap?: number | string;
  pauseOnHover?: boolean;
  repeat?: number;
  reverse?: boolean;
  speed?: MarqueeSpeed;
  vertical?: boolean;
};

function getGapValue(gap: number | string): string {
  return typeof gap === "number" ? `${gap}px` : gap;
}

function getMaskImage(vertical: boolean): string {
  return vertical
    ? "linear-gradient(to bottom, transparent, black 12%, black 88%, transparent)"
    : "linear-gradient(to right, transparent, black 12%, black 88%, transparent)";
}

function getTrackItems(
  children: React.ReactNode,
  repeat: number,
): React.ReactNode[] {
  const items = React.Children.toArray(children);

  return Array.from({ length: Math.max(1, repeat) }, (_, copyIndex) =>
    items.map((item, itemIndex) => (
      <div className="shrink-0" key={`${copyIndex}-${itemIndex}`}>
        {item}
      </div>
    )),
  ).flat();
}

function getDuration(
  duration: number | undefined,
  speed: MarqueeSpeed,
): number {
  if (duration !== undefined) {
    return duration;
  }

  switch (speed) {
    case "fast":
      return 10;
    case "normal":
      return 20;
    case "slow":
      return 32;
  }
}

export const Marquee = React.forwardRef<HTMLDivElement, MarqueeProps>(
  (
    {
      children,
      className,
      duration,
      fade = true,
      gap = "1rem",
      pauseOnHover = false,
      repeat = 1,
      reverse = false,
      speed = "normal",
      style,
      vertical = false,
      ...props
    },
    ref,
  ) => {
    const resolvedGap = getGapValue(gap);
    const resolvedDuration = getDuration(duration, speed);
    const trackItems = getTrackItems(children, repeat);

    const animationStyle: React.CSSProperties = {
      animationDirection: reverse ? "reverse" : "normal",
      animationDuration: `${resolvedDuration}s`,
      animationIterationCount: "infinite",
      animationName: vertical ? "vllnt-marquee-y" : "vllnt-marquee-x",
      animationTimingFunction: "linear",
    };
    const maskImage = getMaskImage(vertical);

    return (
      <div
        className={cn(
          "group relative overflow-hidden",
          vertical ? "flex h-full flex-col" : "flex w-full flex-row",
          className,
        )}
        ref={ref}
        style={
          fade ? { ...style, maskImage, WebkitMaskImage: maskImage } : style
        }
        {...props}
      >
        <div
          className={cn(
            "flex shrink-0 will-change-transform motion-reduce:animate-none motion-reduce:transform-none",
            pauseOnHover && "group-hover:[animation-play-state:paused]",
            vertical ? "min-h-full flex-col" : "min-w-full flex-row",
          )}
          style={animationStyle}
        >
          {[0, 1].map((groupIndex) => (
            <div
              aria-hidden={groupIndex === 1}
              className={cn(
                "flex shrink-0",
                vertical ? "flex-col items-stretch" : "flex-row items-center",
              )}
              key={groupIndex}
              style={{ gap: resolvedGap }}
            >
              {trackItems}
            </div>
          ))}
        </div>
      </div>
    );
  },
);

Marquee.displayName = "Marquee";
typescript

Dependencies

  • @vllnt/ui@^0.2.1