"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface GridLogo {
name: string;
/** Logo artwork. Built-in logos use a coloured mark + currentColor wordmark so the greyscale hover works. */
logo: React.ReactNode;
}
/* ---------- Invented brand marks ---------- */
function Brand({ name, color, width, mark, weight = 600, upper, spacing = -0.3 }: { name: string; color: string; width: number; mark: React.ReactNode; weight?: number; upper?: boolean; spacing?: number }) {
return (
<svg viewBox={`0 0 ${width} 32`} width={width} height="32" className="h-7 w-auto overflow-visible sm:h-8" role="img" aria-label={name}>
<g fill={color} stroke={color}>
{mark}
</g>
<text x="34" y="22.5" fill="currentColor" fontSize="19" fontWeight={weight} letterSpacing={spacing} style={{ fontFamily: "inherit" }}>
{upper ? name.toUpperCase() : name}
</text>
</svg>
);
}
const S = { strokeWidth: 0 } as const;
export const GRID_LOGOS: GridLogo[] = [
{ name: "Northwind", logo: <Brand name="Northwind" color="#0ea5e9" width={140} mark={<path {...S} d="M13 3 22 28l-9-5-9 5z" />} /> },
{
name: "Acme",
logo: (
<Brand name="acme" color="#f97316" width={96} weight={800} spacing={-1} mark={<><circle {...S} cx="10" cy="16" r="8" /><circle {...S} cx="19" cy="16" r="8" fillOpacity="0.5" /></>} />
),
},
{
name: "Lumen",
logo: (
<Brand
name="Lumen"
color="#eab308"
width={112}
weight={500}
upper
spacing={1.5}
mark={<><circle {...S} cx="14" cy="16" r="5" /><path fill="none" strokeWidth="2.2" strokeLinecap="round" d="M14 3v4M14 25v4M1 16h4M23 16h4M5 7l3 3M20 22l3 3M5 25l3-3M20 10l3-3" /></>}
/>
),
},
{
name: "Orbit",
logo: (
<Brand name="orbit" color="#8b5cf6" width={96} weight={700} mark={<><circle {...S} cx="14" cy="16" r="5" /><ellipse cx="14" cy="16" rx="13" ry="6" fill="none" strokeWidth="2" transform="rotate(-25 14 16)" /></>} />
),
},
{
name: "Vertex",
logo: (
<Brand name="Vertex" color="#10b981" width={112} weight={650} mark={<><path {...S} d="M14 4 26 11 14 18 2 11z" /><path fill="none" strokeWidth="2.4" strokeLinejoin="round" d="M2 17l12 7 12-7" /></>} />
),
},
{
name: "Quanta",
logo: (
<Brand
name="Quanta"
color="#ec4899"
width={118}
mark={<><rect {...S} x="3" y="5" width="22" height="22" rx="6" /><rect x="9" y="11" width="10" height="10" rx="2" transform="rotate(45 14 16)" fill="var(--card)" stroke="none" /></>}
/>
),
},
{ name: "Helix", logo: <Brand name="helix" color="#06b6d4" width={90} weight={700} mark={<path fill="none" strokeWidth="2.6" strokeLinecap="round" d="M3 9c5 0 5 14 11 14s6-14 11-14M3 23c5 0 5-14 11-14s6 14 11 14" />} /> },
{ name: "Nimbus", logo: <Brand name="Nimbus" color="#3b82f6" width={118} weight={550} mark={<path {...S} d="M8 24a6 6 0 0 1-.6-12A8 8 0 0 1 22.5 10 6 6 0 0 1 21 24z" />} /> },
{ name: "Polar", logo: <Brand name="POLAR" color="#6366f1" width={108} weight={800} spacing={2} mark={<path {...S} d="M14 3l3.2 9.6H27l-8 5.8 3 9.6-8-5.9-8 5.9 3-9.6-8-5.8h9.8z" />} /> },
{ name: "Kestrel", logo: <Brand name="Kestrel" color="#ef4444" width={120} mark={<path {...S} d="M2 10c7 0 11 3 12 9 2-8 7-13 13-14-3 4-4 9-5 13-2 6-7 8-11 7l3-4c-4 0-9-4-12-11z" />} /> },
{ name: "Cobalt", logo: <Brand name="Cobalt" color="#2563eb" width={112} weight={700} mark={<path {...S} d="M14 3l11 6.5v13L14 29 3 22.5v-13z" />} /> },
{ name: "Ember", logo: <Brand name="ember" color="#f43f5e" width={104} weight={700} spacing={-0.8} mark={<path {...S} d="M14 3c2 5 9 8 9 15a9 9 0 0 1-18 0c0-4 2-6 4-8 0 3 1 5 3 5 0-4 0-8 2-12z" />} /> },
{
name: "Fathom",
logo: <Brand name="Fathom" color="#14b8a6" width={116} mark={<path fill="none" strokeWidth="2.6" strokeLinecap="round" d="M2 11c4-3 8 3 12 0s8 3 12 0M2 17c4-3 8 3 12 0s8 3 12 0M2 23c4-3 8 3 12 0s8 3 12 0" />} />,
},
{ name: "Juniper", logo: <Brand name="Juniper" color="#22c55e" width={120} weight={550} mark={<path {...S} d="M5 27C5 13 13 5 26 4c0 13-7 21-19 21l9-10-11 12z" />} /> },
{
name: "Meridian",
logo: (
<Brand name="Meridian" color="#a855f7" width={136} weight={500} mark={<><circle cx="14" cy="16" r="11" fill="none" strokeWidth="2.2" /><ellipse cx="14" cy="16" rx="4.5" ry="11" fill="none" strokeWidth="2.2" /><path strokeWidth="2.2" d="M3 16h22" /></>} />
),
},
{ name: "Tandem", logo: <Brand name="Tandem" color="#f59e0b" width={118} weight={700} mark={<><rect {...S} x="2" y="8" width="14" height="14" rx="3" /><rect {...S} x="12" y="12" width="14" height="14" rx="3" fillOpacity="0.55" /></>} /> },
];
/* ---------- Block ---------- */
export interface LogosGridFadeProps {
eyebrow?: string;
title?: string;
description?: string;
/** Pool of logos. The grid shows `slots` of them and cycles through the rest. */
logos?: GridLogo[];
/** Number of visible cells (4 or 8 work best). */
slots?: number;
/** Milliseconds between swaps (one cell changes per tick). */
interval?: number;
className?: string;
}
const ORDER_8 = [3, 6, 0, 5, 2, 7, 1, 4];
const EASE = [0.22, 1, 0.36, 1] as const;
export function LogosGridFade({
eyebrow = "Customers",
title = "Powering the teams behind products you use every day",
description = "Hover a logo to see it in colour. The wall refreshes itself every few seconds.",
logos = GRID_LOGOS,
slots = 8,
interval = 1800,
className,
}: LogosGridFadeProps) {
const count = Math.min(slots, logos.length);
const order = React.useMemo(
() => (count === 8 ? ORDER_8 : Array.from({ length: count }, (_, i) => (i * 3) % count)),
[count],
);
const [state, setState] = React.useState(() => ({
shown: Array.from({ length: count }, (_, i) => i),
cursor: count,
tick: 0,
}));
const [paused, setPaused] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.3 });
const reduce = useReducedMotion();
React.useEffect(() => {
if (!inView || paused || logos.length <= count) return;
const id = setInterval(() => {
setState((s) => {
const slot = order[s.tick % order.length];
let c = s.cursor;
let guard = 0;
while (s.shown.includes(c % logos.length) && guard++ < logos.length) c++;
const shown = [...s.shown];
shown[slot] = c % logos.length;
return { shown, cursor: c + 1, tick: s.tick + 1 };
});
}, interval);
return () => clearInterval(id);
}, [inView, paused, interval, logos.length, count, order]);
return (
<section className={cn("relative w-full overflow-x-clip bg-background py-16 sm:py-20", className)}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto max-w-2xl text-center">
<p className="text-sm font-medium text-primary">{eyebrow}</p>
<h2 className="mt-2 text-balance text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">{title}</h2>
<p className="mt-3 text-pretty text-muted-foreground">{description}</p>
</div>
<div
ref={ref}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
className="mt-12 grid grid-cols-2 gap-px overflow-hidden rounded-3xl border bg-border sm:grid-cols-4"
>
{state.shown.slice(0, count).map((logoIdx, slot) => {
const item = logos[logoIdx];
return (
<div key={slot} className="group relative flex h-24 items-center justify-center overflow-hidden bg-card sm:h-28">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,var(--accent),transparent_70%)] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={item.name}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 18, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -18, filter: "blur(6px)" }}
transition={{ duration: 0.6, ease: EASE }}
className="relative"
>
<div className="text-foreground/70 opacity-80 grayscale transition-[filter,opacity,color] duration-300 group-hover:text-foreground group-hover:opacity-100 group-hover:grayscale-0">
{item.logo}
</div>
</motion.div>
</AnimatePresence>
</div>
);
})}
</div>
</div>
</section>
);
}