"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/* ---------- Invented wordmarks (drawn in SVG, colour = currentColor) ---------- */
type MarkProps = { className?: string };
function Wordmark({
name,
mark,
width,
weight = 600,
spacing = -0.4,
italic,
upper,
className,
}: {
name: string;
mark: React.ReactNode;
width: number;
weight?: number;
spacing?: number;
italic?: boolean;
upper?: boolean;
className?: string;
}) {
return (
<svg viewBox={`0 0 ${width} 32`} height="32" width={width} className={cn("h-7 w-auto overflow-visible sm:h-8", className)} role="img" aria-label={name}>
<g fill="currentColor">{mark}</g>
<text
x="34"
y="22.5"
fill="currentColor"
fontSize="19"
fontWeight={weight}
letterSpacing={spacing}
fontStyle={italic ? "italic" : undefined}
style={{ fontFamily: "inherit" }}
>
{upper ? name.toUpperCase() : name}
</text>
</svg>
);
}
export const LOGOS: Record<string, (p: MarkProps) => React.ReactElement> = {
Northwind: (p) => <Wordmark {...p} name="Northwind" width={140} mark={<path d="M13 3 22 28l-9-5-9 5z" />} />,
Acme: (p) => (
<Wordmark
{...p}
name="acme"
width={96}
weight={800}
spacing={-1}
mark={
<>
<circle cx="10" cy="16" r="8" />
<circle cx="19" cy="16" r="8" fillOpacity="0.45" />
</>
}
/>
),
Lumen: (p) => (
<Wordmark
{...p}
name="Lumen"
width={110}
weight={500}
spacing={1.5}
upper
mark={
<>
<circle cx="14" cy="16" r="5" />
<path d="M14 3v4M14 25v4M1 16h4M23 16h4M5 7l3 3M20 22l3 3M5 25l3-3M20 10l3-3" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
</>
}
/>
),
Orbit: (p) => (
<Wordmark
{...p}
name="orbit"
width={96}
weight={700}
mark={
<>
<circle cx="14" cy="16" r="5" />
<ellipse cx="14" cy="16" rx="13" ry="6" fill="none" stroke="currentColor" strokeWidth="2" transform="rotate(-25 14 16)" />
</>
}
/>
),
Vertex: (p) => (
<Wordmark
{...p}
name="Vertex"
width={112}
weight={650}
mark={
<>
<path d="M14 4 26 11 14 18 2 11z" />
<path d="M2 17l12 7 12-7" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round" />
</>
}
/>
),
Quanta: (p) => (
<Wordmark
{...p}
name="Quanta"
width={118}
weight={600}
italic
mark={
<>
<rect 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(--background)" />
</>
}
/>
),
Helix: (p) => (
<Wordmark
{...p}
name="helix"
width={90}
weight={700}
spacing={-0.8}
mark={
<path
d="M3 9c5 0 5 14 11 14s6-14 11-14M3 23c5 0 5-14 11-14s6 14 11 14"
fill="none"
stroke="currentColor"
strokeWidth="2.6"
strokeLinecap="round"
/>
}
/>
),
Nimbus: (p) => (
<Wordmark
{...p}
name="Nimbus"
width={118}
weight={550}
mark={<path d="M8 24a6 6 0 0 1-.6-12A8 8 0 0 1 22.5 10 6 6 0 0 1 21 24z" />}
/>
),
Polar: (p) => (
<Wordmark
{...p}
name="POLAR"
width={108}
weight={800}
spacing={2}
mark={<path 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" />}
/>
),
Kestrel: (p) => (
<Wordmark
{...p}
name="Kestrel"
width={120}
weight={600}
mark={<path 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" />}
/>
),
};
const DEFAULT_ROW_A = ["Northwind", "Acme", "Lumen", "Orbit", "Vertex", "Quanta", "Helix", "Nimbus", "Polar", "Kestrel"];
const DEFAULT_ROW_B = ["Helix", "Polar", "Acme", "Kestrel", "Lumen", "Northwind", "Nimbus", "Vertex", "Orbit", "Quanta"];
/* ---------- Marquee primitive ---------- */
export interface MarqueeProps {
children: React.ReactNode;
/** Seconds for one full loop — lower is faster. */
speed?: number;
direction?: "left" | "right";
pauseOnHover?: boolean;
/** Gap between items in px. */
gap?: number;
/** Repeat the children inside each track so short lists still fill very wide screens. */
repeat?: number;
className?: string;
}
export function Marquee({ children, speed = 40, direction = "left", pauseOnHover = true, gap = 56, repeat = 1, className }: MarqueeProps) {
const reduce = useReducedMotion();
const base = React.Children.toArray(children);
const items = Array.from({ length: Math.max(1, repeat) }, () => base).flat();
const track = (hidden: boolean) => (
<ul
aria-hidden={hidden || undefined}
className="flex shrink-0 items-center"
style={{ gap, paddingRight: gap }}
>
{items.map((c, i) => (
<li key={i} className="shrink-0">
{c}
</li>
))}
</ul>
);
if (reduce) {
return (
<div className={cn("flex flex-wrap items-center justify-center", className)} style={{ gap }}>
{base}
</div>
);
}
return (
<div
className={cn(
"group flex overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]",
className,
)}
>
<div
className={cn("flex w-max animate-logos-marquee", pauseOnHover && "group-hover:[animation-play-state:paused]")}
style={
{
"--logos-marquee-duration": `${speed}s`,
animationDirection: direction === "right" ? "reverse" : "normal",
} as React.CSSProperties
}
>
{track(false)}
{track(true)}
</div>
</div>
);
}
/* ---------- Block ---------- */
export interface LogosMarqueeProps {
eyebrow?: string;
title?: React.ReactNode;
description?: string;
/** Small facts shown under the marquee. Pass [] to hide. */
facts?: string[];
/** Logos for each row. Pass any React nodes (SVGs, images) or names from the built-in LOGOS set. */
rows?: (React.ReactNode | string)[][];
/** Seconds per loop (one pass through the row) for the first row; subsequent rows run slightly slower. */
speed?: number;
/** Direction of the first row; rows alternate. */
direction?: "left" | "right";
pauseOnHover?: boolean;
className?: string;
}
export function LogosMarquee({
eyebrow = "Customers",
title = (
<>
Trusted by 4,000+ teams, <span className="text-muted-foreground">from seed to IPO.</span>
</>
),
description = "Product, design and engineering teams ship faster with a workspace they actually enjoy.",
facts = ["4.9 average rating", "SOC 2 Type II", "99.99% uptime"],
rows = [DEFAULT_ROW_A, DEFAULT_ROW_B],
speed = 36,
direction = "left",
pauseOnHover = true,
className,
}: LogosMarqueeProps) {
const reduce = useReducedMotion();
const render = (l: React.ReactNode | string) => {
if (typeof l !== "string") return l;
const Logo = LOGOS[l];
return Logo ? <Logo /> : <span className="text-lg font-semibold">{l}</span>;
};
return (
<section className={cn("relative w-full overflow-hidden bg-background py-16 sm:py-20", className)}>
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mx-auto max-w-3xl px-4 text-center sm:px-6"
>
<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>
{description && <p className="mx-auto mt-3 max-w-xl text-pretty text-muted-foreground">{description}</p>}
</motion.div>
<div className="mt-12 space-y-8 sm:space-y-10">
{rows.map((row, i) => {
const dir = i % 2 === 0 ? direction : direction === "left" ? "right" : "left";
return (
<Marquee key={i} speed={speed + i * 6} direction={dir} pauseOnHover={pauseOnHover} repeat={2}>
{row.map((l, j) => (
<span
key={j}
className="block text-foreground/55 transition-colors duration-300 hover:text-foreground"
>
{render(l)}
</span>
))}
</Marquee>
);
})}
</div>
{facts.length > 0 && (
<ul className="mx-auto mt-12 flex max-w-3xl flex-wrap items-center justify-center gap-x-6 gap-y-2 px-4 text-sm text-muted-foreground">
{facts.map((f, i) => (
<li key={f} className="flex items-center gap-6">
{i > 0 && <span aria-hidden className="hidden size-1 rounded-full bg-muted-foreground/40 sm:block" />}
{f}
</li>
))}
</ul>
)}
</section>
);
}