Skip to main content
A logo wall is how a landing page shows traction: customers, integrations, supported companies. Animating it fits three times the brands into the same space and gives the section life without a video. A visitor scrolling past sees a calm wave pass across the grid as every tile swaps to its next logo, column by column. You feed it a list of domains, and every logo is a Logo API image URL, so there are no logo files to collect, host, or update.

Demo

Every logo above loads live from img.logo.dev. The wall advances every few seconds, sweeping left to right one column at a time.

Build it with AI

Want this wall in your app? This prompt gives your AI coding tool everything it needs to build it in your framework.

Open in Cursor

Code

This example is in React, but you can get the same result in any frontend framework: the wall is just image URLs and CSS transitions. Paste LogoGrid.tsx, then render it as the Usage tab shows. A 5 × 3 grid cycling 3 logos per tile needs 45 domains.
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";

type LogoGridProps = {
  /** Your Logo.dev publishable key (pk_… / live_…). */
  token: string;
  /** Domains to show. Provide columns × rows × deckSize of them. */
  domains: string[];
  columns?: number;
  rows?: number;
  /** How many logos each tile cycles through. */
  deckSize?: number;
};

// One global tick advances every tile together; the per-column transition
// delay turns the shared swap into a left-to-right wave.
const STEP_MS = 3000;
const REVEAL_MS = 400;
const COLUMN_DELAY_MS = 120;
const TRAVEL_PX = 40;
const EASE = "cubic-bezier(0.17, 0.17, 0.3, 1)";

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduced(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);
  return reduced;
}

export function LogoGrid({
  token,
  domains,
  columns = 5,
  rows = 3,
  deckSize = 3,
}: LogoGridProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [inView, setInView] = useState(false);
  const reduced = useReducedMotion();
  const [active, setActive] = useState(0);

  const decks = useMemo(() => {
    const tiles = columns * rows;
    return Array.from({ length: tiles }, (_, i) =>
      domains.slice(i * deckSize, i * deckSize + deckSize)
    );
  }, [domains, columns, rows, deckSize]);

  // Only animate while the grid is on screen.
  useEffect(() => {
    const el = ref.current;
    if (!el) {
      return;
    }
    const observer = new IntersectionObserver(([entry]) =>
      setInView(entry.isIntersecting)
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const running = inView && !reduced;

  useEffect(() => {
    if (!running) {
      return;
    }
    const id = setInterval(() => {
      setActive((v) => (v + 1) % deckSize);
    }, STEP_MS);
    return () => clearInterval(id);
  }, [running, deckSize]);

  // Every logo in a deck stays mounted in one of three positions: the current
  // logo in place, the previous one parked above, and the next waiting below.
  // Because the incoming layer is already in its start position, the swap is a
  // plain style change and the transition fires with no extra bookkeeping.
  const layerStyle = (index: number, columnIndex: number): CSSProperties => {
    const delay = (columnIndex + 1) * COLUMN_DELAY_MS;
    const transition = `opacity ${REVEAL_MS}ms ${EASE} ${delay}ms, translate ${REVEAL_MS}ms ${EASE} ${delay}ms`;
    if (index === active) {
      return { opacity: 1, translate: "0 0", transition };
    }
    if (index === (active - 1 + deckSize) % deckSize) {
      return { opacity: 0, translate: `0 -${TRAVEL_PX}px`, transition };
    }
    return { opacity: 0, translate: `0 ${TRAVEL_PX}px`, transition };
  };

  const src = (domain: string) =>
    `https://img.logo.dev/${domain}?token=${token}&format=webp&retina=true&size=128`;

  return (
    <div
      aria-label="Company logos served from the Logo.dev image API"
      className="grid gap-3 sm:gap-4 overflow-hidden"
      ref={ref}
      role="img"
      style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
    >
      {decks.map((deck, tileIndex) => (
        <div
          className="relative flex aspect-square items-center justify-center"
          key={`tile-${tileIndex}`}
        >
          {deck.map((domain, layerIndex) => (
            <span
              aria-hidden="true"
              className="absolute inset-0 flex items-center justify-center"
              key={domain}
              style={layerStyle(layerIndex, tileIndex % columns)}
            >
              <img
                alt=""
                className="h-[52px] w-[52px] object-contain"
                height={52}
                loading="lazy"
                src={src(domain)}
                width={52}
              />
            </span>
          ))}
        </div>
      ))}
    </div>
  );
}
Your publishable key is built for client-side code, so you can ship it in the browser as-is.

How it works

  • Every logo is one image URL. img.logo.dev/:domain returns the company’s logo, and query parameters handle the rest: size for dimensions, retina for sharp rendering, format=webp for weight. See all image parameters.
  • Unknown domains still render. When Logo.dev doesn’t have a logo, it returns a generated monogram instead of a broken image, so the wall works with any domain list you give it. See fallback images.
  • The CDN does the heavy lifting. Logos load like any other cached image, so swapping them live costs nothing beyond a normal image request.
  • The motion is plain CSS. Every logo in a tile’s deck stays mounted as a stacked layer, and one shared tick sweeps the whole wall left to right. The wall pauses off-screen and holds still for visitors who prefer reduced motion.

Make it your own

  • Restyle the logos. Add &theme=dark for dark backgrounds or &greyscale=true for a muted, uniform wall. See all image parameters.
  • Change the shape. Set columns, rows, and deckSize, and keep domains.length at columns × rows × deckSize.
  • Make it static. Render one logo per tile and skip the timers for a plain logo cloud, or use the customer logo wall for wide wordmark lockups.

Next steps

Logo API

Every parameter for the image URL: size, format, theme, greyscale, and fallbacks.

Search API

Have company names instead of domains? Resolve them first.