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.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.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
| Prop | Type | Default | Description |
|---|---|---|---|
items* | MarqueeLogo[] | – | Logos for the first row (scrolls left). |
secondRow | MarqueeLogo[] | items, reversed | Logos for the second row (scrolls right). |
speed | number | 32 | Scroll speed in pixels per second. |
pauseOnHover | boolean | true | Ease both rows to a stop while hovered or focused. |
eyebrow | string | – | 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.