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 →
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;
  }
}
  • gsap

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 4, 2026

SECTIONS

Portfolio Grid

A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.

Apr 27, 2026

SECTIONS

Case Overview Scroll

Full-viewport stacked case study slider. Each slide is sticky and its image receives scroll-driven translateY and scale transforms, creating a parallax depth effect as slides layer over each other. Inspired by DashDigital's case overview pattern.