SectionsIntermediateMay 1, 2026
Logo Wall Cycle
A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.
View Full Demo →Preview
Source
demo.jsx
import LogoWallCycle from "./index.jsx";
import { logoWallCycle } from "../demo-data.js";
export default function LogoWallCycleDemo() {
return <LogoWallCycle {...logoWallCycle} />;
}
index.jsx
"use client";
import { useEffect, useRef, useCallback } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";
gsap.registerPlugin(ScrollTrigger);
export default function LogoWallCycle({
logos = [],
shuffle = false,
loopDelay = 1.5,
duration = 0.9,
}) {
const rootRef = useRef(null);
const listRef = useRef(null);
const tlRef = useRef(null);
const poolRef = useRef([]);
const patternRef = useRef([]);
const patternIndexRef = useRef(0);
const visibleItemsRef = useRef([]);
const visibleCountRef = useRef(0);
const shuffleArray = useCallback((arr) => {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}, []);
useEffect(() => {
if (!logos.length) return;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.from(list.querySelectorAll("[data-logo-item]"));
const originalTargets = items
.map((item) => item.querySelector("[data-logo-target]"))
.filter(Boolean);
function isVisible(el) {
return window.getComputedStyle(el).display !== "none";
}
function setup() {
if (tlRef.current) {
tlRef.current.kill();
}
const visibleItems = items.filter(isVisible);
const visibleCount = visibleItems.length;
visibleItemsRef.current = visibleItems;
visibleCountRef.current = visibleCount;
patternRef.current = shuffleArray(
Array.from({ length: visibleCount }, (_, i) => i)
);
patternIndexRef.current = 0;
// Remove all injected targets
items.forEach((item) => {
item.querySelectorAll("[data-logo-target]").forEach((old) => old.remove());
});
const pool = originalTargets.map((n) => n.cloneNode(true));
let front, rest;
if (shuffle) {
const shuffledAll = shuffleArray(pool);
front = shuffledAll.slice(0, visibleCount);
rest = shuffleArray(shuffledAll.slice(visibleCount));
} else {
front = pool.slice(0, visibleCount);
rest = shuffleArray(pool.slice(visibleCount));
}
poolRef.current = rest;
// Place initial logos
for (let i = 0; i < visibleCount; i++) {
const parent = visibleItems[i].querySelector("[data-logo-parent]") || visibleItems[i];
parent.appendChild(front[i]);
}
const tl = gsap.timeline({ repeat: -1, repeatDelay: loopDelay });
tl.call(swapNext);
tlRef.current = tl;
tl.play();
}
function swapNext() {
const nowCount = items.filter(isVisible).length;
if (nowCount !== visibleCountRef.current) {
setup();
return;
}
if (!poolRef.current.length) return;
const idx = patternRef.current[patternIndexRef.current % visibleCountRef.current];
patternIndexRef.current++;
const container = visibleItemsRef.current[idx];
const parent =
container.querySelector("[data-logo-parent]") || container;
const existing = parent.querySelectorAll("[data-logo-target]");
if (existing.length > 1) return;
const current = parent.querySelector("[data-logo-target]");
const incoming = poolRef.current.shift();
gsap.set(incoming, { yPercent: 50, autoAlpha: 0 });
parent.appendChild(incoming);
if (current) {
gsap.to(current, {
yPercent: -50,
autoAlpha: 0,
duration,
ease: "expo.inOut",
onComplete: () => {
current.remove();
poolRef.current.push(current);
},
});
}
gsap.to(incoming, {
yPercent: 0,
autoAlpha: 1,
duration,
delay: 0.1,
ease: "expo.inOut",
});
}
setup();
const st = ScrollTrigger.create({
trigger: root,
start: "top bottom",
end: "bottom top",
onEnter: () => tlRef.current?.play(),
onLeave: () => tlRef.current?.pause(),
onEnterBack: () => tlRef.current?.play(),
onLeaveBack: () => tlRef.current?.pause(),
});
const handleVisibility = () => {
if (document.hidden) {
tlRef.current?.pause();
} else {
tlRef.current?.play();
}
};
document.addEventListener("visibilitychange", handleVisibility);
return () => {
tlRef.current?.kill();
st.kill();
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [logos, shuffle, loopDelay, duration, shuffleArray]);
return (
<div ref={rootRef} className={styles.wall}>
<div className={styles.collection}>
<div ref={listRef} className={styles.list}>
{logos.map((logo, i) => (
<div key={i} data-logo-item="" className={styles.item}>
<div data-logo-parent="" className={styles.logo}>
<div data-logo-target="" className={styles.logoTarget}>
<img
src={logo.src}
loading="lazy"
width={100}
alt={logo.alt || ""}
className={styles.logoImg}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
styles.module.css
.wall {
display: flex;
justify-content: center;
width: 100%;
}
.collection {
width: 100%;
}
.list {
display: flex;
flex-flow: wrap;
}
.item {
width: 16.666%;
position: relative;
}
.list .item:nth-child(n + 13) {
display: none;
}
.logo {
display: flex;
justify-content: center;
align-items: center;
position: relative;
aspect-ratio: 3 / 1;
}
.logoTarget {
justify-content: center;
align-items: center;
width: 66.66%;
height: 40%;
display: flex;
position: absolute;
}
.logoImg {
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
filter: invert(1);
}
@media screen and (max-width: 991px) {
.item {
width: 33.333%;
}
.list .item:nth-child(n + 7) {
display: none;
}
}
Dependencies
gsap