Three-column vertical logo drift with alternating scroll direction, pause on hover, and brand wordmarks.
@nusaiba
registry/blocks/logo-cloud/5/logo-cloud-section.tsx
"use client";
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { BrandFonts, BrandWordmark } from "@/lib/brand-typography";
import { cn } from "@/lib/utils";
import { SectionShell } from "@/components/layout-contract";
type Brand = { name: string; domain: string };
function getLogoDevUrl(domain: string) {
const token = process.env.NEXT_PUBLIC_LOGO_DEV_PUBLISHABLE_KEY;
if (!token) return null;
const params = new URLSearchParams({
token,
size: "80",
format: "png",
retina: "true",
fallback: "monogram",
});
return `https://img.logo.dev/${domain}?${params}`;
}
const columns: {
logos: Brand[];
duration: number;
reverse: boolean;
}[] = [
{
logos: [
{ name: "Stripe", domain: "stripe.com" },
{ name: "Notion", domain: "notion.so" },
{ name: "Figma", domain: "figma.com" },
{ name: "Slack", domain: "slack.com" },
],
duration: 22,
reverse: false,
},
{
logos: [
{ name: "Vercel", domain: "vercel.com" },
{ name: "Linear", domain: "linear.app" },
{ name: "GitHub", domain: "github.com" },
{ name: "Shopify", domain: "shopify.com" },
],
duration: 26,
reverse: true,
},
{
logos: [
{ name: "HubSpot", domain: "hubspot.com" },
{ name: "Airtable", domain: "airtable.com" },
{ name: "Intercom", domain: "intercom.com" },
{ name: "Zendesk", domain: "zendesk.com" },
],
duration: 20,
reverse: false,
},
];
function LogoMark({ brand }: { brand: Brand }) {
const src = getLogoDevUrl(brand.domain);
return (
<div className="flex h-8 items-center gap-2 md:h-9">
{src ? (
<img
alt=""
aria-hidden
className="size-6 shrink-0 object-contain opacity-70 grayscale transition-[opacity,filter] duration-300 group-hover:opacity-100 group-hover:grayscale-0 md:size-[1.625rem] outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
height={26}
loading="lazy"
referrerPolicy="origin"
src={src}
/>
) : null}
<BrandWordmark
className={cn(
"text-[0.8125rem] leading-none text-foreground/70 transition-colors duration-300 group-hover:text-foreground/90 md:text-sm",
!src && "opacity-80",
)}
domain={brand.domain}
name={brand.name}
/>
</div>
);
}
function LogoColumn({
logos,
duration,
reverse,
reduceMotion,
paused,
}: {
logos: Brand[];
duration: number;
reverse: boolean;
reduceMotion: boolean;
paused: boolean;
}) {
const items = [...logos, ...logos];
return (
<div className="relative h-56 overflow-hidden md:h-64">
<motion.div
animate={
reduceMotion || paused
? undefined
: { y: reverse ? ["-50%", "0%"] : ["0%", "-50%"] }
}
className="flex flex-col items-center gap-10 py-2"
transition={
reduceMotion || paused
? undefined
: {
duration,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}
}
>
{items.map((brand, index) => (
<div
className="group flex h-9 shrink-0 items-center justify-center md:h-10"
key={`${brand.domain}-${index}`}
>
<LogoMark brand={brand} />
</div>
))}
</motion.div>
</div>
);
}
export function LogoCloudVerticalSection() {
const reduceMotion = useReducedMotion();
const [paused, setPaused] = useState(false);
return (
<SectionShell spacingMode="compact" className="overflow-hidden bg-background">
<BrandFonts id="logo-cloud-5-brand-fonts" />
<div
className="mx-auto w-full max-w-5xl space-y-8 md:space-y-9"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<p className="text-pretty text-center text-muted-foreground text-xs uppercase tracking-[0.2em]">
Trusted worldwide
</p>
<div className="relative mask-[linear-gradient(to_bottom,transparent,black_14%,black_86%,transparent)]">
<div className="grid grid-cols-1 gap-6 min-[30rem]:grid-cols-3 md:gap-10">
{columns.map((column) => (
<LogoColumn
duration={column.duration}
key={column.logos[0]?.domain}
logos={column.logos}
paused={paused}
reduceMotion={!!reduceMotion}
reverse={column.reverse}
/>
))}
</div>
</div>
</div>
</SectionShell>
);
}


































