Blocks

Logo Cloud

A trusted-by logo wall that optically equalises logos of wildly different aspect ratios, as a static grid or a reduced-motion-aware marquee.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"
import type { LogoCloudData, LogoCloudItem } from "./logo-cloud.contract"

/** Keyframes ship via React 19 hoisted <style> — dedupe by href, no Tailwind config edits. */
const KEYFRAMES = `@keyframes zy-logo-cloud{to{transform:translateX(-50%)}}`

export type LogoCloudVariant = "grid" | "marquee"
export type LogoCloudTreatment = "color" | "grayscale" | "mono"

export interface LogoCloudProps extends LogoCloudData {
  /** Static wrapping wall, or a seamless belt that freezes under prefers-reduced-motion. */
  variant?: LogoCloudVariant
  /** How much of the artwork's own colour survives. See TREATMENT for the trade-offs. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/logo-cloud.json

Prompt

Build a React + TypeScript + Tailwind "LogoCloud" block with zod. No animation
library, no measurement code — sizing is pure arithmetic on the data.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; eyebrow?: string;
    items: { id, name, src, darkSrc?, width, height, href?, scale? }[] }.
  width/height are the artwork's INTRINSIC pixel dimensions (an SVG viewBox is
  fine). They are never rendered as-is — they exist so the component knows each
  logo's aspect ratio without waiting for the image to load.
- Component props = z.infer of the schema plus variant ("grid" | "marquee",
  default "grid"), treatment ("color" | "grayscale" | "mono", default
  "grayscale"), balance (0–1, default 1), speed (seconds per marquee loop,
  default 32), onRetry?: () => void and className.
- No "use client": no hooks, no events of its own, no browser APIs.

Behavior — optical alignment (the reason this block exists)
- Logos arrive at wildly different aspect ratios. The obvious move — one shared
  height, width auto — equalises the wrong quantity: at a shared height a 5.5:1
  wordmark covers 5.5x the area of a 1:1 mark and shouts over it. Measured on a
  mixed set (1:1, 1.5:1, 2:1, 3:1, 4:1, 4.6:1, 5.5:1, 1:2) the rendered-area
  spread is 11.0x.
- So normalise AREA instead. With the ratio r = width/height held fixed, set
  w = r^(1-k) and h = r^(-k) in units of one CSS variable (--logo-cloud-size,
  default 2.5rem). Area is then r^(1-2k), constant at k = 0.5. `balance`
  interpolates k from 0 (shared height) to 0.5 (equal area); default 1 means
  full equal-area, which drops the same mixed set to a 1.28x spread.
- Two caps bound the extremes, in the same units: max width 2.6 and max height
  1.25. Apply them by scaling BOTH axes together, never one — the artwork must
  never distort. (The 1.25 height cap is what keeps the 1.28x spread from being
  exactly 1.00x: a 1:2 portrait mark would otherwise tower over the row.)
- Guard the inputs: clamp the ratio to 0.15–14, treat non-finite or non-positive
  width/height as 1, clamp `scale` to 0.5–1.5 and `speed` to 4–600s. A logo with
  garbage dimensions must render small, not crash the row.
- Emit the result as inline style width/height of
  calc(var(--logo-cloud-size) * <factor>) — so the consumer keeps a single CSS
  variable as the size knob and the arithmetic stays out of their way. Also set
  max-w-full + object-contain, so a cell narrower than the logo shrinks the
  artwork proportionally instead of clipping it.
- Feed the artwork trimmed to its ink bounds. Baked-in whitespace lies about the
  aspect ratio and the formula will size the padding, not the logo; `scale` is
  the per-logo escape hatch for the marks the ratio still reads wrong.

Behavior — everything else
- Four first-class branches: loading (skeleton blocks laid out in the same cells
  and spanning the same ratio range as real logos), empty panel, error panel
  with a "Try again" button rendered only when onRetry exists, ready.
- variant="grid": a centred flex-wrap wall. Each cell takes
  min-width: min(var(--logo-cloud-cell), 40%) — even rhythm on a wide wall, and
  the 40% ceiling guarantees two columns on a 375px phone instead of an
  eight-row tower of single logos.
- variant="marquee": one flex track holding the item list TWICE, animated
  translateX(0 → -50%), linear, infinite; duration from a CSS variable set by
  `speed`. Each copy carries gap AND a trailing padding of the same variable, so
  half the track is exactly one loop period and the wrap is seamless. The second
  copy is aria-hidden AND inert — pure loop filler, never announced, never
  focusable. Ship the keyframe with a React 19 hoisted <style href="..."
  precedence="medium"> so instances dedupe; give it a name nobody else owns.
- prefers-reduced-motion: animation off, duplicate hidden, edge mask dropped,
  root swaps overflow-hidden for overflow-x-auto and the remaining copy centres
  itself (and drops its now-pointless trailing padding). The belt becomes a
  static strip the user scrolls by hand; no logo is lost.
- Interaction honesty: a logo is a link only when the item carries a real href.
  Linked logos get an <a>, a pointer cursor, a focus-visible ring and a hover /
  focus state change — grayscale and mono return to full strength, colour dims
  slightly since it has nothing to un-filter. Logos without href render as bare
  artwork: no anchor, no pointer cursor, no hover affordance. Never emit
  href="#". The reveal classes are attached per item, so this is structural, not
  a CSS accident.

Rendering & styling
- Semantic tokens only: bg-card / border for the empty and error panels,
  text-muted-foreground for the eyebrow and supporting copy, bg-muted for
  skeletons, ring-ring for focus. The logos themselves are consumer artwork —
  the component only filters them.
- treatment="grayscale" (default): opacity-75 + grayscale — a quiet wall that
  still reads as branded on hover. Note it preserves LUMINANCE, so a
  light-on-transparent logo is still invisible on a light background; that case
  needs darkSrc, not a filter.
- treatment="mono": brightness-0 dark:invert — one ink that follows the theme
  and is guaranteed legible on both surfaces, at the cost of every internal
  colour. Multi-colour and colour-coded marks lose their identity; some brand
  guidelines forbid it. Never "fix" a colour logo by inverting it — invert on a
  colour mark produces hue-shifted garbage, which is why mono flattens to black
  FIRST and only then inverts.
- treatment="color": ships the artwork untouched. Correct when every logo has a
  darkSrc pair; on a dark theme without one, dark logos vanish.
- darkSrc renders as a second <img> with a dark:hidden / hidden dark:block pair
  — CSS-only, so it is SSR-safe and never flashes the wrong artwork.
- Semantics: the wall is a <ul> of <li>; alt = item.name, so a screen reader
  hears the brand list. Skeletons are aria-hidden. Images get loading="lazy",
  decoding="async" and the intrinsic width/height attributes for CLS.

Customization levers
- Size and density: --logo-cloud-size (per-logo optical size), --logo-cloud-cell
  (grid rhythm) and --logo-cloud-gap (belt spacing) are all set on the root and
  overridable from className, e.g. "[--logo-cloud-size:3.25rem]".
- balance: 1 for a perfectly even wall; drop toward 0.6–0.7 when your set is all
  long wordmarks and you want them taller and more readable. 0 reproduces the
  naive shared-height look on purpose.
- MAX_WIDTH / MAX_HEIGHT are module constants at the top of the file — widen
  them for a roomier wall, tighten MAX_HEIGHT if portrait marks still tower.
- Marquee feel: speed (20–40s reads as ambient), --logo-cloud-fade for the edge
  mask width, or delete the group-hover pause if the logos are not links.
- Double belt: stack two LogoClouds in marquee mode with different item slices
  and speeds (e.g. 28s and 36s) for a woven wall.
- Eyebrow: omit it for a bare strip, or replace the <p> with a heading when the
  wall is its own section rather than a band under a hero.
- Off-site logos: add target="_blank" + rel="noreferrer" to the anchor if your
  hrefs leave the app.

Concepts

  • Optical size, not fixed height — a shared height equalises the wrong quantity: at one height a 5.5:1 wordmark covers 5.5x the area of a square mark. Sizing by r^(1-k) / r^(-k) equalises area instead, which is what "the same size" means to the eye. Measured on a mixed set the rendered-area spread falls from 11.0x to 1.28x.
  • Ratio comes from the data, not the image — intrinsic width/height travel in the contract, so the box is known at render time. No onLoad measuring, no layout shift, no cached-image race, and the server output is already correct.
  • Caps scale both axes — a portrait mark would otherwise tower over the row, so a height ceiling clamps it; applying it to one axis would stretch the artwork, so both axes shrink together and object-contain guarantees the pixels never distort.
  • Neutralisation has a costgrayscale keeps luminance (a light logo still disappears on a light surface), mono guarantees legibility on both themes but flattens every internal colour. Neither is a substitute for a real darkSrc pair; the component makes the trade explicit instead of pretending one filter fits all.
  • Affordance follows data — only items carrying a real href become anchors with a pointer cursor, focus ring and hover reveal. A trusted-by wall is usually meant to be inert, and inert artwork must not look clickable.
  • Seamless belt, honest fallback — two copies each ending in a trailing gap make half the track exactly one loop period; the duplicate is aria-hidden + inert so nothing is announced or tabbed twice. Under prefers-reduced-motion the animation stops and the belt becomes a hand-scrollable strip rather than losing the logos that were off-screen.

On This Page