sections
Logo Cloud
A 'trusted by' logo bar: a small centered eyebrow caption with hairline decorations flanking it, and a seamless infinite horizontal marquee of inline-SVG wordmark placeholders below. The track duplicates the logo set once and scrolls via a pure CSS @keyframes translateX(-50%) for a gapless loop; edges dissolve via a mask-image alpha gradient. Hovering or keyboard-focusing any logo pauses the entire track (animation-play-state) to avoid whiplash. An IntersectionObserver parks the animation when offscreen. All logos use currentColor at a muted opacity — no external images, no hardcoded brand colors — so they read as understated trust marks on both dark and light themes. Under prefers-reduced-motion, scrolling is replaced by a static centered wrapping flex grid.
Trusted by teams who ship
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/logo-cloudUsage
<LogoCloud />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| logos | LogoItem[] | [ { name: "Nexus", icon: <GlyphNexus /> }, { name: "Forge", icon: <GlyphForge /> }, { name: "Apex", icon: <GlyphApex /> }, { name: "Orbit", icon: <GlyphOrbit /> }, { name: "Lumis", icon: <GlyphLumis /> }, { name: "Drift", icon: <GlyphDrift /> }, { name: "Prism", icon: <GlyphPrism /> }, ] | Logo entries. Defaults to 7 placeholder wordmarks. |
| speed | number | 28 | Marquee scroll duration in seconds. Larger = slower, smaller = faster. Default 28s gives a relaxed, non-distracting pace. |
| gap | number | 48 | Gap between logo pills in pixels. Default 48. |
| pauseOnHover | boolean | true | Pause the marquee when a logo is hovered or keyboard-focused. Default true. |
| eyebrow | string | Trusted by teams who ship | Eyebrow caption above the marquee. Default "Trusted by teams who ship". |
| className | string | — | Extra class names merged onto the outer section. |
Source
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import styles from "./logo-cloud.module.css";
/** A single logo entry: an inline-SVG glyph + brand name. */
export type LogoItem = {
/** Brand/company name shown as the wordmark text and AT label. */
name: string;
/**
* Inline SVG element. Must use currentColor for fill/stroke so the container
* CSS can tint it — no hardcoded colors.
*/
icon: React.ReactNode;
};
// ── Default placeholder logos ─────────────────────────────────────────────
// Seven geometric wordmarks; all SVG paths use currentColor so the wrapper
// CSS tints them to a muted foreground shade. No external images or brand
// colors — these are stand-in trust-bar marks.
function GlyphNexus() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
>
<circle cx="10" cy="10" r="7" />
<circle cx="10" cy="10" r="2.5" fill="currentColor" stroke="none" />
</svg>
);
}
function GlyphForge() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 14l5-5 5 5" />
<path d="M5 9.5L10 4.5l5 5" />
</svg>
);
}
function GlyphApex() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" fill="currentColor">
{/* Outlined triangle: filled outer, filled inner cutout via evenodd */}
<path
fillRule="evenodd"
d="M10 2L18.5 17H1.5L10 2zm0 3.6l-5.5 9.4h11L10 5.6z"
/>
</svg>
);
}
function GlyphOrbit() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<ellipse cx="10" cy="10" rx="8.5" ry="3.5" transform="rotate(-35 10 10)" />
<circle cx="10" cy="10" r="1.8" fill="currentColor" stroke="none" />
</svg>
);
}
function GlyphLumis() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" fill="currentColor">
{/* Five-point star / starburst — simple trust/award mark */}
<path d="M10 2l2.4 5.7H18l-4.7 3.4 1.8 5.8L10 14 4.9 16.9l1.8-5.8L2 7.7h5.6L10 2z" />
</svg>
);
}
function GlyphDrift() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
>
{/* Two parallel sine-wave curves */}
<path d="M2 8c1.5-2.5 3-2.5 4.5 0s3 2.5 4.5 0 3-2.5 4.5 0" />
<path d="M2 13c1.5-2.5 3-2.5 4.5 0s3 2.5 4.5 0 3-2.5 4.5 0" />
</svg>
);
}
function GlyphPrism() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
>
{/* Triangle outline with inner refraction line */}
<path d="M10 2L2 17h16L10 2z" />
<path d="M10 2L7 17" strokeWidth="1.1" />
</svg>
);
}
const DEFAULT_LOGOS: LogoItem[] = [
{ name: "Nexus", icon: <GlyphNexus /> },
{ name: "Forge", icon: <GlyphForge /> },
{ name: "Apex", icon: <GlyphApex /> },
{ name: "Orbit", icon: <GlyphOrbit /> },
{ name: "Lumis", icon: <GlyphLumis /> },
{ name: "Drift", icon: <GlyphDrift /> },
{ name: "Prism", icon: <GlyphPrism /> },
];
// ── Types ─────────────────────────────────────────────────────────────────
export type LogoCloudProps = {
/** Logo entries. Defaults to 7 placeholder wordmarks. */
logos?: LogoItem[];
/**
* Marquee scroll duration in seconds. Larger = slower, smaller = faster.
* Default 28s gives a relaxed, non-distracting pace.
*/
speed?: number;
/** Gap between logo pills in pixels. Default 48. */
gap?: number;
/** Pause the marquee when a logo is hovered or keyboard-focused. Default true. */
pauseOnHover?: boolean;
/** Eyebrow caption above the marquee. Default "Trusted by teams who ship". */
eyebrow?: string;
/** Extra class names merged onto the outer section. */
className?: string;
};
// ── Sub-component ─────────────────────────────────────────────────────────
function LogoMark({
logo,
duplicate = false,
}: {
logo: LogoItem;
duplicate?: boolean;
}) {
return (
<li
className={styles.item}
// Duplicate loop copies are hidden from AT — the originals cover the list.
aria-hidden={duplicate ? "true" : undefined}
>
{/*
* role="img" + aria-label makes the whole pill a single labelled image
* so AT announces "Nexus" without reading glyph + text separately.
* No tabIndex — these are non-interactive marks; making them tab stops
* would add focusable elements that do nothing (the marquee pauses on hover).
*/}
<span
className={styles.mark}
role={duplicate ? undefined : "img"}
aria-label={duplicate ? undefined : logo.name}
>
<span className={styles.icon} aria-hidden="true">
{logo.icon}
</span>
<span className={styles.name} aria-hidden="true">
{logo.name}
</span>
</span>
</li>
);
}
// ── Main component ────────────────────────────────────────────────────────
export function LogoCloud({
logos = DEFAULT_LOGOS,
speed = 28,
gap = 48,
pauseOnHover = true,
eyebrow = "Trusted by teams who ship",
className,
}: LogoCloudProps) {
const sectionRef = useRef<HTMLElement>(null);
// Mount-gate + null-safe reduced-motion check (Bottega contract):
// SSR + first client render emit the same static HTML (data-animate absent)
// so server HTML === client first render → no React #418 hydration error.
// The animated layer turns on post-hydration only for non-reduced users.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null = treat as reduced
const animate = mounted && !reduce;
// IntersectionObserver: park the CSS animation when scrolled offscreen
// to avoid burning GPU on invisible elements.
const [inView, setInView] = useState(false);
useEffect(() => {
if (!animate) return;
const el = sectionRef.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
return (
<section
ref={sectionRef}
aria-label="Logo cloud"
data-slot="logo-cloud"
data-animate={animate ? "true" : undefined}
data-pause={pauseOnHover ? "true" : "false"}
data-inview={inView ? "true" : "false"}
className={[styles.section, className].filter(Boolean).join(" ")}
style={
{
"--logo-gap": `${gap}px`,
"--marquee-speed": `${speed}s`,
} as React.CSSProperties
}
>
{/* Eyebrow caption — always visible, no motion dependency */}
<p className={styles.eyebrow}>{eyebrow}</p>
{/*
* Viewport clips overflow and applies edge-fade.
* The track always renders both copies so SSR HTML is static and
* identical on first client render — the CSS keyframe only runs
* after data-animate="true" is set post-mount.
*/}
<div className={styles.viewport}>
<ul className={styles.track}>
{/* Original copy — accessible to screen readers */}
{logos.map((logo, i) => (
<LogoMark key={`orig-${i}`} logo={logo} />
))}
{/* Duplicate copy for gapless CSS loop — hidden from AT */}
{logos.map((logo, i) => (
<LogoMark key={`dupe-${i}`} logo={logo} duplicate />
))}
</ul>
</div>
</section>
);
}
Dependencies
- motion
- @bottega/tokens