"use client";
import { ArrowRightIcon, FileTextIcon, MessageSquareIcon, SearchIcon, SparklesIcon, ZapIcon } from "lucide-react";
import { AnimatePresence, motion, MotionConfig, useInView, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefers = useReducedMotion();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted && Boolean(prefers);
}
export type FeatureDemo = "search" | "shortcuts" | "history" | "automation" | "insights";
export interface BentoFeature {
title: string;
description: string;
demo: FeatureDemo;
span?: 2 | 3 | 4 | 6;
}
export interface FeatureBentoProps {
eyebrow?: string;
title: ReactNode;
description?: ReactNode;
features: BentoFeature[];
className?: string;
}
const ease = [0.23, 1, 0.32, 1] as const;
const spring = { type: "spring", stiffness: 380, damping: 34 } as const;
const accent = "text-violet-600 dark:text-violet-400";
const docs = [
{ title: "Q3 roadmap", meta: "Product · edited 2h ago" },
{ title: "Onboarding checklist", meta: "People · edited yesterday" },
{ title: "Pricing page rewrite", meta: "Marketing · edited Mon" },
{ title: "Roadmap review notes", meta: "Product · edited Sep 30" },
{ title: "Launch plan: Atlas", meta: "Marketing · edited Sep 28" },
{ title: "Mobile app roadmap", meta: "Engineering · edited Sep 24" },
{ title: "Launch retro, spring", meta: "Marketing · edited Jun 3" },
{ title: "Onboarding emails v2", meta: "Lifecycle · edited Aug 14" },
];
const queries = ["roadmap", "launch", "onboarding"];
function Highlight({ text, query }: { text: string; query: string }) {
const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
if (at < 0) return <>{text}>;
return (
<>
{text.slice(0, at)}
{text.slice(at, at + query.length)}
{text.slice(at + query.length)}
>
);
}
function SearchDemo({ active }: { active: boolean }) {
const [q, setQ] = useState(0);
const [typed, setTyped] = useState(queries[0].length);
useEffect(() => {
if (!active) return;
let cancelled = false;
let timer: number | undefined;
const run = (index: number, count: number) => {
if (cancelled) return;
const word = queries[index];
if (count <= word.length) {
setQ(index);
setTyped(count);
timer = window.setTimeout(() => run(index, count + 1), count === 0 ? 260 : 55);
} else {
timer = window.setTimeout(() => run((index + 1) % queries.length, 0), 1500);
}
};
timer = window.setTimeout(() => run((q + 1) % queries.length, 0), 350);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
const query = queries[q].slice(0, typed);
const results = docs.filter((d) => d.title.toLowerCase().includes(query.toLowerCase())).slice(0, 3);
return (
{query}
{results.length} {results.length === 1 ? "doc" : "docs"}
{results.map((doc) => (
{doc.meta}
))}
);
}
const combos = [
{ keys: ["⌘", "K"], label: "Open anything" },
{ keys: ["⌘", "⇧", "P"], label: "Publish page" },
{ keys: ["G", "I"], label: "Go to inbox" },
];
function Key({ children, down }: { children: ReactNode; down: boolean }) {
return (
{children}
);
}
function ShortcutDemo({ active }: { active: boolean }) {
const [combo, setCombo] = useState(0);
const [pressed, setPressed] = useState(0);
useEffect(() => {
if (!active) {
setPressed(0);
return;
}
let cancelled = false;
const timers: number[] = [];
const later = (fn: () => void, ms: number) => timers.push(window.setTimeout(() => !cancelled && fn(), ms));
const play = (index: number) => {
setCombo(index);
setPressed(0);
const n = combos[index].keys.length;
for (let k = 1; k <= n; k++) later(() => setPressed(k), 200 + k * 170);
later(() => setPressed(0), 200 + n * 170 + 900);
later(() => play((index + 1) % combos.length), 200 + n * 170 + 1400);
};
play((combo + 1) % combos.length);
return () => {
cancelled = true;
timers.forEach(window.clearTimeout);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
const current = combos[combo];
const fired = pressed === current.keys.length;
return (
{current.keys.map((key, i) => (
i} key={`${combo}-${key}`}>
{key}
))}
);
}
const versions = [
{ label: "v9", who: "Iris", when: "Sep 12", lines: [0.9, 0.6, 0.75, 0.3] },
{ label: "v10", who: "Marc", when: "Sep 19", lines: [0.9, 0.82, 0.5, 0.64] },
{ label: "v11", who: "Maya", when: "Oct 2", lines: [0.7, 0.88, 0.62, 0.8] },
{ label: "v12", who: "Iris", when: "Oct 9", lines: [0.96, 0.7, 0.86, 0.45] },
{ label: "v13", who: "You", when: "Today", lines: [0.8, 0.94, 0.58, 0.72] },
];
function HistoryDemo({ active }: { active: boolean }) {
const [v, setV] = useState(versions.length - 1);
useEffect(() => {
if (!active) {
setV(versions.length - 1);
return;
}
let i = 0;
setV(0);
const id = window.setInterval(() => {
i = (i + 1) % versions.length;
setV(i);
}, 900);
return () => window.clearInterval(id);
}, [active]);
const version = versions[v];
return (
{version.label}
{version.who} · {version.when}
{version.lines.map((w, i) => (
))}
{versions.map((item, i) => (
))}
);
}
const steps = [
{ icon: MessageSquareIcon, label: "Form reply" },
{ icon: SparklesIcon, label: "Summarise" },
{ icon: ZapIcon, label: "Post to #sales" },
];
function AutomationDemo({ active }: { active: boolean }) {
const reduce = useReducedMotionSafe();
const [lit, setLit] = useState(-1);
useEffect(() => {
if (!active) {
setLit(-1);
return;
}
let i = 0;
setLit(0);
const id = window.setInterval(() => {
i = (i + 1) % (steps.length + 1);
setLit(i === steps.length ? -1 : i);
}, 650);
return () => window.clearInterval(id);
}, [active]);
return (
{steps.map((step, i) => (
i && "text-muted-foreground",
)}
transition={{ duration: 0.18, ease }}
>
{step.label}
= i ? "bg-emerald-500" : "bg-border",
)}
/>
{i < steps.length - 1 && (
i ? "0%" : "-100%" }}
className="absolute inset-0 bg-violet-500"
initial={false}
transition={{ duration: 0.2, ease }}
/>
)}
))}
);
}
const rest = [38, 52, 44, 66, 58, 74, 88];
const days = ["M", "T", "W", "T", "F", "S", "S"];
function InsightsDemo({ active }: { active: boolean }) {
const [bars, setBars] = useState(rest);
useEffect(() => {
if (!active) {
setBars(rest);
return;
}
const shuffle = () => setBars(rest.map((b) => Math.max(18, Math.min(100, b + Math.round((Math.random() - 0.4) * 40)))));
shuffle();
const id = window.setInterval(shuffle, 1100);
return () => window.clearInterval(id);
}, [active]);
const total = Math.round(bars.reduce((a, b) => a + b, 0) * 3.1);
return (
Readers this week
{total.toLocaleString("en-US")}
{bars.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed days
{days[i]}
))}
);
}
const demos: Record ReactNode> = {
search: SearchDemo,
shortcuts: ShortcutDemo,
history: HistoryDemo,
automation: AutomationDemo,
insights: InsightsDemo,
};
const spans: Record = {
2: "md:col-span-2",
3: "md:col-span-3",
4: "sm:col-span-2 md:col-span-4",
6: "sm:col-span-2 md:col-span-6",
};
function Tile({ feature, index }: { feature: BentoFeature; index: number }) {
const reduce = useReducedMotionSafe();
const ref = useRef(null);
const inView = useInView(ref, { amount: 0.7 });
const [hovered, setHovered] = useState(false);
const [touch, setTouch] = useState(false);
useEffect(() => {
setTouch(window.matchMedia("(hover: none)").matches);
}, []);
const active = !reduce && (hovered || (touch && inView));
const Demo = demos[feature.demo];
return (
e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => setHovered(false)}
ref={ref}
transition={{ duration: 0.5, ease, delay: Math.min(index, 5) * 0.04 }}
viewport={{ once: true, amount: 0.3 }}
whileInView={{ opacity: 1, y: 0 }}
>
{feature.title}
{feature.description}
);
}
export function FeatureBento({ eyebrow, title, description, features, className }: FeatureBentoProps) {
return (
{features.map((feature, i) => (
))}
);
}