Swish UI
All componentsBrand and hero Free

Logo cloud swap

A grid of customer wordmarks where one cell at a time trades its logo for another with a blur and scale crossfade.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const CELLS = 8;
const EVERY = 1600;
type Mark = { name: string; look: string; glyph?: ReactNode };
// Invented wordmarks: a typeface treatment plus a small drawn glyph, so the cloud reads like real logos.
const POOL: Mark[] = [
  { name: "Lumora", look: "heavy", glyph: <circle cx="8" cy="8" r="5.5" /> },
  { name: "kestrel", look: "lower", glyph: <path d="M2 13 8 3l6 10z" /> },
  { name: "QUANTA", look: "wide" },
  { name: "Halcyon", look: "serif", glyph: <path d="M2 10c2-4 4-4 6 0s4 4 6 0" fill="none" /> },
  { name: "ferro", look: "mono", glyph: <path d="M3 3h4v10H3zM9 3h4v10H9z" /> },
  { name: "Brightline", look: "italic" },
  { name: "Orbitly", look: "heavy", glyph: <path d="M8 2a6 6 0 1 0 6 6H8z" /> },
  { name: "tidal", look: "lower", glyph: <path d="M8 2 14 8 8 14 2 8z" /> },
  { name: "Meridian", look: "serif" },
  { name: "PINECREST", look: "wide", glyph: <path d="M8 2 13 13H3z" /> },
  { name: "Sundial", look: "italic", glyph: <circle cx="8" cy="8" r="5.5" fill="none" /> },
  { name: "corvid", look: "mono" },
  { name: "Northstar", look: "heavy", glyph: <path d="m8 1.5 1.8 4.7 4.7 1.8-4.7 1.8L8 14.5 6.2 9.8 1.5 8l4.7-1.8z" /> },
  { name: "fieldnote", look: "lower" },
];

type Cell = { item: number; prev: number | null; n: number };
const START: Cell[] = Array.from({ length: CELLS }, (_, i) => ({ item: i, prev: null, n: 0 }));

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [cells, setCells] = useState(START);
  const [paused, setPaused] = useState(false);
  const lastCell = useRef(-1);

  // Swap one cell for a wordmark that is not on screen right now.
  const swap = (at: number) => {
    lastCell.current = at;
    setCells((now) => {
      const free = POOL.map((_, i) => i).filter((i) => !now.some((c) => c.item === i));
      const pick = free[Math.floor(Math.random() * free.length)];
      return now.map((c, i) => (i === at ? { item: pick, prev: c.item, n: c.n + 1 } : c));
    });
  };

  useEffect(() => {
    if (reduced || paused || !inView) return;
    const timer = setInterval(() => {
      // Never the same cell twice in a row.
      let at = Math.floor(Math.random() * (CELLS - 1));
      if (at >= lastCell.current) at += 1;
      swap(at % CELLS);
    }, EVERY);
    return () => clearInterval(timer);
  }, [reduced, paused, inView]);

  const render = (m: Mark) => (
    <>
      {m.glyph && <svg viewBox="0 0 16 16" aria-hidden="true">{m.glyph}</svg>}
      {m.name}
    </>
  );

  return (
    <div
      ref={root}
      className={styles.root}
      onPointerEnter={(e) => e.pointerType === "mouse" && setPaused(true)}
      onPointerLeave={() => setPaused(false)}
    >
      <section className={styles.cloud} aria-label="Customer logos">
        <p className={styles.eyebrow}>Trusted by 4,000+ teams</p>
        <ul className={styles.grid}>
          {cells.map((c, i) => (
            <li key={i}>
              <button type="button" className={styles.cell} onClick={() => swap(i)} aria-label={`${POOL[c.item].name}, show another`}>
                {c.prev !== null && !reduced && (
                  <span key={`out${c.n}`} className={styles.mark} data-look={POOL[c.prev].look} data-dir="out" aria-hidden="true">
                    {render(POOL[c.prev])}
                  </span>
                )}
                <span key={`in${c.n}`} className={styles.mark} data-look={POOL[c.item].look} data-dir={c.n ? "in" : undefined}>
                  {render(POOL[c.item])}
                </span>
              </button>
            </li>
          ))}
        </ul>
      </section>
    </div>
  );
}

