"use client"; import { ArrowUpRight, Search, X } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion, } from "motion/react"; import { type CSSProperties, type ReactNode, useId, useMemo, useState } from "react"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface Integration { id: string; name: string; category: string; description?: ReactNode; /** Icon or logo node; sized to fit a small tile. */ icon: ReactNode; href?: string; /** Small label on the card, e.g. "Beta" or "New". */ badge?: ReactNode; /** Extra text matched by search (aliases, keywords). */ keywords?: string[]; } export interface IntegrationsSectionProps { integrations: Integration[]; /** The hub at the center of the orbit (your product's logo). */ centerLogo: ReactNode; /** Accessible name for the hub. Default "Our product". */ centerLabel?: string; /** Ids shown on the orbit, inner ring first. Defaults to the first 14 integrations. */ orbitIds?: string[]; /** Icons on the inner ring; the rest go on the outer ring. Default 5. */ innerCount?: number; /** Seconds per inner-ring revolution; the outer ring is 1.5× slower and turns the other way. Default 40. */ speed?: number; /** Category chips in order. Derived from `integrations` when omitted. */ categories?: string[]; /** Label of the chip that shows everything. Default "All". */ allLabel?: string; /** Controlled category ("" or `allLabel` means all). */ category?: string; defaultCategory?: string; onCategoryChange?: (category: string) => void; /** Show the search field. Default true. */ searchable?: boolean; searchPlaceholder?: string; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; className?: string; /** Classes for the scrolling card list (e.g. a max height). */ listClassName?: string; } interface OrbitIcon { integration: Integration; angle: number; } function spread(items: Integration[], offset: number): OrbitIcon[] { return items.map((integration, index) => ({ integration, angle: offset + (360 / Math.max(1, items.length)) * index, })); } /** Percent position of a point on a circle inscribed in its box. */ function onCircle(angle: number): CSSProperties { const rad = (angle * Math.PI) / 180; // Rounded so server and client stringify the same value. const round = (n: number) => Math.round(n * 1000) / 1000; return { left: `${round(50 + 50 * Math.cos(rad))}%`, top: `${round(50 + 50 * Math.sin(rad))}%`, }; } function OrbitRing({ icons, inset, duration, reverse, animated, }: { icons: OrbitIcon[]; inset: string; duration: number; reverse: boolean; animated: boolean; }) { // The whole HTML ring rotates (CSS `spin`); each icon runs the same spin // in the opposite direction at the same duration, so it stays upright. const spin: CSSProperties = animated ? { animationDuration: `${duration}s`, animationDirection: reverse ? "reverse" : "normal", } : {}; const counter: CSSProperties = animated ? { animationDuration: `${duration}s`, animationDirection: reverse ? "normal" : "reverse", } : {}; return (
{icons.map(({ integration, angle }) => (
{integration.icon}
))}
); } function Orbit({ center, centerLabel, inner, outer, speed, reduce, }: { center: ReactNode; centerLabel: string; inner: OrbitIcon[]; outer: OrbitIcon[]; speed: number; reduce: boolean | null; }) { const animated = !reduce; return (
icon.integration.name) .join(", ")}`} className="group/orbit relative mx-auto aspect-square w-full max-w-[19rem]" > {/* Soft halo behind the hub. */}
{outer.length ? ( ) : null} {inner.length ? ( ) : null}
{animated ? ( ) : null}
{center}
); } function matches(integration: Integration, query: string) { if (!query) return true; const haystack = [ integration.name, integration.category, typeof integration.description === "string" ? integration.description : "", ...(integration.keywords ?? []), ] .join(" ") .toLowerCase(); return query .toLowerCase() .split(/\s+/) .filter(Boolean) .every((word) => haystack.includes(word)); } /** * Integrations section: a hub-and-spoke orbit (your logo at the center, * integration icons riding one or two slowly counter-rotating rings, paused * on hover and still under reduced motion) beside a searchable grid of * integration cards. Category chips filter the grid with a gliding pill, and * cards enter, leave and reflow with layout animation. */ export function IntegrationsSection({ integrations, centerLogo, centerLabel = "Our product", orbitIds, innerCount = 5, speed = 40, categories: categoriesProp, allLabel = "All", category: categoryProp, defaultCategory, onCategoryChange, searchable = true, searchPlaceholder = "Search integrations", eyebrow, heading, description, className, listClassName, }: IntegrationsSectionProps) { const reduce = useReducedMotion(); const pillId = useId(); const searchId = useId(); const [query, setQuery] = useState(""); const [internalCategory, setInternalCategory] = useState( defaultCategory ?? allLabel, ); const rawCategory = categoryProp ?? internalCategory; const category = rawCategory === "" ? allLabel : rawCategory; const categories = useMemo(() => { if (categoriesProp) return categoriesProp; const seen: string[] = []; for (const item of integrations) { if (!seen.includes(item.category)) seen.push(item.category); } return seen; }, [categoriesProp, integrations]); const { inner, outer } = useMemo(() => { const picked = orbitIds ? orbitIds .map((id) => integrations.find((item) => item.id === id)) .filter((item): item is Integration => Boolean(item)) : integrations.slice(0, 14); return { inner: spread(picked.slice(0, innerCount), -90), // Offset half a slot so outer icons sit between inner ones. outer: spread( picked.slice(innerCount), -90 + 180 / Math.max(1, picked.length - innerCount), ), }; }, [integrations, orbitIds, innerCount]); const visible = useMemo( () => integrations.filter( (item) => (category === allLabel || item.category === category) && matches(item, query.trim()), ), [integrations, category, allLabel, query], ); const pick = (next: string) => { if (next === category) return; if (categoryProp === undefined) setInternalCategory(next); onCategoryChange?.(next); }; const header = eyebrow || heading || description ? (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ? (

{description}

) : null}
) : null; const chips = [allLabel, ...categories]; return (
{header}
{searchable ? (
setQuery(event.target.value)} placeholder={searchPlaceholder} autoComplete="off" className="h-10 w-full rounded-full border border-border bg-card pr-10 pl-10 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-search-cancel-button]:hidden" /> {query ? ( ) : null}
) : null}
Filter by category {chips.map((chip) => { const selected = chip === category; return ( ); })}

{`${visible.length} integration${visible.length === 1 ? "" : "s"}`}

{/* Layout animation is the point here: cards reflow as the filter changes, so `layout` (position/size) is intended. */} {visible.map((item) => ( {item.icon} {item.href ? ( {item.name} ) : ( {item.name} )} {item.badge ? ( {item.badge} ) : null} {item.href ? ( ) : null} {item.category} {item.description ? ( {item.description} ) : null} ))} {visible.length === 0 ? ( {query ? `No integrations match "${query.trim()}".` : "No integrations in this category yet."} ) : null}
); }