"use client";
import * as React from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface BentoTileCopy {
title: string;
description: string;
}
export interface BentoShowcaseProps {
eyebrow?: string;
title?: React.ReactNode;
description?: string;
/** Copy for the six tiles, in order: globe, chart, counter, team, assistant, controls. */
tiles?: Partial<Record<"globe" | "chart" | "counter" | "team" | "assistant" | "controls", BentoTileCopy>>;
/** Starting value of the live counter tile. */
counterValue?: number;
counterLabel?: string;
/** Prompt/answer pairs the assistant tile types out. */
conversations?: { prompt: string; answer: string }[];
className?: string;
}
const COPY: Record<"globe" | "chart" | "counter" | "team" | "assistant" | "controls", BentoTileCopy> = {
globe: { title: "Global edge network", description: "Deployed to 32 regions and served from the one closest to every visitor." },
chart: { title: "Real-time analytics", description: "Watch traffic, conversions and latency stream in as they happen." },
counter: { title: "Built to scale", description: "Billions of requests a month without touching a server." },
team: { title: "Made for teams", description: "Invite everyone. Roles and presence are built in." },
assistant: { title: "Ask your data", description: "Plain-language answers, with sources." },
controls: { title: "Ship behind flags", description: "Roll out, roll back and experiment in seconds." },
};
const CONVERSATIONS = [
{ prompt: "Why did signups dip Tuesday?", answer: "A checkout script failed in EU-West for 41 min, then recovered." },
{ prompt: "Summarise this week's churn", answer: "Churn fell to 1.8% (−0.4 pts), led by annual-plan saves." },
{ prompt: "Which page converts best?", answer: "/pricing at 7.2% — 2.1× the site average." },
];
const EASE = [0.22, 1, 0.36, 1] as const;
export function BentoShowcase({
eyebrow = "Platform",
title = (
<>
A whole platform, <span className="text-muted-foreground">in one place.</span>
</>
),
description = "Infrastructure, analytics and collaboration that feel like one product — because they are.",
tiles,
counterValue = 1284392,
counterLabel = "requests served today",
conversations = CONVERSATIONS,
className,
}: BentoShowcaseProps) {
const copy = { ...COPY, ...tiles };
return (
<section className={cn("relative w-full overflow-x-clip bg-background py-14 sm:py-16", className)}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-medium text-primary">{eyebrow}</p>
<h2 className="mt-2 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">{title}</h2>
</div>
<p className="max-w-sm text-pretty text-muted-foreground">{description}</p>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-2 lg:grid-cols-4 lg:grid-rows-[250px_250px]">
<Tile index={0} className="md:row-span-2" copy={copy.globe} visualClassName="min-h-[260px]">
<DottedGlobe />
</Tile>
<Tile index={1} className="lg:col-span-2" copy={copy.chart}>
<LiveChart />
</Tile>
<Tile index={2} copy={copy.counter}>
<Counter value={counterValue} label={counterLabel} />
</Tile>
<Tile index={3} copy={copy.team}>
<Orbit />
</Tile>
<Tile index={4} copy={copy.assistant}>
<Typing conversations={conversations} />
</Tile>
<Tile index={5} className="md:col-span-2 lg:col-span-1" copy={copy.controls}>
<Toggles />
</Tile>
</div>
</div>
</section>
);
}
function Tile({
index,
copy,
className,
visualClassName,
children,
}: {
index: number;
copy: BentoTileCopy;
className?: string;
visualClassName?: string;
children: React.ReactNode;
}) {
const reduce = useReducedMotion();
return (
<motion.article
initial={{ opacity: 0, y: reduce ? 0 : 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.6, delay: index * 0.07, ease: EASE }}
className={cn(
"group relative flex min-h-[250px] flex-col overflow-hidden rounded-3xl border bg-card shadow-sm transition-shadow hover:shadow-lg",
className,
)}
>
<div className={cn("relative flex min-h-0 flex-1 items-center justify-center overflow-hidden", visualClassName)}>{children}</div>
<div className="relative px-5 pb-5">
<h3 className="text-sm font-semibold text-card-foreground">{copy.title}</h3>
<p className="mt-1 text-pretty text-xs leading-relaxed text-muted-foreground sm:text-[13px]">{copy.description}</p>
</div>
</motion.article>
);
}
/* ---------- Globe: dotted sphere projected in SVG ---------- */
const GLOBE_POINTS = (() => {
const n = 560;
const pts: [number, number, number][] = [];
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < n; i++) {
const y = 1 - (i / (n - 1)) * 2;
const r = Math.sqrt(1 - y * y);
const t = golden * i;
pts.push([Math.cos(t) * r, y, Math.sin(t) * r]);
}
return pts;
})();
const HOTSPOTS = new Set([37, 94, 151, 208, 262, 319, 377, 431, 488]);
const TILT = 0.38;
function project([x, y, z]: [number, number, number], a: number) {
const x1 = x * Math.cos(a) + z * Math.sin(a);
const z1 = -x * Math.sin(a) + z * Math.cos(a);
const y2 = y * Math.cos(TILT) - z1 * Math.sin(TILT);
const z2 = y * Math.sin(TILT) + z1 * Math.cos(TILT);
return { x: 100 + x1 * 80, y: 100 + y2 * 80, z: z2 };
}
function DottedGlobe() {
const refs = React.useRef<(SVGCircleElement | null)[]>([]);
const wrap = React.useRef<HTMLDivElement>(null);
const inView = useInView(wrap);
const reduce = useReducedMotion();
React.useEffect(() => {
if (!inView || reduce) return;
let raf = 0;
let a = 0;
let last = performance.now();
const tick = (now: number) => {
a += (now - last) * 0.00025;
last = now;
GLOBE_POINTS.forEach((p, i) => {
const el = refs.current[i];
if (!el) return;
const q = project(p, a);
el.setAttribute("cx", q.x.toFixed(2));
el.setAttribute("cy", q.y.toFixed(2));
el.setAttribute("opacity", (q.z > 0 ? 0.25 + q.z * 0.75 : 0.08).toFixed(2));
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, reduce]);
return (
<div ref={wrap} className="relative aspect-square w-[92%] max-w-[340px] shrink-0 md:w-[118%] md:max-w-none" aria-hidden>
<div className="absolute inset-[18%] rounded-full bg-primary/20 blur-3xl" />
<svg viewBox="0 0 200 200" className="relative size-full overflow-visible">
<circle cx="100" cy="100" r="82" fill="none" stroke="var(--border)" />
{GLOBE_POINTS.map((p, i) => {
const q = project(p, 0);
const hot = HOTSPOTS.has(i);
return (
<circle
key={i}
ref={(el) => {
refs.current[i] = el;
}}
cx={q.x.toFixed(2)}
cy={q.y.toFixed(2)}
r={hot ? 2.2 : 0.95}
fill={hot ? "var(--primary)" : "currentColor"}
className={hot ? "" : "text-foreground"}
opacity={(q.z > 0 ? 0.25 + q.z * 0.75 : 0.08).toFixed(2)}
/>
);
})}
</svg>
</div>
);
}
/* ---------- Live line chart ---------- */
function mulberry32(seed: number) {
let s = seed;
return () => {
s |= 0;
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const INITIAL_SERIES = [42, 48, 45, 53, 50, 58, 55, 62, 59, 66, 63, 71, 68, 74, 70, 78];
function LiveChart() {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: false, amount: 0.4 });
const [data, setData] = React.useState(INITIAL_SERIES);
const rand = React.useRef<() => number>(null);
React.useEffect(() => {
if (!inView) return;
rand.current ??= mulberry32(7);
const id = setInterval(() => {
setData((d) => {
const r = rand.current!;
const last = d[d.length - 1];
const next = Math.max(30, Math.min(92, last + (r() - 0.45) * 14));
return [...d.slice(1), Math.round(next)];
});
}, 1600);
return () => clearInterval(id);
}, [inView]);
const w = 400;
const h = 120;
const pts = data.map((v, i) => [(i / (data.length - 1)) * w, h - (v / 100) * h] as const);
const line = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
const area = `${line} L${w} ${h} L0 ${h} Z`;
const last = data[data.length - 1];
const [lx, ly] = pts[pts.length - 1];
const clipId = `bento-live-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<div ref={ref} className="flex size-full flex-col px-5 pt-5" aria-hidden>
<div className="flex items-center gap-3">
<span className="text-2xl font-semibold tabular-nums tracking-tight text-foreground">{(last * 31).toLocaleString("en-US")}</span>
<span className="text-xs text-muted-foreground">visitors / min</span>
<span className="ml-auto flex items-center gap-1.5 rounded-full border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground">
<span className="relative flex size-1.5">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-500 opacity-75" />
<span className="relative size-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
</div>
<div className="relative mt-3 min-h-[100px] flex-1">
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
<defs>
<linearGradient id={`${clipId}-area`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.3" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
</linearGradient>
<clipPath id={clipId}>
<motion.rect
x="-10"
y="-20"
height={h + 40}
initial={{ width: 0 }}
animate={{ width: inView ? w + 20 : 0 }}
transition={{ duration: 1.4, ease: "easeInOut" }}
/>
</clipPath>
</defs>
<g clipPath={`url(#${clipId})`}>
<motion.path
initial={{ d: area }}
animate={{ d: area }}
transition={{ duration: 0.9, ease: "easeInOut" }}
fill={`url(#${clipId}-area)`}
/>
<motion.path
initial={{ d: line }}
animate={{ d: line }}
transition={{ duration: 0.9, ease: "easeInOut" }}
fill="none"
stroke="var(--primary)"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
</svg>
<motion.span
initial={{ scale: 0 }}
className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_5px] shadow-primary/20"
animate={{ top: `${(ly / h) * 100}%`, scale: inView ? 1 : 0 }}
transition={{ duration: 0.9, ease: "easeInOut", scale: { delay: 1.3, type: "spring", stiffness: 400, damping: 15 } }}
style={{ left: `${(lx / w) * 100}%` }}
/>
</div>
</div>
);
}
/* ---------- Counter ---------- */
function Counter({ value, label }: { value: number; label: string }) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const mv = useMotionValue(0);
const text = useTransform(mv, (v) => Math.round(v).toLocaleString("en-US"));
React.useEffect(() => {
if (!inView) return;
if (reduce) {
mv.set(value);
return;
}
const ctrl = animate(mv, value, { duration: 2, ease: [0.16, 1, 0.3, 1] });
const rand = mulberry32(3);
let id: ReturnType<typeof setInterval> | undefined;
const start = setTimeout(() => {
id = setInterval(() => animate(mv, mv.get() + 40 + Math.round(rand() * 160), { duration: 0.6 }), 1200);
}, 2100);
return () => {
ctrl.stop();
clearTimeout(start);
if (id) clearInterval(id);
};
}, [inView, value, reduce, mv]);
return (
<div ref={ref} className="flex flex-col items-center px-4 text-center" aria-hidden>
<motion.span className="bg-gradient-to-b from-foreground to-foreground/60 bg-clip-text text-4xl font-semibold tabular-nums tracking-tight text-transparent sm:text-[2.6rem]">
{text}
</motion.span>
<span className="mt-1 text-xs text-muted-foreground">{label}</span>
<span className="mt-3 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
▲ 12.4% vs yesterday
</span>
</div>
);
}
/* ---------- Orbiting avatars ---------- */
const PEOPLE = [
{ i: "AK", c: "bg-violet-500" },
{ i: "JM", c: "bg-sky-500" },
{ i: "SL", c: "bg-emerald-500" },
{ i: "RT", c: "bg-amber-500" },
{ i: "NB", c: "bg-rose-500" },
];
function Ring({ radius, duration, people, reverse }: { radius: number; duration: number; people: typeof PEOPLE; reverse?: boolean }) {
const reduce = useReducedMotion();
const turn = reverse ? -360 : 360;
return (
<>
<div className="absolute rounded-full border border-dashed" style={{ width: radius * 2, height: radius * 2 }} />
<motion.div
className="absolute"
style={{ width: radius * 2, height: radius * 2 }}
animate={reduce ? undefined : { rotate: turn }}
transition={{ repeat: Infinity, ease: "linear", duration }}
>
{people.map((p, k) => {
const ang = (k / people.length) * Math.PI * 2;
return (
<div key={p.i} className="absolute" style={{ left: radius + Math.cos(ang) * radius, top: radius + Math.sin(ang) * radius }}>
<motion.span
className={cn(
"flex size-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 border-card text-[10px] font-semibold text-white shadow-md",
p.c,
)}
animate={reduce ? undefined : { rotate: -turn }}
transition={{ repeat: Infinity, ease: "linear", duration }}
>
{p.i}
</motion.span>
</div>
);
})}
</motion.div>
</>
);
}
function Orbit() {
return (
<div className="relative flex size-full min-h-[170px] items-center justify-center" aria-hidden>
<Ring radius={62} duration={28} people={PEOPLE.slice(0, 3)} />
<Ring radius={34} duration={18} people={PEOPLE.slice(3)} reverse />
<div className="relative z-10 flex size-11 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-fuchsia-500 text-white shadow-lg shadow-primary/30">
<Sparkles className="size-5" />
</div>
</div>
);
}
/* ---------- Typing assistant ---------- */
function Typing({ conversations }: { conversations: { prompt: string; answer: string }[] }) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.5 });
const [idx, setIdx] = React.useState(0);
const [chars, setChars] = React.useState(0);
const convo = conversations[idx % conversations.length];
React.useEffect(() => {
if (!inView) return;
const full = convo.answer.length;
const t = setTimeout(
() => {
if (chars < full) setChars((c) => c + 1);
else {
setChars(0);
setIdx((i) => (i + 1) % conversations.length);
}
},
chars === 0 ? 700 : chars < full ? 22 : 2600,
);
return () => clearTimeout(t);
}, [inView, chars, convo.answer.length, conversations.length]);
return (
<div ref={ref} className="flex size-full flex-col justify-start gap-2 px-4 pt-5" aria-hidden>
<motion.div
key={idx}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="ml-auto max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3 py-1.5 text-xs text-primary-foreground"
>
{convo.prompt}
</motion.div>
<div className="flex max-w-[92%] gap-2">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-fuchsia-500 text-white">
<Sparkles className="size-3" />
</span>
<p className="min-h-[2.5rem] rounded-2xl rounded-tl-md border bg-background px-3 py-1.5 text-xs leading-relaxed text-foreground">
{chars === 0 ? (
<span className="inline-flex gap-1 py-1">
{[0, 1, 2].map((d) => (
<motion.span
key={d}
className="size-1 rounded-full bg-muted-foreground"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ repeat: Infinity, duration: 1, delay: d * 0.15 }}
/>
))}
</span>
) : (
<>
{convo.answer.slice(0, chars)}
<motion.span
className="ml-px inline-block h-3 w-px translate-y-0.5 bg-foreground"
animate={{ opacity: [1, 0] }}
transition={{ repeat: Infinity, duration: 0.7, repeatType: "reverse" }}
/>
</>
)}
</p>
</div>
</div>
);
}
/* ---------- Toggles ---------- */
const FLAGS = ["New checkout", "Dark launch: search", "Spend alerts"];
function Toggles() {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.5 });
const [state, setState] = React.useState([true, false, true]);
React.useEffect(() => {
if (!inView) return;
let k = 0;
const order = [1, 0, 2, 1, 2, 0];
const id = setInterval(() => {
const i = order[k++ % order.length];
setState((s) => s.map((v, j) => (j === i ? !v : v)));
}, 1300);
return () => clearInterval(id);
}, [inView]);
return (
<div ref={ref} className="w-full space-y-2 px-5 pt-5" aria-hidden>
{FLAGS.map((f, i) => (
<div key={f} className="flex items-center gap-3 rounded-xl border bg-background px-3 py-2">
<span className={cn("size-1.5 rounded-full transition-colors", state[i] ? "bg-emerald-500" : "bg-muted-foreground/40")} />
<span className="flex-1 truncate text-xs font-medium text-foreground">{f}</span>
<span
className={cn(
"relative h-5 w-9 shrink-0 rounded-full transition-colors duration-300",
state[i] ? "bg-primary" : "bg-muted-foreground/25",
)}
>
<motion.span
className="absolute top-0.5 size-4 rounded-full bg-white shadow"
animate={{ left: state[i] ? 18 : 2 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</span>
</div>
))}
</div>
);
}