// app/components/ui/LogoMarquee.tsx
"use client";
import Image from "next/image";
import React, { useEffect, useRef } from "react";
export type LogoItem = {
name: string;
src: string;
href?: string;
width?: number;
height?: number;
};
type LogoMarqueeProps = {
logos: LogoItem[];
/** Seconds for one full loop at normal speed. */
speed?: number;
/** Seconds for one full loop while hovered (bigger = slower). */
hoverSpeed?: number;
direction?: "left" | "right";
/** Fully stop on hover instead of slowing down. */
pauseOnHover?: boolean;
grayscale?: boolean;
className?: string;
logoClassName?: string;
/**
* Space after every logo. It is padding (not flex `gap`) on purpose, so
* each group is exactly 50% of the track and the loop is seamless.
*/
spacingClassName?: string;
ariaLabel?: string;
};
function LogoMarquee({
logos,
speed = 28,
hoverSpeed = 70,
direction = "left",
pauseOnHover = false,
grayscale = true,
className = "",
logoClassName = "",
spacingClassName = "pr-12 sm:pr-16 lg:pr-24",
ariaLabel = "Trusted companies",
}: LogoMarqueeProps) {
const trackRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<Animation | null>(null);
const rateRef = useRef(1);
const rafRef = useRef<number | null>(null);
// One animation, moving the track by exactly one group (-50%).
useEffect(() => {
const track = trackRef.current;
if (!track) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const animation = track.animate(
[{ transform: "translateX(0)" }, { transform: "translateX(-50%)" }],
{
duration: speed * 1000,
iterations: Infinity,
easing: "linear",
direction: direction === "right" ? "reverse" : "normal",
},
);
animationRef.current = animation;
rateRef.current = 1;
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
animation.cancel();
animationRef.current = null;
};
}, [speed, direction, logos.length]);
// Ease the playback rate toward a target so hover never causes a jump.
const rampTo = (target: number) => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
const step = () => {
const animation = animationRef.current;
if (!animation) return;
rateRef.current += (target - rateRef.current) * 0.08;
if (Math.abs(target - rateRef.current) < 0.005) {
rateRef.current = target;
}
animation.playbackRate = rateRef.current;
rafRef.current =
rateRef.current === target ? null : requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
};
const handleEnter = () =>
rampTo(pauseOnHover ? 0 : Math.min(1, speed / hoverSpeed));
const handleLeave = () => rampTo(1);
if (!logos.length) return null;
const renderGroup = (isDuplicate: boolean) => (
<div
className="flex shrink-0 items-center"
aria-hidden={isDuplicate || undefined}
>
{logos.map((logo, index) => {
const content = (
<div
className={`
flex h-16 w-32 items-center justify-center
transition-[filter,opacity] duration-500
sm:h-20 sm:w-40
lg:h-24 lg:w-48
${grayscale ? "grayscale opacity-60" : ""}
hover:grayscale-0 hover:opacity-100
${logoClassName}
`}
>
<Image
src={logo.src}
alt={isDuplicate ? "" : logo.name}
width={logo.width ?? 180}
height={logo.height ?? 80}
sizes="(min-width: 1024px) 192px, (min-width: 640px) 160px, 128px"
// Logos sit off-screen to the right, so lazy loading makes them
// pop in late. Load them all up front (there are only a few).
loading="eager"
className="max-h-full w-auto max-w-full object-contain"
draggable={false}
/>
</div>
);
const key = `${logo.name}-${index}`;
if (logo.href && !isDuplicate) {
return (
<a
key={key}
href={logo.href}
target="_blank"
rel="noreferrer"
aria-label={logo.name}
className={`shrink-0 ${spacingClassName}`}
>
{content}
</a>
);
}
return (
<div key={key} className={`shrink-0 ${spacingClassName}`}>
{content}
</div>
);
})}
</div>
);
return (
<section
aria-label={ariaLabel}
className={`relative w-full min-w-0 overflow-hidden ${className}`}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{/* Left fade */}
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-16 bg-gradient-to-r from-white to-transparent sm:w-28" />
{/* Right fade */}
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-16 bg-gradient-to-l from-white to-transparent sm:w-28" />
<div ref={trackRef} className="flex w-max will-change-transform">
{renderGroup(false)}
{renderGroup(true)}
</div>
</section>
);
}
export default LogoMarquee;
Component details
Overview, install command, dependencies, and project links.
A seamless infinite scrolling logo marquee component built for Next.js with Tailwind CSS and TypeScript. Uses the Web Animations API directly on a duplicated track div to achieve a perfect loop with zero layout shift - no CSS keyframe tricks that can cause a visible jump at the seam. Hover slows the animation down smoothly by ramping the playbackRate with requestAnimationFrame instead of snapping to a new speed, so the transition in and out always feels fluid. Supports left and right scroll direction, pauseOnHover mode for a full stop, per-logo href links that open in a new tab, grayscale filter with opacity that lifts on hover, and left/right edge fade masks using gradient overlays. Fully respects prefers-reduced-motion - animation skips entirely for users who have reduced motion enabled. The demo shows two rows scrolling in opposite directions, which is the standard "trusted by" section pattern seen on SaaS landing pages. Built as a "use client" component so it works in the Next.js App Router out of the box.
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
