Skip to content

Dual Logo Marquee

Two rows scrolling in opposite directions with edge fades.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/dual-logo-marquee.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/dual-logo-marquee/dual-logo-marquee.tsx
    "use client";
    
    import { useEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from "react";
    import { motion, useAnimationFrame, useInView, useMotionValue, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    
    export interface MarqueeLogo {
      name: string;
      logo: ReactNode;
      href?: string;
    }
    
    export interface DualLogoMarqueeProps extends Omit<ComponentProps<"section">, "children"> {
      /** Logos for the first row (scrolls left). */
      items: MarqueeLogo[];
      /** Logos for the second row (scrolls right). @default items, reversed */
      secondRow?: MarqueeLogo[];
      /** Scroll speed in pixels per second. @default 32 */
      speed?: number;
      /** Ease both rows to a stop while hovered or focused. @default true */
      pauseOnHover?: boolean;
      /** Small line above the rows. */
      eyebrow?: string;
    }
    
    function Row({ items, direction, speed, paused }: { items: MarqueeLogo[]; direction: 1 | -1; speed: number; paused: RefObject<boolean> }) {
      const reduceMotion = useReducedMotion() ?? false;
      const x = useMotionValue(0);
      const track = useRef<HTMLUListElement>(null);
      const [width, setWidth] = useState(0);
      const velocity = useRef(1);
    
      useEffect(() => {
        const el = track.current;
        if (!el) return;
        const observer = new ResizeObserver(() => setWidth(el.offsetWidth));
        observer.observe(el);
        return () => observer.disconnect();
      }, []);
    
      // Offscreen, the loop does no work (it resumes just before the track scrolls back into view).
      const inView = useInView(track, { margin: "200px" });
    
      useAnimationFrame((_, delta) => {
        if (!inView || reduceMotion || width === 0) return;
        const target = paused.current ? 0 : 1;
        velocity.current += (target - velocity.current) * Math.min(1, delta / 200);
        if (Math.abs(target - velocity.current) < 0.005) velocity.current = target;
        if (velocity.current === 0) return;
        let next = x.get() + direction * speed * velocity.current * (delta / 1000);
        if (next <= -width) next += width;
        if (next > 0) next -= width;
        x.set(next);
      });
    
      const list = (copy: boolean) => (
        <ul
          ref={copy ? undefined : track}
          aria-hidden={copy || undefined}
          inert={copy}
          className={cn("flex shrink-0 items-center motion-reduce:w-full motion-reduce:shrink motion-reduce:flex-wrap motion-reduce:justify-center", copy && "motion-reduce:hidden")}
        >
          {items.map((item) => (
            <li key={item.name} className="flex h-16 shrink-0 items-center px-7 text-[20px] text-foreground/75 motion-reduce:h-14">
              {item.href ? (
                <a
                  href={item.href}
                  className="rounded-md outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4"
                >
                  <span className="sr-only">{item.name}</span>
                  <span aria-hidden="true">{item.logo}</span>
                </a>
              ) : (
                <span role="img" aria-label={item.name}>
                  <span aria-hidden="true">{item.logo}</span>
                </span>
              )}
            </li>
          ))}
        </ul>
      );
    
      return (
        <div className="overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)] motion-reduce:[mask-image:none]">
          <motion.div className="flex w-max motion-reduce:w-full" style={{ x }}>
            {list(false)}
            {list(true)}
          </motion.div>
        </div>
      );
    }
    
    export function DualLogoMarquee({ items, secondRow, speed = 32, pauseOnHover = true, eyebrow, className, ...props }: DualLogoMarqueeProps) {
      const paused = useRef(false);
      const second = secondRow ?? [...items].reverse();
      const pause = (value: boolean) => {
        if (pauseOnHover) paused.current = value;
      };
    
      return (
        <section
          onPointerEnter={() => pause(true)}
          onPointerLeave={() => pause(false)}
          onFocus={() => pause(true)}
          onBlur={() => pause(false)}
          className={cn("w-full py-10", className)}
          {...props}
        >
          {eyebrow ? <p className="mb-6 px-4 text-center text-sm text-muted-foreground">{eyebrow}</p> : null}
          <div className="space-y-2">
            <Row items={items} direction={-1} speed={speed} paused={paused} />
            <Row items={second} direction={1} speed={speed * 0.85} paused={paused} />
          </div>
        </section>
      );
    }

Usage

import { DualLogoMarquee } from "@/components/ui/dual-logo-marquee/dual-logo-marquee";

<DualLogoMarquee
  eyebrow="Powering product teams everywhere"
  items={[{ name: "Acme", logo: <AcmeLogo />, href: "/customers/acme" }]}
/>

API reference

PropTypeDefaultDescription
items*MarqueeLogo[]–Logos for the first row (scrolls left).
secondRowMarqueeLogo[]items, reversedLogos for the second row (scrolls right).
speednumber32Scroll speed in pixels per second.
pauseOnHoverbooleantrueEase both rows to a stop while hovered or focused.
eyebrowstring–Small line above the rows.

Also accepts every native <section> attribute, including ref and className.

  • Each row loops one copy's measured width, so it stays seamless as fonts load or the container resizes. The second row runs a little slower for depth.

Accessibility

  • Each logo is named once (a link with hidden text, or role="img" with a label); the duplicate copies that make the loop seamless are aria-hidden and inert.
  • Hovering or focusing anywhere in the section eases both rows to a stop, so links can be read and clicked.
  • With reduced motion nothing scrolls: the logos wrap into a static, centered cloud.