bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/logo-cloud

Usage

Usagetsx
<LogoCloud />

Props

PropTypeDefaultDescription
logosLogoItem[][ { 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.
speednumber28Marquee scroll duration in seconds. Larger = slower, smaller = faster. Default 28s gives a relaxed, non-distracting pace.
gapnumber48Gap between logo pills in pixels. Default 48.
pauseOnHoverbooleantruePause the marquee when a logo is hovered or keyboard-focused. Default true.
eyebrowstringTrusted by teams who shipEyebrow caption above the marquee. Default "Trusted by teams who ship".
classNamestringExtra class names merged onto the outer section.

Source

logo-cloud.tsxtsx
"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