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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/logo-cloud.jsonPrompt
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/heighttravel in the contract, so the box is known at render time. NoonLoadmeasuring, 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-containguarantees the pixels never distort. - Neutralisation has a cost —
grayscalekeeps luminance (a light logo still disappears on a light surface),monoguarantees legibility on both themes but flattens every internal colour. Neither is a substitute for a realdarkSrcpair; the component makes the trade explicit instead of pretending one filter fits all. - Affordance follows data — only items carrying a real
hrefbecome 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+inertso nothing is announced or tabbed twice. Underprefers-reduced-motionthe animation stops and the belt becomes a hand-scrollable strip rather than losing the logos that were off-screen.
CTA Band
A closing call-to-action strip — headline, one supporting line and a primary/secondary CTA pair, in four surfaces and two layouts.
Newsletter Signup
An email capture band built around its submit state machine — submitting, confirmed, already-subscribed, and a failure that keeps the address you typed.