Fazekit

Code

"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>
  );
}

More in Bento Grids

View all →