Create a Logo Loop Animation in React
Scroll a row of logos in an infinite horizontal loop with smooth, gap-free motion that pauses on hover.
Social proof is most effective when it feels effortless. A static grid of client logos takes up space and demands attention. A smooth, scrolling loop presents the same information passively, and it looks polished doing it.
The final result
What we are building
A row of logos scrolls in a continuous loop in any direction: left, right, up, or down. The items repeat seamlessly with no jump. Hovering slows or pauses the animation. The scroll speed eases smoothly rather than snapping, so it feels alive rather than mechanical.
Setting up
No external animation libraries. The component uses requestAnimationFrame and CSS transforms directly.
import { LogoLoop } from './LogoLoop';Logos can be image sources or arbitrary React nodes:
export type LogoItem =
| { node: React.ReactNode; href?: string; ariaLabel?: string }
| { src: string; alt?: string; href?: string; width?: number; height?: number };Building the component
The animation loop is the heart of the component. It uses a velocity-based approach where the current speed eases toward the target speed each frame, using exponential smoothing:
const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;SMOOTH_TAU is set to 0.25, giving a quarter-second feel of acceleration and deceleration. This makes the pause-on-hover feel weighted rather than abrupt.
The offset is accumulated each frame and wrapped to the sequence width to create the seamless loop:
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;
offsetRef.current = nextOffset;
const transformValue = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
track.style.transform = transformValue;How many copies of the logo list do we need? Enough to always fill the container, plus a headroom buffer:
const copiesNeeded =
Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));The component uses ResizeObserver to recalculate this whenever the container or images change size:
useResizeObserver(
updateDimensions,
[containerRef, seqRef],
[logos, gap, logoHeight, isVertical]
);The fade-out overlays at the edges are purely decorative divs with a gradient from the background color to transparent:
<div
aria-hidden
className='pointer-events-none absolute inset-y-0 left-0 z-10 w-[clamp(24px,8%,120px)]'
style={{ background: 'linear-gradient(to right, var(--logoloop-fadeColor) 0%, transparent 100%)' }}
/>How to use it
<LogoLoop
logos={[
{ src: '/logos/acme.svg', alt: 'Acme' },
{ src: '/logos/globex.svg', alt: 'Globex' },
{ node: <MyCustomLogo />, ariaLabel: 'Custom Co' },
]}
speed={120}
direction="left"
pauseOnHover
fadeOut
logoHeight={32}
gap={48}
/>Key takeaways
- Velocity-based scrolling with exponential smoothing gives pause-on-hover a natural deceleration rather than a hard stop.
- Dynamic copy count means the component works for any container width without you specifying anything manually.
- The
prefers-reduced-motionmedia query is checked inside the animation loop. If the user has it enabled, the transform is frozen at zero and no animation runs.