demo.module.css

.root { container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 18px; box-sizing: border-box; background: var(--surface); }
.cloud { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 12px; width: min(760px, 100%); height: min(100%, 300px); }
.eyebrow { margin: 0; text-align: center; font-size: 12px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
/* Hairlines between cells come from the 1px gap showing the grid's line colour. */
.grid {
  list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 1fr; gap: 1px;
  overflow: hidden; border-radius: 14px; border: 1px solid var(--line); background: var(--line);
}
.grid li { display: grid; min-width: 0; min-height: 0; }
.cell {
  position: relative; display: grid; place-items: center; min-height: 40px; padding: 0 8px; border: 0; background: var(--white);
  color: var(--ink); cursor: pointer; overflow: hidden; transition: background-color .25s;
}
.cell:hover { background: var(--surface); }
.cell:focus-visible { outline: 2px solid var(--accent-text); outline-offset: -3px; }

/* Both marks share one grid area so the old one fades out on top of the new one. */
.mark {
  grid-area: 1 / 1; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; color: var(--ink); opacity: .78;
  font-size: clamp(14px, 3.4cqi, 19px); font-weight: 650; letter-spacing: -0.02em; transition: opacity .25s;
}
.cell:hover .mark:not([data-dir="out"]) { opacity: 1; }
.mark svg { flex: none; width: 1em; height: 1em; fill: currentColor; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; }
.mark svg [fill="none"] { fill: none; stroke-width: 2; stroke-linecap: round; }
.mark[data-look="heavy"] { font-weight: 800; letter-spacing: -0.04em; }
.mark[data-look="lower"] { font-weight: 600; letter-spacing: -0.03em; }
.mark[data-look="wide"] { font-size: clamp(11px, 2.6cqi, 15px); font-weight: 700; letter-spacing: .18em; }
.mark[data-look="serif"] { font-family: Georgia, "Times New Roman", serif; font-weight: 600; letter-spacing: -0.01em; }
.mark[data-look="mono"] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; letter-spacing: -0.04em; }
.mark[data-look="italic"] { font-style: italic; font-weight: 750; letter-spacing: -0.03em; }

.mark[data-dir="in"] { animation: swap-in .7s var(--out) both; }
.mark[data-dir="out"] { animation: swap-out .5s var(--out) both; pointer-events: none; }
@keyframes swap-in { from { opacity: 0; scale: .82; filter: blur(8px); } }
@keyframes swap-out { to { opacity: 0; scale: 1.14; filter: blur(8px); } }

@container (max-width: 520px) {
  .root { padding: 12px; }
  .grid { grid-template-columns: repeat(2, 1fr); }
  .mark { font-size: clamp(14px, 4.6cqi, 18px); }
  .mark[data-look="wide"] { font-size: 12px; letter-spacing: .14em; }
}
@container (max-height: 300px) {
  .cloud { gap: 8px; }
  .eyebrow { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
  .mark[data-dir] { animation: none; }
}

Use it for

  • Social proof rows with more customers than cells
  • Landing pages that want quiet motion below the hero
  • Partner or integration grids that rotate through a longer list

Avoid

  • Pages where logos must be read in full, such as a legal partner list
  • Swapping several cells at once, which turns calm into noise

How it works

An interval picks a random cell, skipping the one that swapped last, and gives it a wordmark from the pool that is not already on screen. The cell keeps the old mark for one swap and renders both in the same grid area under a new key: the new one animates in from blur and a smaller scale while the old one blurs out larger. Hovering with a mouse pauses the cycle and clicking a cell swaps it straight away.

Accessibility

Each cell is a button named after its current wordmark, the leaving mark is hidden from screen readers and swaps are not announced. The cycle pauses off screen and on hover. Reduced motion stops the timer and makes manual swaps instant.

Found on